feat(invoices): ROT/RUT begäran status as a column and filter in the invoice list (#2434)
An invoice's begäran state (Att begära, Skapad, Uppladdad, Beviljad, Delvis beviljad, Avslagen) was only visible one invoice at a time or inside the payout dialog. The list now embeds the begäran behind each invoice, shows the state in a ROT/RUT column and filters on it through a third ContextPicker (?rotrut=), both gated on rot_rut_enabled or an invoiced deduction. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the column, the filter and its counts. Normal states read as muted text; only a partial approval and an avslag get a chip. Closes #2426 Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
9782f80db0
commit
d5373cd66c
@@ -15,4 +15,10 @@ describe('invoice list query shape', () => {
|
||||
expect(source).toContain('.range(from, to)')
|
||||
expect(source).toContain('dedupeBy: (invoice) => invoice.id')
|
||||
})
|
||||
|
||||
it('embeds the begäran behind each invoice for the ROT/RUT column and filter (#2426)', () => {
|
||||
expect(source).toContain(
|
||||
'rot_rut_items:rot_rut_payout_request_items(request:rot_rut_payout_requests(id, status, created_at))',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ROT_RUT_LIST_FILTERS,
|
||||
matchesRotRutListFilter,
|
||||
parseRotRutListFilter,
|
||||
rotRutListStateOf,
|
||||
type RotRutListInvoice,
|
||||
type RotRutListRequest,
|
||||
} from '../rot-rut-list-status'
|
||||
|
||||
function request(overrides: Partial<RotRutListRequest> & { status: string }): RotRutListRequest {
|
||||
return { id: `req-${overrides.status}`, created_at: '2026-01-01T00:00:00Z', ...overrides }
|
||||
}
|
||||
|
||||
function invoice(
|
||||
overrides: Partial<RotRutListInvoice> & { requests?: RotRutListRequest[] } = {},
|
||||
): RotRutListInvoice {
|
||||
const { requests, ...rest } = overrides
|
||||
return {
|
||||
status: 'paid',
|
||||
deduction_total: 3000,
|
||||
rot_rut_items: requests?.map((r) => ({ request: r })),
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
describe('rotRutListStateOf', () => {
|
||||
it('reads the active request as the invoice state', () => {
|
||||
expect(rotRutListStateOf(invoice({ requests: [request({ status: 'submitted' })] }))).toBe(
|
||||
'submitted',
|
||||
)
|
||||
expect(rotRutListStateOf(invoice({ requests: [request({ status: 'paid' })] }))).toBe('paid')
|
||||
expect(
|
||||
rotRutListStateOf(invoice({ requests: [request({ status: 'partially_paid' })] })),
|
||||
).toBe('partially_paid')
|
||||
})
|
||||
|
||||
it('lets the active request win over historical avslag and cancelled files', () => {
|
||||
const rows = [
|
||||
request({ status: 'rejected', created_at: '2026-03-01T00:00:00Z' }),
|
||||
request({ status: 'generated', created_at: '2026-02-01T00:00:00Z' }),
|
||||
request({ status: 'cancelled', created_at: '2026-04-01T00:00:00Z' }),
|
||||
]
|
||||
expect(rotRutListStateOf(invoice({ requests: rows }))).toBe('generated')
|
||||
})
|
||||
|
||||
it('falls back to the newest historical row when nothing is active', () => {
|
||||
const rows = [
|
||||
request({ status: 'cancelled', created_at: '2026-02-01T00:00:00Z' }),
|
||||
request({ status: 'rejected', created_at: '2026-03-01T00:00:00Z' }),
|
||||
]
|
||||
expect(rotRutListStateOf(invoice({ requests: rows }))).toBe('rejected')
|
||||
})
|
||||
|
||||
it('reads PostgREST array-shaped embeds too', () => {
|
||||
const row: RotRutListInvoice = {
|
||||
status: 'paid',
|
||||
deduction_total: 500,
|
||||
rot_rut_items: [{ request: [request({ status: 'submitted' })] }],
|
||||
}
|
||||
expect(rotRutListStateOf(row)).toBe('submitted')
|
||||
})
|
||||
|
||||
it('marks a paid invoice with a deduction and no request as claimable', () => {
|
||||
expect(rotRutListStateOf(invoice({ requests: [] }))).toBe('claimable')
|
||||
expect(rotRutListStateOf(invoice({ rot_rut_items: undefined }))).toBe('claimable')
|
||||
})
|
||||
|
||||
it('has nothing to say for unpaid invoices or invoices without a deduction', () => {
|
||||
expect(rotRutListStateOf(invoice({ status: 'sent' }))).toBeNull()
|
||||
expect(rotRutListStateOf(invoice({ status: 'overdue', deduction_total: 3000 }))).toBeNull()
|
||||
expect(rotRutListStateOf(invoice({ deduction_total: 0 }))).toBeNull()
|
||||
expect(rotRutListStateOf(invoice({ deduction_total: undefined }))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores request rows with an unknown status', () => {
|
||||
expect(rotRutListStateOf(invoice({ requests: [request({ status: 'weird' })] }))).toBe(
|
||||
'claimable',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesRotRutListFilter', () => {
|
||||
it('matches everything on all and the exact state otherwise', () => {
|
||||
const submitted = invoice({ requests: [request({ status: 'submitted' })] })
|
||||
const claimable = invoice({ requests: [] })
|
||||
const plain = invoice({ status: 'sent', deduction_total: 0 })
|
||||
expect(ROT_RUT_LIST_FILTERS.filter((f) => matchesRotRutListFilter(submitted, f))).toEqual([
|
||||
'all',
|
||||
'submitted',
|
||||
])
|
||||
expect(ROT_RUT_LIST_FILTERS.filter((f) => matchesRotRutListFilter(claimable, f))).toEqual([
|
||||
'all',
|
||||
'claimable',
|
||||
])
|
||||
expect(ROT_RUT_LIST_FILTERS.filter((f) => matchesRotRutListFilter(plain, f))).toEqual(['all'])
|
||||
})
|
||||
|
||||
it('does not offer cancelled as a filter but still matches nothing but all for it', () => {
|
||||
const cancelled = invoice({ requests: [request({ status: 'cancelled' })] })
|
||||
expect(ROT_RUT_LIST_FILTERS).not.toContain('cancelled')
|
||||
expect(ROT_RUT_LIST_FILTERS.filter((f) => matchesRotRutListFilter(cancelled, f))).toEqual([
|
||||
'all',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRotRutListFilter', () => {
|
||||
it('accepts filter ids and rejects everything else', () => {
|
||||
expect(parseRotRutListFilter('submitted')).toBe('submitted')
|
||||
expect(parseRotRutListFilter('claimable')).toBe('claimable')
|
||||
expect(parseRotRutListFilter('all')).toBe('all')
|
||||
expect(parseRotRutListFilter('cancelled')).toBeNull()
|
||||
expect(parseRotRutListFilter('')).toBeNull()
|
||||
expect(parseRotRutListFilter(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* The ROT/RUT begäran state of one invoice, as the invoice list reads it.
|
||||
*
|
||||
* One predicate for the ROT/RUT column, the filter and its counts, so the
|
||||
* three can never drift (the same shape as invoice-list-tabs.ts). The list
|
||||
* embeds `rot_rut_payout_request_items` with their parent request; this file
|
||||
* only decides which request speaks for the invoice.
|
||||
*
|
||||
* Which request: the trigger enforce_single_active_rot_rut_request keeps an
|
||||
* invoice in at most ONE active begäran (status not cancelled/rejected), so
|
||||
* the active one is the state. Without an active request the newest
|
||||
* historical row (an avslag or a cancelled file) still explains the invoice
|
||||
* until it is re-requested. Without any request, a paid invoice that carries
|
||||
* a deduction is waiting for its begäran ("Att begära", the same rule as
|
||||
* skvClaimable on the invoice page); anything else has nothing to say.
|
||||
*/
|
||||
|
||||
export const ROT_RUT_REQUEST_STATUSES = [
|
||||
'generated',
|
||||
'submitted',
|
||||
'paid',
|
||||
'partially_paid',
|
||||
'rejected',
|
||||
'cancelled',
|
||||
] as const
|
||||
export type RotRutRequestStatus = (typeof ROT_RUT_REQUEST_STATUSES)[number]
|
||||
|
||||
/** Request statuses that no longer hold the invoice: history, not state. */
|
||||
const INACTIVE_REQUEST_STATUSES: readonly string[] = ['cancelled', 'rejected']
|
||||
|
||||
export type RotRutListState = RotRutRequestStatus | 'claimable' | null
|
||||
|
||||
export interface RotRutListRequest {
|
||||
id: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** The shape PostgREST returns for the reverse embed on the list query. */
|
||||
export interface RotRutListItem {
|
||||
request: RotRutListRequest | RotRutListRequest[] | null
|
||||
}
|
||||
|
||||
export type RotRutListInvoice = {
|
||||
status: string
|
||||
deduction_total?: number | null
|
||||
rot_rut_items?: RotRutListItem[] | null
|
||||
}
|
||||
|
||||
function isRequestStatus(status: string): status is RotRutRequestStatus {
|
||||
return (ROT_RUT_REQUEST_STATUSES as readonly string[]).includes(status)
|
||||
}
|
||||
|
||||
function requestsOf(invoice: RotRutListInvoice): RotRutListRequest[] {
|
||||
const requests: RotRutListRequest[] = []
|
||||
for (const item of invoice.rot_rut_items ?? []) {
|
||||
const embedded = item.request
|
||||
if (!embedded) continue
|
||||
if (Array.isArray(embedded)) requests.push(...embedded)
|
||||
else requests.push(embedded)
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
export function rotRutListStateOf(invoice: RotRutListInvoice): RotRutListState {
|
||||
const requests = requestsOf(invoice).filter((request) => isRequestStatus(request.status))
|
||||
const active = requests.find((request) => !INACTIVE_REQUEST_STATUSES.includes(request.status))
|
||||
if (active) return active.status as RotRutRequestStatus
|
||||
if (requests.length > 0) {
|
||||
const newest = [...requests].sort((a, b) => b.created_at.localeCompare(a.created_at))[0]
|
||||
return newest.status as RotRutRequestStatus
|
||||
}
|
||||
if ((invoice.deduction_total ?? 0) > 0 && invoice.status === 'paid') return 'claimable'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter's values: the states a user looks for. `cancelled` is not a
|
||||
* view of its own (a cancelled file is a non-event for the invoice), it only
|
||||
* shows in the column until the invoice is requested again.
|
||||
*/
|
||||
export const ROT_RUT_LIST_FILTERS = [
|
||||
'all',
|
||||
'claimable',
|
||||
'generated',
|
||||
'submitted',
|
||||
'paid',
|
||||
'partially_paid',
|
||||
'rejected',
|
||||
] as const
|
||||
export type RotRutListFilter = (typeof ROT_RUT_LIST_FILTERS)[number]
|
||||
|
||||
export function parseRotRutListFilter(param: string | null): RotRutListFilter | null {
|
||||
if (!param) return null
|
||||
return (ROT_RUT_LIST_FILTERS as readonly string[]).includes(param)
|
||||
? (param as RotRutListFilter)
|
||||
: null
|
||||
}
|
||||
|
||||
export function matchesRotRutListFilter(
|
||||
invoice: RotRutListInvoice,
|
||||
filter: RotRutListFilter,
|
||||
): boolean {
|
||||
if (filter === 'all') return true
|
||||
return rotRutListStateOf(invoice) === filter
|
||||
}
|
||||
Reference in New Issue
Block a user