Bug/document linking (#688)

* feat: enhance supplier invoice payment process and settings handling

- Implemented linking of invoice documents to journal entries for cash payments in the supplier invoice payment process.
- Refactored settings fetching logic to improve loading states and error handling across various settings components.
- Introduced a new SettingsLoadError component to handle cases where settings fetch fails or returns no data.
- Updated useSettings hook to manage loading and error states more effectively, allowing for retries on failure.
- Enhanced tests for supplier invoice creation to ensure document IDs are persisted correctly for cash method payments.

* feat(salary): enable monthly salary edits in draft runs and handle zero-total declarations
This commit is contained in:
Mattsson
2026-06-08 07:37:24 +02:00
committed by GitHub
parent 32af88f9c4
commit 809120c4b8
24 changed files with 1244 additions and 131 deletions
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
// ── Mocks ────────────────────────────────────────────────────
// The route is wrapped in withRouteContext (auth via requireAuth, company via
// getActiveCompanyId, write-gate via requireWritePermission). createSalaryRunEntries
// is mocked so we can assert it is NOT called for a nollkörning (zero-amount run),
// where the bookkeeping engine would otherwise reject a zero voucher.
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/events', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
}))
vi.mock('@/lib/salary/salary-entries', () => ({ createSalaryRunEntries: vi.fn() }))
import { POST } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { eventBus } from '@/lib/events'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const makePaidRun = (overrides = {}) => ({
id: 'run-1',
company_id: 'company-1',
status: 'paid',
period_year: 2026,
period_month: 4,
payment_date: '2026-04-25',
voucher_series: 'A',
total_gross: 0,
total_tax: 0,
total_net: 0,
total_avgifter: 0,
total_vacation_accrual: 0,
...overrides,
})
describe('POST /api/salary/runs/[id]/book — nollkörning', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('books an empty-roster zero-total run without creating journal entries', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: makePaidRun() }, // salary_runs (paid) lookup
{ data: [] }, // salary_run_employees roster (empty)
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs update → booked
])
const request = createMockRequest('/api/salary/runs/run-1/book', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(
response,
)
expect(status).toBe(200)
expect(body.data.status).toBe('booked')
expect(createSalaryRunEntries).not.toHaveBeenCalled()
expect(eventBus.emit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'salary_run.booked' }),
)
})
it('books a run whose employees were all set to 0 kr as a nollkörning (no vouchers)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: makePaidRun() }, // salary_runs (paid) lookup
{ data: [{ employee_id: 'e1', gross_salary: 0, line_items: [] }] }, // roster present but zero
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs update → booked
])
const request = createMockRequest('/api/salary/runs/run-1/book', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(createSalaryRunEntries).not.toHaveBeenCalled()
})
})
+42 -3
View File
@@ -36,8 +36,47 @@ export const POST = withRouteContext(
.select('*, employee:employees(employment_type), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
if (empError || !employees || employees.length === 0) {
return errorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', opLog, { requestId })
if (empError) {
return errorResponse(empError, opLog, { requestId })
}
const roster = employees ?? []
// Nollkörning: a run with no monetary effect (employees set to 0 kr, or no
// roster at all) has nothing to post. The bookkeeping engine forbids
// zero-amount vouchers (every entry must balance with debit & credit > 0),
// so we skip journal-entry creation entirely and just advance to 'booked'.
// The AGI nolldeklaration is then the only artefact for the period.
const nothingToBook =
Math.round((run.total_gross ?? 0) * 100) === 0 &&
Math.round((run.total_tax ?? 0) * 100) === 0 &&
Math.round((run.total_avgifter ?? 0) * 100) === 0 &&
Math.round((run.total_vacation_accrual ?? 0) * 100) === 0
if (nothingToBook) {
const { data: bookedRun, error: updateError } = await supabase
.from('salary_runs')
.update({
status: 'booked',
booked_at: new Date().toISOString(),
booked_by: user.id,
})
.eq('id', id)
.eq('company_id', companyId)
.select()
.single()
if (updateError) {
return errorResponse(updateError, opLog, { requestId })
}
await eventBus.emit({
type: 'salary_run.booked',
payload: { salaryRunId: id, entryIds: [], userId: user.id, companyId: companyId! },
})
opLog.info('salary run booked as nollkörning (no journal entries)', { salaryRunId: id })
return NextResponse.json({ data: bookedRun })
}
try {
@@ -56,7 +95,7 @@ export const POST = withRouteContext(
total_net: run.total_net,
total_avgifter: run.total_avgifter,
total_vacation_accrual: run.total_vacation_accrual,
employees: employees.map((sre) => ({
employees: roster.map((sre) => ({
employee_id: sre.employee_id,
employment_type: sre.employee?.employment_type || 'employee',
gross_salary: sre.gross_salary,
@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
// ── Mocks ────────────────────────────────────────────────────
// This route hand-rolls auth (createClient + getUser) rather than
// withRouteContext, so we mock createClient and the write/company helpers.
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({ createClient: () => mockCreateClient() }))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/salary/personnummer', () => ({
decryptPersonnummer: (x: string) => x,
maskPersonnummer: (x: string) => x,
}))
import { PATCH } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function authedSupabase() {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
mockCreateClient.mockResolvedValue(supabase)
return { supabase, enqueueMany }
}
describe('PATCH /api/salary/runs/[id]/employees/[employeeId] — monthly salary edit', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('updates the per-run monthly salary while the run is a draft', async () => {
const { enqueueMany } = authedSupabase()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
{
data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 30000 },
}, // salary_run_employees update
{ data: null }, // salary_line_items Grundlön refresh
])
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
method: 'PATCH',
body: { monthly_salary: 30000 },
})
const response = await PATCH(
request,
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
)
const { status, body } = await parseJsonResponse<{ data: { monthly_salary: number } }>(response)
expect(status).toBe(200)
expect(body.data.monthly_salary).toBe(30000)
})
it('allows a zero monthly salary (nollkörning) on a draft', async () => {
const { enqueueMany } = authedSupabase()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } },
{ data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 0 } },
{ data: null },
])
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
method: 'PATCH',
body: { monthly_salary: 0 },
})
const response = await PATCH(
request,
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('rejects a monthly salary edit when the run is no longer a draft', async () => {
const { enqueueMany } = authedSupabase()
enqueueMany([
{ data: { id: 'run-1', status: 'review' } }, // not a draft
])
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
method: 'PATCH',
body: { monthly_salary: 30000 },
})
const response = await PATCH(
request,
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('utkast')
})
it('rejects mixing a salary edit with a tax override in one request', async () => {
authedSupabase()
const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', {
method: 'PATCH',
body: { monthly_salary: 30000, tax_withheld_override: 5000, reason: 'test' },
})
const response = await PATCH(
request,
createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }),
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
})
@@ -53,13 +53,19 @@ export async function GET(
}
/**
* Apply per-employee override on tax/avgifter (advanced mode).
* Per-employee edits within a salary run. Two operations, gated to different
* statuses (never combined in one request):
*
* Only allowed in `review` status — the calculation engine has run, but the
* run hasn't been approved or booked yet. After approval, vouchers and AGI
* lock in the effective values; further changes require correction flows.
* • monthly_salary — set this month's base salary for the employee. Allowed
* only in `draft`. 0 is valid (an intentional nollkörning). The engine reads
* this per-run value (not the employee master) when the run is calculated, so
* each month's gross can differ without touching the employee's standard pay.
*
* Pass `null` for any field to clear a previously-set override.
* • tax_withheld_override / avgifter_*_override — manual tax/avgifter
* adjustment (advanced mode). Allowed only in `review`: the engine has run
* but the run isn't approved/booked. After approval, vouchers and AGI lock in
* the effective values; further changes require correction flows. Pass `null`
* for any override field to clear it.
*/
export async function PATCH(
request: Request,
@@ -78,7 +84,24 @@ export async function PATCH(
const parsed = await validateBody(request, SalaryEmployeeOverrideSchema)
if (!parsed.success) return parsed.response
// Gate on run status. Override is only valid mid-review.
// Two distinct operations share this endpoint, gated to different statuses:
// • monthly_salary → edit this month's base salary (draft only)
// • *_override → manual tax/avgifter adjustment (review only)
// They must not be mixed in one request.
const wantsSalaryEdit = parsed.data.monthly_salary !== undefined
const wantsOverride =
parsed.data.tax_withheld_override !== undefined ||
parsed.data.avgifter_amount_override !== undefined ||
parsed.data.avgifter_basis_override !== undefined ||
parsed.data.reason !== undefined
if (wantsSalaryEdit && wantsOverride) {
return NextResponse.json(
{ error: 'Kan inte ändra månadslön och skatte-/avgiftsjustering i samma anrop.' },
{ status: 400 },
)
}
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
@@ -87,6 +110,48 @@ export async function PATCH(
.single()
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
// ── Draft-stage edit of this month's base salary ──
if (wantsSalaryEdit) {
if (run.status !== 'draft') {
return NextResponse.json(
{ error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' },
{ status: 400 },
)
}
const monthly = Math.round((parsed.data.monthly_salary as number) * 100) / 100
const { data: sre, error: sreErr } = await supabase
.from('salary_run_employees')
.update({ monthly_salary: monthly })
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.eq('company_id', companyId)
.select('id, employment_degree, salary_type, monthly_salary')
.maybeSingle()
if (sreErr) return NextResponse.json({ error: sreErr.message }, { status: 400 })
if (!sre) {
return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 })
}
// Keep the displayed 'Grundlön' line consistent with the new salary. This is
// display-only — the engine recomputes baseSalary from monthly_salary at
// calc time — but it avoids a stale row before the user clicks Beräkna.
if (sre.salary_type === 'monthly') {
const baseAmount = Math.round(monthly * (sre.employment_degree / 100) * 100) / 100
await supabase
.from('salary_line_items')
.update({ amount: baseAmount })
.eq('salary_run_employee_id', sre.id)
.eq('company_id', companyId)
.eq('item_type', 'monthly_salary')
}
return NextResponse.json({ data: sre })
}
// ── Review-stage override of tax/avgifter ──
if (run.status !== 'review') {
return NextResponse.json(
{ error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' },
@@ -35,7 +35,18 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
mockCreateSupplierInvoiceCashEntry(...args),
}))
vi.mock('@/lib/core/documents/document-service', async () => {
const actual = await vi.importActual<typeof import('@/lib/core/documents/document-service')>(
'@/lib/core/documents/document-service'
)
return {
...actual,
linkToJournalEntry: vi.fn(),
}
})
import { eventBus } from '@/lib/events'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { POST } from '../route'
@@ -241,6 +252,88 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
})
it('cash method: links the inbox document to the cash payment verifikat (BFL 5 kap 6 §)', async () => {
const supplier = makeSupplier()
const invoice = makeSupplierInvoice({
id: 'si-1',
status: 'approved',
total: 10000,
remaining_amount: 10000,
paid_amount: 0,
document_id: 'doc-1',
supplier,
items: [],
})
enqueue({ data: invoice, error: null })
// Duplicate-payment guard: no candidate transactions
enqueue({ data: [], error: null })
enqueue({ data: { accounting_method: 'cash' }, error: null })
mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-cash' })
// Update invoice (CAS guard: returns matched row)
enqueue({ data: [{ id: 'si-1' }], error: null })
// Record payment
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
method: 'POST',
body: {},
})
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
const { status, body } = await parseJsonResponse<{ journal_entry_id: string }>(response)
expect(status).toBe(200)
expect(body.journal_entry_id).toBe('je-cash')
// The cash entry is the ONLY booking, so its underlag must hang on it.
expect(linkToJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'doc-1',
'je-cash',
)
})
it('accrual method: does NOT re-link the document at payment (stays on the registration verifikat)', async () => {
const supplier = makeSupplier()
const invoice = makeSupplierInvoice({
id: 'si-1',
status: 'approved',
total: 10000,
remaining_amount: 10000,
paid_amount: 0,
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
supplier,
items: [],
})
enqueue({ data: invoice, error: null })
// Duplicate-payment guard: no candidate transactions
enqueue({ data: [], error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-pay' })
// Update invoice (CAS guard: returns matched row)
enqueue({ data: [{ id: 'si-1' }], error: null })
// Record payment
enqueue({ data: null, error: null })
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
method: 'POST',
body: {},
})
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// The document already lives on the registration verifikat — re-linking
// here would move it off the primary booking.
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('returns 500 when journal entry creation fails (blocking — GL must succeed for payment)', async () => {
const supplier = makeSupplier()
const invoice = makeSupplierInvoice({
@@ -7,6 +7,7 @@ import {
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { validateBody } from '@/lib/api/validate'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -279,6 +280,27 @@ export const POST = withRouteContext(
opLog.warn('failed to record supplier_invoice_payments row', paymentError)
}
// Under kontantmetoden the cash payment entry is the ONLY booking of the
// affärshändelse, so its underlag (the document from the inbox) must hang on
// THIS verifikat per BFL 5 kap 6 §. Under faktureringsmetoden the document
// is already linked to the registration verifikat at receipt — re-linking
// here would move it off that primary booking, so we attach only for the
// cash entry. Non-fatal: the payment is already committed and immutable, so
// a link failure is logged and the invoice stays usable (mirrors the
// registration-time linking in commitCreateSupplierInvoiceFromInbox).
const invoiceDocumentId = (invoice as { document_id?: string | null }).document_id
if (useCashEntry && invoiceDocumentId && journalEntryId) {
try {
await linkToJournalEntry(supabase, companyId!, invoiceDocumentId, journalEntryId)
} catch (linkErr) {
opLog.warn('failed to link supplier invoice document to cash payment JE', {
documentId: invoiceDocumentId,
journalEntryId,
error: linkErr instanceof Error ? linkErr.message : String(linkErr),
})
}
}
try {
await eventBus.emit({
type: 'supplier_invoice.paid',