feat(invoices): show the ROT/RUT claim on the invoice detail page (#1690)

The claim was visible only on the PDF (audit item 8, reported by kalletoxic
2026-08-18): the detail page showed the unreduced total and nothing about
the deduction. Now:

- Totals: Totalt stands, a Skattereduktion ROT/RUT row follows and the bold
  line is Att betala, computed with the same getAmountToPay() as the PDF and
  the invoice email so the three never disagree.
- Each claimed line shows kind, arbetstyp, hours and its deduction amount
  under the description (desktop + mobile).
- New Skattereduktion card: customer share vs Skatteverket share, masked
  personnummer, fastighetsbeteckning/BRF/lägenhet for ROT, and where the
  begäran om utbetalning stands (reads rot_rut_payout_request_items; paid
  invoices without a begäran get the one-line CTA to /invoices?rot-rut=1).

Verified in the sandbox against a RUT invoice in draft, sent and paid state.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-18 17:33:11 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 83932f2e07
commit 72603abfd6
3 changed files with 258 additions and 5 deletions
+222 -5
View File
@@ -13,7 +13,8 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { invoiceDisplayNumber, isTextLikeLine } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { getDisplayTotal, getAmountToPay } from '@/lib/invoices/rounding'
import { workTypeLabel } from '@/lib/invoices/rot-rut-rules'
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
@@ -47,11 +48,13 @@ import {
CalendarClock,
Pencil,
Copy,
Landmark,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { AttnLine } from '@/components/ui/attn-line'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import {
@@ -119,6 +122,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoice_detail')
// Begäran status labels are shared with the payout dialog on the list page.
const tInvoices = useTranslations('invoices')
const locale = useLocale()
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
@@ -150,6 +155,21 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
voucher_number: number | null
}>
>([])
// ROT/RUT begäran rows this invoice is part of (fakturamodellen). Empty
// for invoices without a deduction and for claimed invoices whose begäran
// has not been generated yet; the Skattereduktion card reads it.
const [payoutRequests, setPayoutRequests] = useState<
Array<{
id: string
requested_amount: number
decided_amount: number | null
status: string
name: string
created_at: string
submitted_at: string | null
decided_at: string | null
}>
>([])
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
@@ -242,7 +262,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// Invoice, reminders, payments, and deliveries all key on the route id: one
// parallel batch. Only the follow-ups below need the invoice row.
const [{ data, error }, { data: reminderData }, { data: paymentData }, deliveryData] =
const [{ data, error }, { data: reminderData }, { data: paymentData }, deliveryData, { data: payoutData }] =
await Promise.all([
supabase
.from('invoices')
@@ -270,6 +290,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.eq('invoice_id', id)
.order('payment_date', { ascending: true }),
deliveriesPromise,
// ROT/RUT begäran this invoice belongs to (usually 0 or 1 rows).
// RLS scopes the join to the user's companies.
supabase
.from('rot_rut_payout_request_items')
.select(
'id, requested_amount, decided_amount, request:rot_rut_payout_requests(status, name, created_at, submitted_at, decided_at)',
)
.eq('invoice_id', id),
])
// A newer fetch owns the page now (pager step or later refresh): commit
@@ -321,6 +349,34 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)
}
type PayoutRow = {
id: string
requested_amount: number
decided_amount: number | null
request:
| { status: string; name: string; created_at: string; submitted_at: string | null; decided_at: string | null }
| { status: string; name: string; created_at: string; submitted_at: string | null; decided_at: string | null }[]
| null
}
setPayoutRequests(
((payoutData ?? []) as unknown as PayoutRow[]).flatMap((row) => {
const req = Array.isArray(row.request) ? row.request[0] : row.request
if (!req) return []
return [
{
id: row.id,
requested_amount: Number(row.requested_amount),
decided_amount: row.decided_amount === null ? null : Number(row.decided_amount),
status: req.status,
name: req.name,
created_at: req.created_at,
submitted_at: req.submitted_at,
decided_at: req.decided_at,
},
]
}),
)
const settingsRes = await settingsPromise
if (seq !== fetchSeqRef.current) return
if (settingsRes) {
@@ -957,6 +1013,42 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// immutable (BFL); they are corrected with a credit note instead.
const isEditableDraft = isEditableInvoiceDraft(invoice)
const isCopyable = canCopyInvoice(invoice)
// ROT/RUT (fakturamodellen): the customer owes total minus the deduction and
// the rest is claimed from Skatteverket. Same helper as the PDF and the
// invoice email so all three surfaces state the same "Att betala".
const amountToPay = getAmountToPay(invoice, { ore_rounding: oreRounding })
const deductionItems = invoice.items.filter(
(i) => i.deduction_type === 'rot' || i.deduction_type === 'rut',
)
const hasRot = deductionItems.some((i) => i.deduction_type === 'rot')
const hasRut = deductionItems.some((i) => i.deduction_type === 'rut')
const deductionKindLabel = hasRot && hasRut ? 'ROT/RUT' : hasRot ? 'ROT' : 'RUT'
const showDeduction = amountToPay.deductionApplies && !isDeliveryNote
// Fastighetsbeteckning / lägenhet live on the ROT lines (one property per
// invoice in practice); the first ROT line carries the value.
const rotItem = deductionItems.find((i) => i.deduction_type === 'rot')
const rotHousing = rotItem?.housing_designation ?? null
const rotApartment = rotItem?.apartment_number ?? null
const rotBrf = rotItem?.brf_org_number ?? null
const skvClaimable = invoice.status === 'paid' && payoutRequests.length === 0
// "RUT · Städning · 4 tim · avdrag 1 250,00 kr" under a claimed line, so
// the claim is visible on the item itself, not only in the PDF.
const deductionLineInfo = (item: InvoiceItem): string => {
const parts = [item.deduction_type === 'rot' ? 'ROT' : 'RUT']
const label = workTypeLabel(item.work_type)
if (label) parts.push(label)
if (item.labor_hours && item.labor_hours > 0) {
parts.push(t('deduction_line_hours', { hours: item.labor_hours }))
}
if ((item.deduction_amount ?? 0) > 0) {
parts.push(
t('deduction_line_amount', {
amount: formatCurrency(item.deduction_amount ?? 0, invoice.currency),
}),
)
}
return parts.join(' · ')
}
const hasAccruedItems = invoice.items.some(itemHasAccrual)
const latestCompletedDelivery = deliveries.find(
(delivery) => delivery.status === 'sent' || delivery.status === 'marked_sent',
@@ -1252,6 +1344,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</span>
</p>
)}
{item.deduction_type && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{deductionLineInfo(item)}
</p>
)}
</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
@@ -1286,6 +1383,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</span>
</p>
)}
{item.deduction_type && (
<p className="text-xs text-muted-foreground tabular-nums">
{deductionLineInfo(item)}
</p>
)}
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
</div>
@@ -1337,7 +1439,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
})()}
<Separator />
{(() => {
const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding })
const { rounding } = amountToPay
return (
<>
{rounding.applies && (
@@ -1346,10 +1448,28 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<span>{formatCurrency(rounding.roundingDelta, 'SEK')}</span>
</div>
)}
<div className="flex justify-between font-bold text-lg">
<span>{t('total')}</span>
{/* ROT/RUT (fakturamodellen): the invoice total stands,
the deduction is shown as a reduction and the bold
line becomes what the customer actually pays, exactly
as on the PDF and in the invoice email. */}
<div className={cn('flex justify-between', showDeduction ? 'tabular-nums' : 'font-bold text-lg')}>
<span className={cn(showDeduction && 'text-muted-foreground')}>{t('total')}</span>
<span>{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
{showDeduction && (
<>
<div className="flex justify-between tabular-nums">
<span className="text-muted-foreground">
{t('deduction_row', { kind: deductionKindLabel })}
</span>
<span>{formatCurrency(-Math.abs(invoice.deduction_total ?? 0), invoice.currency)}</span>
</div>
<div className="flex justify-between font-bold text-lg tabular-nums">
<span>{t('amount_to_pay')}</span>
<span>{formatCurrency(amountToPay.toPay, invoice.currency)}</span>
</div>
</>
)}
</>
)
})()}
@@ -1526,6 +1646,103 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</CardContent>
</Card>
{/* Skattereduktion ROT/RUT card (fakturamodellen). The claim used to
be visible only on the PDF; whoever created the invoice needs it
here too: who pays what, the underlag Skatteverket needs, and
where the begäran om utbetalning stands. */}
{showDeduction && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Landmark className="h-5 w-5" />
{t('deduction_card_title', { kind: deductionKindLabel })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('deduction_customer_pays')}
</p>
<p className="font-display text-xl tabular-nums mt-1">
{formatCurrency(amountToPay.toPay, invoice.currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t('deduction_skv_pays')}
</p>
<p className="font-display text-xl tabular-nums mt-1">
{formatCurrency(invoice.deduction_total ?? 0, invoice.currency)}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('deduction_card_explainer')}</p>
<Separator />
<div className="space-y-2 text-sm">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_personnummer_label')}</span>
{invoice.deduction_personnummer_last4 ? (
<span className="tabular-nums">XXXXXXXX-{invoice.deduction_personnummer_last4}</span>
) : (
<span className="text-attn">{t('deduction_personnummer_missing')}</span>
)}
</div>
{hasRot && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_housing_label')}</span>
{rotHousing ? (
<span className="text-right">{rotHousing}</span>
) : rotBrf ? (
<span className="text-right">{t('deduction_brf_value', { org: rotBrf })}</span>
) : (
<span className="text-attn">{t('deduction_housing_missing')}</span>
)}
</div>
)}
{hasRot && rotApartment && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('deduction_apartment_label')}</span>
<span className="tabular-nums">{rotApartment}</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('deduction_claim_heading')}
</p>
{payoutRequests.length === 0 ? (
skvClaimable ? (
<AttnLine action={{ label: t('deduction_claim_cta'), href: '/invoices?rot-rut=1' }}>
{t('deduction_claim_ready')}
</AttnLine>
) : (
<p className="text-sm text-muted-foreground">{t('deduction_claim_not_yet')}</p>
)
) : (
payoutRequests.map((req) => (
<div key={req.id} className="flex items-center justify-between gap-4 text-sm">
<span className="tabular-nums">
{req.name} · {formatDate(req.decided_at ?? req.submitted_at ?? req.created_at)}
</span>
<span className="text-right text-muted-foreground tabular-nums">
{tInvoices(`rot_rut_status_${req.status}`)}
{req.decided_amount !== null &&
` · ${formatCurrency(req.decided_amount, invoice.currency)}`}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
)}
{/* Betalningsstatus card. Shows for both `paid` and `partially_paid`
so the user always sees how much has been paid + what remains +
the individual payment events. Previously only the `paid` case
+18
View File
@@ -3757,6 +3757,24 @@
"ore_rounding": "Öresavrundning",
"total": "Total",
"in_sek": "In SEK (rate {rate})",
"deduction_row": "{kind} tax reduction",
"amount_to_pay": "Amount to pay",
"deduction_line_hours": "{hours} h",
"deduction_line_amount": "reduction {amount}",
"deduction_card_title": "{kind} tax reduction",
"deduction_customer_pays": "Customer pays",
"deduction_skv_pays": "Skatteverket",
"deduction_card_explainer": "The customer pays the invoice total minus the tax reduction. You claim the rest from Skatteverket once the customer has paid (fakturamodellen).",
"deduction_personnummer_label": "Personnummer",
"deduction_personnummer_missing": "Missing",
"deduction_housing_label": "Property designation",
"deduction_housing_missing": "Missing",
"deduction_brf_value": "Housing cooperative, BRF {org}",
"deduction_apartment_label": "Apartment number",
"deduction_claim_heading": "Claim with Skatteverket",
"deduction_claim_not_yet": "Created once the customer has paid their share.",
"deduction_claim_ready": "The customer has paid. You can now claim the payout from Skatteverket.",
"deduction_claim_cta": "Create claim",
"notes_card_title": "Notes",
"reverse_charge_label": "Reverse charge",
"details_card_title": "Details",
+18
View File
@@ -3757,6 +3757,24 @@
"ore_rounding": "Öresavrundning",
"total": "Totalt",
"in_sek": "I SEK (kurs {rate})",
"deduction_row": "Skattereduktion {kind}",
"amount_to_pay": "Att betala",
"deduction_line_hours": "{hours} tim",
"deduction_line_amount": "avdrag {amount}",
"deduction_card_title": "Skattereduktion {kind}",
"deduction_customer_pays": "Kunden betalar",
"deduction_skv_pays": "Skatteverket",
"deduction_card_explainer": "Kunden betalar fakturabeloppet minus skattereduktionen. Resten begär du från Skatteverket när kunden har betalat (fakturamodellen).",
"deduction_personnummer_label": "Personnummer",
"deduction_personnummer_missing": "Saknas",
"deduction_housing_label": "Fastighetsbeteckning",
"deduction_housing_missing": "Saknas",
"deduction_brf_value": "Bostadsrätt, BRF {org}",
"deduction_apartment_label": "Lägenhetsnummer",
"deduction_claim_heading": "Begäran hos Skatteverket",
"deduction_claim_not_yet": "Skapas när kunden har betalat sin del.",
"deduction_claim_ready": "Kunden har betalat. Nu kan du begära utbetalning från Skatteverket.",
"deduction_claim_cta": "Skapa begäran",
"notes_card_title": "Anteckningar",
"reverse_charge_label": "Omvänd skattskyldighet",
"details_card_title": "Detaljer",