Files
accounted/app/api/rot-rut/__tests__/routes.test.ts
T
MattssonandClaude Fable 5 cbfb2201ff fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884) (#1891)
* fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884)

Four silent drop paths made a paid RUT invoice invisible in the begaran
dialog (neither eligible nor blocked), and the empty list hid the year
picker so the dialog looked dead:

1. deduction lines without a header deduction_total: a second line-based
   candidate query now finds them and they block as DEDUCTION_TOTAL_MISSING
   (also at file generation: the 1513 receivable was never booked).
2. partially_paid with the customer share settled: remaining_amount = 0
   (total - paid_amount - deduction_total, migration 20260817191708) now
   counts as paid in evaluateInvoiceForFile; a genuine partial blocks as
   NOT_PAID with the outstanding amount.
3. NO_DEDUCTION_OF_TYPE is no longer filtered out of blocked: the message
   points at the other type, and the dialog's empty state adds a
   switch-type hint.
4. invoices held by a generated/submitted begaran block as
   ALREADY_REQUESTED naming the request; decided requests stay omitted
   (finished business, visible in the history list).

The dialog keeps the year picker rendered when the list is empty (current
year as inert fallback) and opens the blocked list by default when nothing
is eligible.

Fixes #1884

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

* fix(rot-rut): skeptic hardening: decided requests vanish on both tabs, customer share derived from header fields (#1884)

Two skeptic refutations against the frozen PR head:

1. Regression: the wrong-type branch ran before the active-request lookup,
   so invoices of the OTHER type whose begaran was already decided
   resurfaced forever as NO_DEDUCTION_OF_TYPE in the opposite tab's blocked
   list, and the empty-state hint pointed at a tab where they never appear.
   The decided-request skip now runs first, on every tab.

2. Correctness: the paid gate and the NOT_PAID message trusted
   remaining_amount, but payment-sync's storno path recomputes it WITHOUT
   subtracting deduction_total, so the stored column can carry Skatteverkets
   1513 share and the dialog could assert a wrong customer-outstanding
   figure. The gate now derives the customer share as
   total - paid_amount - deduction_total (the buildInvoiceWriteData /
   migration 20260817191708 formula) from fields every settlement path
   maintains.

Tests pin both: decided+wrong-type omitted from both lists, corrupted
remaining still classified and reported from the derived share.

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

* docs(rot-rut): align CANDIDATE_STATUSES comment with the derived-share gate (#1884)

The skeptic-hardening commit moved the paid gate off remaining_amount to
the derived customer share (total - paid_amount - deduction_total); the
comment still named remaining_amount as the signal.

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

* fix(rot-rut): explicit decided-status set + correction-path wording (#1884)

Swedish accounting review findings on the candidate list:

1. The decided-begaran skip inferred 'decided' by exclusion (anything not
   generated/submitted), so a future request status would make an invoice
   vanish from both lists, exactly the silent drop the module forbids.
   DECIDED_REQUEST_STATUSES now names paid/partially_paid; any other
   status held by a request lands in blocked as ALREADY_REQUESTED with a
   generic message. Test pins it.

2. The DEDUCTION_TOTAL_MISSING message said only 'ratta fakturan', which
   could read as an invitation to edit a booked invoice directly. The
   invoice edit route already refuses sent/paid/booked invoices, and the
   message now names the sanctioned path: drafts edit directly, sent or
   paid invoices are corrected via credit note + new invoice.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:24:01 +02:00

545 lines
19 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
makeInvoice,
} from '@/tests/helpers'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import type { Invoice, InvoiceItem } from '@/types'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
const mockCreatePayoutEntry = vi.fn()
vi.mock('@/lib/bookkeeping/rot-rut-entries', () => ({
createRotRutPayoutEntry: (...args: unknown[]) => mockCreatePayoutEntry(...args),
}))
import { GET as eligibleGET } from '../eligible/route'
import { POST as payoutFilePOST } from '../payout-file/route'
import { GET as requestsGET } from '../payout-requests/route'
import { PATCH as requestPATCH } from '../payout-requests/[id]/route'
import { POST as settlePOST } from '../payout-requests/[id]/settle/route'
const INVOICE_ID = '11111111-1111-4111-8111-111111111111'
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
// Skatteverket official example personnummer (synthetic).
const PNR = '198406012388'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function makeRotItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: INVOICE_ID,
sort_order: 0,
description: 'Snickeri',
quantity: 1,
unit: 'tim',
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
labor_hours: 25,
work_type: 'BYGG',
housing_designation: 'Stockholm Vasastan 1:23',
apartment_number: null,
brf_org_number: null,
created_at: '2026-06-01T00:00:00Z',
...overrides,
}
}
function makePaidRotInvoice(overrides: Partial<Invoice> = {}): Invoice {
return makeInvoice({
id: INVOICE_ID,
status: 'paid',
paid_at: '2026-06-20T10:00:00Z',
deduction_total: 3000,
deduction_personnummer_encrypted: encryptPersonnummer(PNR),
items: [makeRotItem()],
...overrides,
})
}
function makePayoutRequestRow(overrides: Record<string, unknown> = {}) {
return {
id: REQUEST_ID,
company_id: 'company-1',
user_id: 'user-1',
deduction_type: 'rot',
name: 'ROT 2026-07-02',
status: 'generated',
requested_total: 3000,
decided_total: null,
file_name: 'rot_begaran_2026-07-02.xml',
file_document_id: null,
settlement_journal_entry_id: null,
submitted_at: null,
decided_at: null,
created_at: '2026-07-02T00:00:00Z',
updated_at: '2026-07-02T00:00:00Z',
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
})
describe('GET /api/rot-rut/eligible', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
expect(response.status).toBe(401)
})
it('splits invoices into eligible and blocked', async () => {
const good = makePaidRotInvoice()
const missingHours = makePaidRotInvoice({
id: '33333333-3333-4333-8333-333333333333',
invoice_number: 'F-BAD',
items: [makeRotItem({ labor_hours: null })],
})
enqueue({ data: [good, missingHours] }) // by header total
enqueue({ data: [] }) // by deduction lines
enqueue({ data: [] }) // no active request items
const response = await eligibleGET(
createMockRequest('/api/rot-rut/eligible', { searchParams: { type: 'rot' } }),
)
const { status, body } = await parseJsonResponse<{
data: { eligible: Array<{ invoice_id: string; begart_belopp: number }>; blocked: Array<{ code: string }> }
}>(response)
expect(status).toBe(200)
expect(body.data.eligible).toHaveLength(1)
expect(body.data.eligible[0].invoice_id).toBe(INVOICE_ID)
expect(body.data.eligible[0].begart_belopp).toBe(3000)
expect(body.data.blocked).toHaveLength(1)
expect(body.data.blocked[0].code).toBe('MISSING_HOURS')
})
it('surfaces invoices held by an in-flight request as ALREADY_REQUESTED', async () => {
enqueue({ data: [makePaidRotInvoice()] })
enqueue({ data: [] })
enqueue({ data: [{ invoice_id: INVOICE_ID, request: { id: 'r', name: 'ROT juli', status: 'submitted', company_id: 'company-1' } }] })
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
const { body } = await parseJsonResponse<{
data: { eligible: unknown[]; blocked: Array<{ code: string; message: string }> }
}>(response)
expect(body.data.eligible).toHaveLength(0)
expect(body.data.blocked).toHaveLength(1)
expect(body.data.blocked[0].code).toBe('ALREADY_REQUESTED')
expect(body.data.blocked[0].message).toContain('ROT juli')
})
it('hides invoices whose request is already decided', async () => {
enqueue({ data: [makePaidRotInvoice()] })
enqueue({ data: [] })
enqueue({ data: [{ invoice_id: INVOICE_ID, request: { id: 'r', name: 'ROT juli', status: 'paid', company_id: 'company-1' } }] })
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
const { body } = await parseJsonResponse<{
data: { eligible: unknown[]; blocked: unknown[] }
}>(response)
expect(body.data.eligible).toHaveLength(0)
expect(body.data.blocked).toHaveLength(0)
})
it('returns 500 on database error', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
expect(response.status).toBe(500)
})
})
describe('POST /api/rot-rut/payout-file', () => {
const validBody = { deduction_type: 'rot', invoice_ids: [INVOICE_ID] }
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await payoutFilePOST(
createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }),
)
expect(response.status).toBe(401)
})
it('returns 400 on invalid body', async () => {
const response = await payoutFilePOST(
createMockRequest('/api/rot-rut/payout-file', {
method: 'POST',
body: { deduction_type: 'gront', invoice_ids: [] },
}),
)
expect(response.status).toBe(400)
})
it('generates the file, records the request and archives the document', async () => {
enqueue({ data: [makePaidRotInvoice()] }) // invoices fetch
enqueue({ data: makePayoutRequestRow() }) // request insert
enqueue({ data: null }) // items insert
enqueue({ data: null }) // file_document_id update
const response = await payoutFilePOST(
createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }),
)
const { status, body } = await parseJsonResponse<{
data: { xml: string; file_name: string; arenden: unknown[]; request: { id: string } }
}>(response)
expect(status).toBe(200)
expect(body.data.xml).toContain('<ns2:RotBegaran>')
expect(body.data.xml).toContain(`<ns2:Kopare>${PNR}</ns2:Kopare>`)
expect(body.data.arenden).toHaveLength(1)
expect(body.data.request.id).toBe(REQUEST_ID)
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
})
it('rejects all-or-nothing when a selected invoice is blocked', async () => {
enqueue({
data: [
makePaidRotInvoice(),
makePaidRotInvoice({
id: '33333333-3333-4333-8333-333333333333',
status: 'sent',
}),
],
})
const response = await payoutFilePOST(
createMockRequest('/api/rot-rut/payout-file', {
method: 'POST',
body: {
deduction_type: 'rot',
invoice_ids: [INVOICE_ID, '33333333-3333-4333-8333-333333333333'],
},
}),
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { blockers: Array<{ code: string }> } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ROT_RUT_INVOICES_BLOCKED')
})
it('rejects a file that mixes payment years', async () => {
const otherInvoiceId = '33333333-3333-4333-8333-333333333333'
enqueue({
data: [
makePaidRotInvoice(),
makePaidRotInvoice({
id: otherInvoiceId,
invoice_number: 'F-2025',
paid_at: '2025-12-30T10:00:00Z',
}),
],
})
const response = await payoutFilePOST(
createMockRequest('/api/rot-rut/payout-file', {
method: 'POST',
body: { deduction_type: 'rot', invoice_ids: [INVOICE_ID, otherInvoiceId] },
}),
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { blockers: Array<{ code: string }> } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ROT_RUT_INVOICES_BLOCKED')
expect(body.error.details?.blockers).toEqual([
expect.objectContaining({ invoice_id: otherInvoiceId, code: 'MIXED_PAYMENT_YEARS' }),
])
})
it('returns 404 when an invoice id does not belong to the company', async () => {
enqueue({ data: [] })
const response = await payoutFilePOST(
createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }),
)
expect(response.status).toBe(404)
})
it('maps the double-request trigger to 409 and rolls back the header row', async () => {
enqueue({ data: [makePaidRotInvoice()] })
enqueue({ data: makePayoutRequestRow() })
enqueue({ data: null, error: { code: '23505', message: 'already included in an active rot/rut payout request' } })
enqueue({ data: null }) // rollback delete
const response = await payoutFilePOST(
createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('ROT_RUT_INVOICE_CONFLICT')
})
})
describe('GET /api/rot-rut/payout-requests', () => {
it('lists requests', async () => {
enqueue({ data: [makePayoutRequestRow()] })
const response = await requestsGET(createMockRequest('/api/rot-rut/payout-requests'))
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
})
})
describe('PATCH /api/rot-rut/payout-requests/[id]', () => {
const routeParams = createMockRouteParams({ id: REQUEST_ID })
it('returns 404 for an unknown request', async () => {
enqueue({ data: null })
const response = await requestPATCH(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
method: 'PATCH',
body: { status: 'submitted' },
}),
routeParams,
)
expect(response.status).toBe(404)
})
it('rejects an invalid transition', async () => {
enqueue({ data: makePayoutRequestRow({ status: 'paid' }) })
const response = await requestPATCH(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
method: 'PATCH',
body: { status: 'submitted' },
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ROT_RUT_INVALID_STATUS_TRANSITION')
})
it('requires decided_total for partially_paid', async () => {
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
const response = await requestPATCH(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
method: 'PATCH',
body: { status: 'partially_paid' },
}),
routeParams,
)
expect(response.status).toBe(400)
})
it('marks a generated request as submitted', async () => {
enqueue({ data: makePayoutRequestRow() })
enqueue({ data: makePayoutRequestRow({ status: 'submitted', submitted_at: '2026-07-02T12:00:00Z' }) })
const response = await requestPATCH(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
method: 'PATCH',
body: { status: 'submitted' },
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('submitted')
})
it('records a rejection with decided_total 0', async () => {
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
enqueue({ data: makePayoutRequestRow({ status: 'rejected', decided_total: 0 }) })
const response = await requestPATCH(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
method: 'PATCH',
body: { status: 'rejected' },
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('rejected')
})
})
describe('POST /api/rot-rut/payout-requests/[id]/settle', () => {
const routeParams = createMockRouteParams({ id: REQUEST_ID })
const settleBody = { payment_date: '2026-07-10' }
it('returns 404 for an unknown request', async () => {
enqueue({ data: null })
const response = await settlePOST(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
method: 'POST',
body: settleBody,
}),
routeParams,
)
expect(response.status).toBe(404)
})
it('refuses an already settled request', async () => {
enqueue({
data: makePayoutRequestRow({
status: 'paid',
settlement_journal_entry_id: 'je-1',
}),
})
const response = await settlePOST(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
method: 'POST',
body: settleBody,
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ROT_RUT_SETTLE_INVALID_STATE')
})
it('books the payout and completes the request as paid', async () => {
mockCreatePayoutEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
enqueue({
data: makePayoutRequestRow({
status: 'paid',
settlement_journal_entry_id: 'je-1',
decided_total: 3000,
}),
})
enqueue({ data: [{ id: 'item-1', requested_amount: 3000 }] })
enqueue({ data: null }) // item decided_amount update
const response = await settlePOST(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
method: 'POST',
body: settleBody,
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{
data: { journal_entry_id: string; request: { status: string } }
}>(response)
expect(status).toBe(200)
expect(body.data.journal_entry_id).toBe('je-1')
expect(body.data.request.status).toBe('paid')
expect(mockCreatePayoutEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ amount: 3000, paymentDate: '2026-07-10' }),
)
})
it('forwards bank_account to the engine and defaults it to undefined', async () => {
mockCreatePayoutEntry.mockResolvedValue({ id: 'je-3' })
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
enqueue({ data: makePayoutRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-3' }) })
enqueue({ data: [] })
const response = await settlePOST(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
method: 'POST',
body: { payment_date: '2026-07-10', bank_account: '1920' },
}),
routeParams,
)
expect(response.status).toBe(200)
expect(mockCreatePayoutEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ bankAccount: '1920' }),
)
})
it('rejects a non-19xx bank_account', async () => {
const response = await settlePOST(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
method: 'POST',
body: { payment_date: '2026-07-10', bank_account: '3001' },
}),
routeParams,
)
expect(response.status).toBe(400)
})
it('books a partial payout as partially_paid once the beslut is recorded', async () => {
mockCreatePayoutEntry.mockResolvedValue({ id: 'je-2' })
enqueue({ data: makePayoutRequestRow({ status: 'submitted', decided_total: 2000 }) })
enqueue({
data: makePayoutRequestRow({ status: 'partially_paid', decided_total: 2000 }),
})
const response = await settlePOST(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
method: 'POST',
body: { payment_date: '2026-07-10', amount: 2000 },
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{ data: { request: { status: string } } }>(response)
expect(status).toBe(200)
expect(body.data.request.status).toBe('partially_paid')
})
it('refuses a partial settlement before the beslut is recorded', async () => {
enqueue({ data: makePayoutRequestRow({ status: 'submitted', decided_total: null }) })
const response = await settlePOST(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
method: 'POST',
body: { payment_date: '2026-07-10', amount: 2000 },
}),
routeParams,
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).not.toBe(200)
expect(body.error.code).toBe('ROT_RUT_SETTLE_INVALID_STATE')
expect(mockCreatePayoutEntry).not.toHaveBeenCalled()
})
it('returns 500 and does not update the request when the engine fails', async () => {
mockCreatePayoutEntry.mockRejectedValue(new Error('period locked'))
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
const response = await settlePOST(
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
method: 'POST',
body: settleBody,
}),
routeParams,
)
expect(response.status).toBe(500)
})
})