feat(transactions): pick several ROT/RUT begäran by hand for one Skatteverket transfer (#2431)
The manual invoice picker's ROT/RUT section handed over exactly one begäran, while the route, the settle service and the confirm dialog take a bundle since #2360. When the automatic set matcher refuses a transfer (two open begäran with the same amount, or more than four), the user had no way to build the bundle and was told to split the bank row. Each begäran row now carries a checkbox; ticking one or more shows the running sum against the bank row and a "Matcha valda" button that hands the set (largest first, the matcher's order) to the existing confirm dialog, which still refuses a sum that is off the row. A plain row click keeps the one-begäran path. Part of #2425 (the suggestion itself shipped in #2271 and #2360). 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] #2425 ROT/RUT: the picker's begäran rows keep single-click handoff and gain a checkbox per row for a bundled pick, instead of a new multi-select mode or an ambiguity-tolerant matcher. Why: the server (request_ids, settleRotRutPayoutRequestSet) and the confirm dialog already take N begäran since #2360; only the manual fallback was 1:1, and a firm whose jobs share a price hits the matcher's ambiguity refusal often. Guessing between two same-amount begäran would clear the wrong 1513 fordran, so the choice stays with the user.
|
||||
[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.
|
||||
|
||||
@@ -2713,15 +2713,16 @@ export default function TransactionsPage() {
|
||||
setMatchDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleSelectRotRutPayoutFromPicker(request: PotentialRotRutPayoutRequest) {
|
||||
if (!invoicePickerTransaction) return
|
||||
// Same handoff as the invoice pick: close the picker, hang the request on
|
||||
// the row and open the ROT/RUT confirm dialog so the user sees the
|
||||
// 19xx / 1513 entry before it is booked.
|
||||
function handleSelectRotRutPayoutFromPicker(requests: PotentialRotRutPayoutRequest[]) {
|
||||
if (!invoicePickerTransaction || requests.length === 0) return
|
||||
// Same handoff as the invoice pick: close the picker, hang the begäran
|
||||
// (one, or the bundle the user ticked) on the row and open the ROT/RUT
|
||||
// confirm dialog so the user sees the 19xx / 1513 entry before it is
|
||||
// booked. The dialog refuses a bundle whose sum is off the row.
|
||||
const tx = invoicePickerTransaction
|
||||
setInvoicePickerOpen(false)
|
||||
setInvoicePickerTransaction(null)
|
||||
setSelectedTransaction({ ...tx, potential_rot_rut_payout: { requests: [request] } })
|
||||
setSelectedTransaction({ ...tx, potential_rot_rut_payout: { requests } })
|
||||
setRotRutMatchDialogOpen(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { Search, FileText, Loader2, Landmark } from 'lucide-react'
|
||||
@@ -25,9 +27,12 @@ type OpenInvoice = Invoice & { customer?: Customer }
|
||||
interface InvoicePickerProps {
|
||||
transaction: TransactionWithInvoice
|
||||
onSelect: (invoice: OpenInvoice) => void
|
||||
/** Pick an open ROT/RUT begäran instead of an invoice (Skatteverkets
|
||||
* utbetalning). The section only renders when the company has one. */
|
||||
onSelectRotRutPayout?: (request: PotentialRotRutPayoutRequest) => void
|
||||
/** Pick one or several open ROT/RUT begäran instead of an invoice
|
||||
* (Skatteverkets utbetalning). A row click hands over that begäran alone;
|
||||
* the checkboxes build a bundle for a transfer that paid several beslut,
|
||||
* which the automatic set matcher refuses when two begäran carry the same
|
||||
* amount (#2425). The section only renders when the company has one. */
|
||||
onSelectRotRutPayout?: (requests: PotentialRotRutPayoutRequest[]) => void
|
||||
}
|
||||
|
||||
type RotRutRequestRow = {
|
||||
@@ -52,6 +57,8 @@ export default function InvoicePicker({ transaction, onSelect, onSelectRotRutPay
|
||||
const [rotRutRequests, setRotRutRequests] = useState<PotentialRotRutPayoutRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
// Begäran ticked for a bundled handoff; empty until the user ticks one.
|
||||
const [pickedRequestIds, setPickedRequestIds] = useState<ReadonlySet<string>>(() => new Set())
|
||||
// Boolean, not the callback: a fresh function identity per parent render
|
||||
// must not refetch the list.
|
||||
const wantRotRutRequests = !!onSelectRotRutPayout
|
||||
@@ -183,6 +190,30 @@ export default function InvoicePicker({ transaction, onSelect, onSelectRotRutPay
|
||||
// Skatteverket pays out in kronor only; the match route refuses other
|
||||
// currencies, so a foreign-currency row must not be offered a begäran.
|
||||
const txIsSek = (transaction.currency || 'SEK').toUpperCase() === 'SEK'
|
||||
const pickedRequests = rotRutRequests.filter((request) => pickedRequestIds.has(request.id))
|
||||
const pickedTotal = roundOre(
|
||||
pickedRequests.reduce((sum, request) => sum + expectedRotRutPayoutAmount(request), 0),
|
||||
)
|
||||
const pickedDiff = roundOre(Math.abs(pickedTotal - txAmount))
|
||||
const pickedExact = pickedRequests.length > 0 && pickedDiff < 0.005
|
||||
const togglePicked = (id: string, checked: boolean) => {
|
||||
setPickedRequestIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
// The settle service books a bundle largest first (the matcher's order):
|
||||
// hand the manual pick over in the same order so the voucher reads alike.
|
||||
const handOverPicked = () => {
|
||||
if (!onSelectRotRutPayout || pickedRequests.length === 0) return
|
||||
onSelectRotRutPayout(
|
||||
[...pickedRequests].sort(
|
||||
(a, b) => expectedRotRutPayoutAmount(b) - expectedRotRutPayoutAmount(a),
|
||||
),
|
||||
)
|
||||
}
|
||||
const rotRutSection =
|
||||
onSelectRotRutPayout && txIsSek && rotRutRequests.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
@@ -192,40 +223,75 @@ export default function InvoicePicker({ transaction, onSelect, onSelectRotRutPay
|
||||
{rotRutRequests.map((request) => {
|
||||
const expected = expectedRotRutPayoutAmount(request)
|
||||
const exact = Math.abs(expected - txAmount) < 0.005
|
||||
const picked = pickedRequestIds.has(request.id)
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={request.id}
|
||||
type="button"
|
||||
onClick={() => onSelectRotRutPayout(request)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border px-3 py-2.5 transition-colors',
|
||||
'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring',
|
||||
'flex items-stretch rounded-lg border transition-colors',
|
||||
exact && 'border-success/50 bg-success/5',
|
||||
picked && 'border-primary/40 bg-secondary/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Landmark className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium text-sm">{request.name}</span>
|
||||
<label className="flex cursor-pointer items-center pl-3 pr-1">
|
||||
<Checkbox
|
||||
checked={picked}
|
||||
onCheckedChange={(checked) => togglePicked(request.id, checked === true)}
|
||||
aria-label={t('rot_rut_pick_aria', { name: request.name })}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectRotRutPayout([request])}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 rounded-r-lg py-2.5 pl-2 pr-3 text-left transition-colors',
|
||||
'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Landmark className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium text-sm">{request.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{t('rot_rut_request_meta', {
|
||||
type: request.deduction_type === 'rut' ? 'RUT' : 'ROT',
|
||||
count: request.invoices.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className={cn('text-sm font-medium tabular-nums', exact && 'text-success')}>
|
||||
{formatCurrency(expected, DOMESTIC_CURRENCY)}
|
||||
</p>
|
||||
{exact && <p className="text-[10px] text-success">{t('exact_match')}</p>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{t('rot_rut_request_meta', {
|
||||
type: request.deduction_type === 'rut' ? 'RUT' : 'ROT',
|
||||
count: request.invoices.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className={cn('text-sm font-medium tabular-nums', exact && 'text-success')}>
|
||||
{formatCurrency(expected, DOMESTIC_CURRENCY)}
|
||||
</p>
|
||||
{exact && <p className="text-[10px] text-success">{t('exact_match')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{pickedRequests.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-3 pt-1">
|
||||
<div className="min-w-0 text-xs">
|
||||
<p className={cn('tabular-nums', pickedExact ? 'text-success' : 'text-muted-foreground')}>
|
||||
{t('rot_rut_picked_sum', {
|
||||
count: pickedRequests.length,
|
||||
amount: formatCurrency(pickedTotal, DOMESTIC_CURRENCY),
|
||||
})}
|
||||
</p>
|
||||
{!pickedExact && (
|
||||
<p className="text-attn tabular-nums">
|
||||
{t('rot_rut_picked_diff', { amount: formatCurrency(pickedDiff, DOMESTIC_CURRENCY) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={handOverPicked}>
|
||||
{t('rot_rut_match_picked', { count: pickedRequests.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
|
||||
@@ -3247,6 +3247,10 @@
|
||||
"loading": "Loading invoices...",
|
||||
"rot_rut_section_title": "ROT/RUT payouts from Skatteverket",
|
||||
"rot_rut_request_meta": "{type} · {count} invoices",
|
||||
"rot_rut_pick_aria": "Select {name} for a bundled payout",
|
||||
"rot_rut_picked_sum": "{count, plural, one {# request selected} other {# requests selected}} · {amount}",
|
||||
"rot_rut_picked_diff": "{amount} off the bank row",
|
||||
"rot_rut_match_picked": "Match selected",
|
||||
"empty": "No open invoices to match against.",
|
||||
"search_placeholder": "Search invoice number or customer...",
|
||||
"no_number": "(no number)",
|
||||
|
||||
@@ -3247,6 +3247,10 @@
|
||||
"loading": "Laddar fakturor...",
|
||||
"rot_rut_section_title": "ROT/RUT-utbetalningar från Skatteverket",
|
||||
"rot_rut_request_meta": "{type} · {count} fakturor",
|
||||
"rot_rut_pick_aria": "Välj {name} till en samlad utbetalning",
|
||||
"rot_rut_picked_sum": "{count, plural, one {# vald begäran} other {# valda begäran}} · {amount}",
|
||||
"rot_rut_picked_diff": "Skiljer {amount} mot bankraden",
|
||||
"rot_rut_match_picked": "Matcha {count, plural, one {vald} other {valda}}",
|
||||
"empty": "Inga öppna fakturor att matcha mot.",
|
||||
"search_placeholder": "Sök fakturanummer eller kund...",
|
||||
"no_number": "(utan nummer)",
|
||||
|
||||
Reference in New Issue
Block a user