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:
@@ -1671,6 +1671,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-08] Medelantal anställda (Not 2, ÅRL 5:20 §) gets a whole-number override on arsredovisning_narratives (migration 20260908130127) instead of the free-text note override the support request asked for. Why: the number keeps the statutory sentence and the iXBRL MedelantaletAnstallda fact correct; free text would let a non-compliant note through and could not be tagged. One resolver (lib/salary/medelantal.ts resolveMedelantalAnstallda: override, else FTE average over employees) feeds the K2 and K3 note builders and the iXBRL input, which also reads the previous period's override so the jämförelseår column shows the same figure the previous year's document did. Rejected: rounding 0.5 up globally (silently changes every company's note and does nothing for the 148 of 195 aktiebolag with salary but no employees rows); asking the user to backdate employment_start (fixes one company, misstates the hire date).
|
||||
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
|
||||
[2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place.
|
||||
[2026-09-08] Issue #2426 ROT/RUT begäran state in the invoice list: normal states (Att begära, Skapad, Uppladdad, Beviljad) render as muted text and only Delvis beviljad and Avslagen get a chip (design convention 5: a fönsterputsfirma where every invoice is ROT/RUT would otherwise carry the same chip on every row); the filter is a third ContextPicker (?rotrut=) gated on rot_rut_enabled or an invoiced deduction, not extra entries in the status view, so a ROT/RUT state can be combined with any status view and companies without ROT/RUT never see it. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the column, the filter and its counts; the state is read from a rot_rut_payout_request_items embed on the existing list query, no new API and no migration.
|
||||
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
|
||||
[2026-09-08] Issue #2224 offert -> kundorder: the quote source was added to the existing proforma -> order service (lib/sales-orders/convert-to-sales-order.ts) and to gnubok_convert_invoice as a target parameter staged under the existing convert_invoice operation type, instead of a second service, a separate MCP tool and a new pending-operation type. Why: one conversion path and one guard pair (a quote with a live order cannot become an invoice, a quote with a live invoice cannot become an order) keep the proforma and quote flows identical, and reusing the operation type keeps the pending-operations type list untouched. The quote flips to accepted like quote -> invoice does; cancelling it like the proforma path would hide the customer's accepted agreement. Skeptic refutation: for an already-accepted quote that accepted -> accepted compare-and-set cannot detect a concurrent conversion, so the "never twice" guard moved into the database (migration 20260908165000: one live kundorder per source, and two triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa); the service pre-checks stay for the friendly error. Second refutation: order -> invoice booked a foreign-currency invoice 1:1 when Riksbanken had no rate (pre-existing, but the quote now depends on that path and the fail-closed quote -> invoice route is refused while an order lives), so createInvoiceFromSalesOrder now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE. Locking the quote decision (open/declined) while a live order exists was left out: the DB trigger only knows about converted invoices and changing it is a migration; a declined quote with a live order is harmless since the order carries the sale.
|
||||
[2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version.
|
||||
|
||||
@@ -51,6 +51,15 @@ import {
|
||||
type InvoiceListSortColumn,
|
||||
} from '@/lib/invoices/invoice-list-sort'
|
||||
import { invoiceRowTone, type InvoiceRowTone } from '@/lib/invoices/invoice-list-row-tone'
|
||||
import {
|
||||
ROT_RUT_LIST_FILTERS,
|
||||
matchesRotRutListFilter,
|
||||
parseRotRutListFilter,
|
||||
rotRutListStateOf,
|
||||
type RotRutListFilter,
|
||||
type RotRutListItem,
|
||||
type RotRutListState,
|
||||
} from '@/lib/invoices/rot-rut-list-status'
|
||||
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -166,6 +175,31 @@ const TAB_LABEL_KEYS: Record<ListTab, string> = {
|
||||
cancelled: 'tab_cancelled',
|
||||
}
|
||||
|
||||
/** A list row: the invoice plus the begäran embed the ROT/RUT column reads. */
|
||||
type ListInvoice = Invoice & { rot_rut_items?: RotRutListItem[] | null }
|
||||
|
||||
const ROT_RUT_STATE_LABEL_KEYS: Record<NonNullable<RotRutListState>, string> = {
|
||||
claimable: 'rot_rut_status_claimable',
|
||||
generated: 'rot_rut_status_generated',
|
||||
submitted: 'rot_rut_status_submitted',
|
||||
paid: 'rot_rut_status_paid',
|
||||
partially_paid: 'rot_rut_status_partially_paid',
|
||||
rejected: 'rot_rut_status_rejected',
|
||||
cancelled: 'rot_rut_status_cancelled',
|
||||
}
|
||||
const rotRutFilterLabelKey = (filter: RotRutListFilter): string =>
|
||||
filter === 'all' ? 'rot_rut_filter_all' : ROT_RUT_STATE_LABEL_KEYS[filter]
|
||||
|
||||
/** Chips mark exceptions (convention 5): a begäran on its way or approved is
|
||||
* the normal state of a ROT/RUT invoice and reads as muted text; only a
|
||||
* partial approval and an avslag deviate. */
|
||||
const ROT_RUT_EXCEPTION_VARIANT: Partial<
|
||||
Record<NonNullable<RotRutListState>, RowStatusDescriptor['variant']>
|
||||
> = {
|
||||
partially_paid: 'warning',
|
||||
rejected: 'destructive',
|
||||
}
|
||||
|
||||
function daysOverdue(dueDateStr: string): number {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
@@ -228,7 +262,7 @@ export default function InvoicesPage() {
|
||||
const { canWrite } = useCanWrite()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [invoices, setInvoices] = useState<ListInvoice[]>([])
|
||||
// Settings-driven gates from the session-cached settings row
|
||||
// (lib/reference-data), derived instead of copied into state.
|
||||
const { settings: companySettings } = useCompanySettings()
|
||||
@@ -255,6 +289,10 @@ export default function InvoicesPage() {
|
||||
const param = searchParams.get('group')
|
||||
return param && GROUP_MODES.includes(param as never) ? (param as GroupMode) : 'none'
|
||||
})
|
||||
// ROT/RUT begäran filter (#2426), ?rotrut=. 'all' is the URL-less default.
|
||||
const [rotRutFilter, setRotRutFilter] = useState<RotRutListFilter>(
|
||||
() => parseRotRutListFilter(searchParams.get('rotrut')) ?? 'all',
|
||||
)
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS)
|
||||
// Fiscal-year scope (convention 8): null = all years.
|
||||
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
|
||||
@@ -341,6 +379,9 @@ export default function InvoicesPage() {
|
||||
// ones. ?rot-rut=1 keeps working regardless, so nothing is unreachable.
|
||||
const showRotRutAction =
|
||||
rotRutEnabled || invoices.some((invoice) => (invoice.deduction_total ?? 0) > 0)
|
||||
// The begäran column and its filter share that gate: a company without
|
||||
// ROT/RUT never sees an empty column or a picker with nothing to pick.
|
||||
const showRotRut = showRotRutAction
|
||||
|
||||
// Invoice-register coverage (see lib/invoices/invoice-register-coverage.ts):
|
||||
// a migrated or backfilled company has invoices that live only as verifikat,
|
||||
@@ -378,7 +419,11 @@ export default function InvoicesPage() {
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(name)')
|
||||
// The begäran embed feeds the ROT/RUT column and filter; the
|
||||
// items index on invoice_id keeps the reverse join cheap.
|
||||
.select(
|
||||
'*, customer:customers(name), rot_rut_items:rot_rut_payout_request_items(request:rot_rut_payout_requests(id, status, created_at))',
|
||||
)
|
||||
.eq('company_id', company.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.order('id', { ascending: false })
|
||||
@@ -408,7 +453,7 @@ export default function InvoicesPage() {
|
||||
// Search + fiscal-year scope, before the status view: the per-view counts
|
||||
// in the ContextPicker are computed on this same base so they always equal
|
||||
// the rows the view would show.
|
||||
const scopedInvoices = useMemo(
|
||||
const searchScopedInvoices = useMemo(
|
||||
() =>
|
||||
invoices.filter((invoice) => {
|
||||
const matchesSearch = matchesInvoiceSearch(invoice, normalizedSearch)
|
||||
@@ -422,6 +467,15 @@ export default function InvoicesPage() {
|
||||
}),
|
||||
[fyPeriod, invoices, normalizedSearch],
|
||||
)
|
||||
// The ROT/RUT filter sits between search/FY and the status view, so the
|
||||
// status counts honour it while its own counts are taken one level up.
|
||||
const scopedInvoices = useMemo(
|
||||
() =>
|
||||
rotRutFilter === 'all'
|
||||
? searchScopedInvoices
|
||||
: searchScopedInvoices.filter((invoice) => matchesRotRutListFilter(invoice, rotRutFilter)),
|
||||
[rotRutFilter, searchScopedInvoices],
|
||||
)
|
||||
const filteredInvoices = useMemo(
|
||||
() => scopedInvoices.filter((invoice) => matchesListTab(invoice, activeTab)),
|
||||
[activeTab, scopedInvoices],
|
||||
@@ -507,6 +561,19 @@ export default function InvoicesPage() {
|
||||
return counts
|
||||
}, [scopedInvoices])
|
||||
|
||||
const rotRutCounts = useMemo(() => {
|
||||
const counts = Object.fromEntries(ROT_RUT_LIST_FILTERS.map((f) => [f, 0])) as Record<
|
||||
RotRutListFilter,
|
||||
number
|
||||
>
|
||||
for (const invoice of searchScopedInvoices) {
|
||||
const state = rotRutListStateOf(invoice)
|
||||
counts.all += 1
|
||||
if (state && state in counts) counts[state as RotRutListFilter] += 1
|
||||
}
|
||||
return counts
|
||||
}, [searchScopedInvoices])
|
||||
|
||||
const resetPaging = () => setVisibleCount(INITIAL_VISIBLE_ROWS)
|
||||
|
||||
const updateSort = (column: InvoiceListSortColumn) => {
|
||||
@@ -544,6 +611,16 @@ export default function InvoicesPage() {
|
||||
router.replace(qs ? `/invoices?${qs}` : '/invoices', { scroll: false })
|
||||
}
|
||||
|
||||
const updateRotRut = (filter: RotRutListFilter) => {
|
||||
setRotRutFilter(filter)
|
||||
resetPaging()
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (filter === 'all') params.delete('rotrut')
|
||||
else params.set('rotrut', filter)
|
||||
const qs = params.toString()
|
||||
router.replace(qs ? `/invoices?${qs}` : '/invoices', { scroll: false })
|
||||
}
|
||||
|
||||
// Bulk Bokför eligibility. Kontantmetoden books at payment, so no row is
|
||||
// selectable (the checkbox column is hidden entirely). Accrual companies
|
||||
// that book at issue select drafts ("Bokför och markera som skickade");
|
||||
@@ -736,6 +813,11 @@ export default function InvoicesPage() {
|
||||
return { label: t('status_sent') }
|
||||
}
|
||||
|
||||
function rotRutDescriptor(state: NonNullable<RotRutListState>): RowStatusDescriptor {
|
||||
const variant = ROT_RUT_EXCEPTION_VARIANT[state]
|
||||
return { label: t(ROT_RUT_STATE_LABEL_KEYS[state]), exception: variant !== undefined, variant }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page header (concept scene 15): title + invoice actions */}
|
||||
@@ -793,6 +875,24 @@ export default function InvoicesPage() {
|
||||
label: t(GROUP_LABEL_KEYS[mode]),
|
||||
}))}
|
||||
/>
|
||||
{showRotRut && (
|
||||
<ContextPicker
|
||||
value={rotRutFilter}
|
||||
onChange={(id) => updateRotRut(id as RotRutListFilter)}
|
||||
ariaLabel={t('rot_rut_filter_aria')}
|
||||
triggerLabel={
|
||||
rotRutFilter !== 'all' && rotRutCounts[rotRutFilter] > 0
|
||||
? `${t('rot_rut_filter_label')} · ${t(rotRutFilterLabelKey(rotRutFilter))} · ${rotRutCounts[rotRutFilter]}`
|
||||
: `${t('rot_rut_filter_label')} · ${t(rotRutFilterLabelKey(rotRutFilter))}`
|
||||
}
|
||||
items={ROT_RUT_LIST_FILTERS.map((filter) => ({
|
||||
id: filter,
|
||||
label: t(rotRutFilterLabelKey(filter)),
|
||||
annotation:
|
||||
filter !== 'all' && rotRutCounts[filter] > 0 ? String(rotRutCounts[filter]) : undefined,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<ToolbarSearch
|
||||
containerClassName="min-w-[190px]"
|
||||
placeholder={t('search_placeholder')}
|
||||
@@ -941,6 +1041,9 @@ export default function InvoicesPage() {
|
||||
className="text-right"
|
||||
align="right"
|
||||
/>
|
||||
{showRotRut && (
|
||||
<th className={cn(TH_CLASS, 'whitespace-nowrap')}>{t('th_rot_rut')}</th>
|
||||
)}
|
||||
<SortableHeader
|
||||
label={t('th_status')}
|
||||
sortLabel={t('sort_by', { column: t('th_status') })}
|
||||
@@ -955,6 +1058,7 @@ export default function InvoicesPage() {
|
||||
const prevKey = index > 0 ? visibleRows[index - 1].groupKey : undefined
|
||||
const showHeader = showGroupHeaders && groupKey !== null && groupKey !== prevKey
|
||||
const status = statusDescriptor(invoice)
|
||||
const rotRutState = showRotRut ? rotRutListStateOf(invoice) : null
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const displayedTotal = getDisplayTotal(
|
||||
@@ -982,7 +1086,7 @@ export default function InvoicesPage() {
|
||||
{showHeader && (
|
||||
<tr data-no-stagger>
|
||||
<td
|
||||
colSpan={showSelection ? 6 : 5}
|
||||
colSpan={(showSelection ? 6 : 5) + (showRotRut ? 1 : 0)}
|
||||
className={cn(
|
||||
'border-b border-border px-1 pb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground',
|
||||
index === 0 ? 'pt-4' : 'pt-6',
|
||||
@@ -1067,6 +1171,11 @@ export default function InvoicesPage() {
|
||||
>
|
||||
{formatCurrency(displayedTotal, invoice.currency)}
|
||||
</td>
|
||||
{showRotRut && (
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
||||
{rotRutState && <RowStatus status={rotRutDescriptor(rotRutState)} />}
|
||||
</td>
|
||||
)}
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{typeMarker && (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6712,6 +6712,7 @@
|
||||
"rot_rut_status_partially_paid": "Partly approved",
|
||||
"rot_rut_status_rejected": "Rejected",
|
||||
"rot_rut_status_cancelled": "Cancelled",
|
||||
"rot_rut_status_claimable": "To request",
|
||||
"rot_rut_download_again": "Download again",
|
||||
"rot_rut_cancel_request": "Cancel",
|
||||
"rot_rut_mark_uploaded": "Mark as uploaded",
|
||||
@@ -6778,6 +6779,7 @@
|
||||
"th_due": "Due",
|
||||
"th_amount": "Amount",
|
||||
"th_status": "Status",
|
||||
"th_rot_rut": "ROT/RUT",
|
||||
"sort_by": "Sort by {column}",
|
||||
"create_invoice_desc": "Regular customer invoice",
|
||||
"create_recurring": "Recurring invoice",
|
||||
@@ -6808,6 +6810,9 @@
|
||||
"status_paid_date": "Paid {date}",
|
||||
"status_picker_aria": "Filter by status",
|
||||
"group_picker_aria": "Group invoices",
|
||||
"rot_rut_filter_aria": "Filter by ROT/RUT request",
|
||||
"rot_rut_filter_label": "ROT/RUT",
|
||||
"rot_rut_filter_all": "All",
|
||||
"group_by": "Group",
|
||||
"group_status": "Status",
|
||||
"group_customer": "Customer",
|
||||
|
||||
@@ -6712,6 +6712,7 @@
|
||||
"rot_rut_status_partially_paid": "Delvis beviljad",
|
||||
"rot_rut_status_rejected": "Avslagen",
|
||||
"rot_rut_status_cancelled": "Avbruten",
|
||||
"rot_rut_status_claimable": "Att begära",
|
||||
"rot_rut_download_again": "Hämta igen",
|
||||
"rot_rut_cancel_request": "Avbryt",
|
||||
"rot_rut_mark_uploaded": "Markera uppladdad",
|
||||
@@ -6778,6 +6779,7 @@
|
||||
"th_due": "Förfaller",
|
||||
"th_amount": "Belopp",
|
||||
"th_status": "Status",
|
||||
"th_rot_rut": "ROT/RUT",
|
||||
"sort_by": "Sortera efter {column}",
|
||||
"create_invoice_desc": "Vanlig kundfaktura",
|
||||
"create_recurring": "Återkommande faktura",
|
||||
@@ -6808,6 +6810,9 @@
|
||||
"status_paid_date": "Betald {date}",
|
||||
"status_picker_aria": "Filtrera på status",
|
||||
"group_picker_aria": "Gruppera fakturor",
|
||||
"rot_rut_filter_aria": "Filtrera på ROT/RUT-begäran",
|
||||
"rot_rut_filter_label": "ROT/RUT",
|
||||
"rot_rut_filter_all": "Alla",
|
||||
"group_by": "Gruppera",
|
||||
"group_status": "Status",
|
||||
"group_customer": "Kund",
|
||||
|
||||
Reference in New Issue
Block a user