diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx
index eb3258e3..45632ab0 100644
--- a/app/(dashboard)/invoices/[id]/page.tsx
+++ b/app/(dashboard)/invoices/[id]/page.tsx
@@ -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(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(null)
const [originalInvoice, setOriginalInvoice] = useState(null)
const [convertedFromInvoice, setConvertedFromInvoice] = useState(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
)}
+ {item.deduction_type && (
+
+ {deductionLineInfo(item)}
+
+ )}
{item.quantity}
{item.unit}
@@ -1286,6 +1383,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
+ {item.deduction_type && (
+
+ {deductionLineInfo(item)}
+
+ )}
{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}
@@ -1337,7 +1439,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
})()}
{(() => {
- 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
{formatCurrency(rounding.roundingDelta, 'SEK')}
)}
-
-
{t('total')}
+ {/* 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. */}
+
+ {t('total')}
{formatCurrency(rounding.displayed, invoice.currency)}
+ {showDeduction && (
+ <>
+
+
+ {t('deduction_row', { kind: deductionKindLabel })}
+
+ {formatCurrency(-Math.abs(invoice.deduction_total ?? 0), invoice.currency)}
+
+
+ {t('amount_to_pay')}
+ {formatCurrency(amountToPay.toPay, invoice.currency)}
+
+ >
+ )}
>
)
})()}
@@ -1526,6 +1646,103 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
+ {/* 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 && (
+
+
+
+
+ {t('deduction_card_title', { kind: deductionKindLabel })}
+
+
+
+
+
+
+ {t('deduction_customer_pays')}
+
+
+ {formatCurrency(amountToPay.toPay, invoice.currency)}
+
+
+
+
+ {t('deduction_skv_pays')}
+
+
+ {formatCurrency(invoice.deduction_total ?? 0, invoice.currency)}
+
+
+
+ {t('deduction_card_explainer')}
+
+
+
+
+
+ {t('deduction_personnummer_label')}
+ {invoice.deduction_personnummer_last4 ? (
+ XXXXXXXX-{invoice.deduction_personnummer_last4}
+ ) : (
+ {t('deduction_personnummer_missing')}
+ )}
+
+ {hasRot && (
+
+ {t('deduction_housing_label')}
+ {rotHousing ? (
+ {rotHousing}
+ ) : rotBrf ? (
+ {t('deduction_brf_value', { org: rotBrf })}
+ ) : (
+ {t('deduction_housing_missing')}
+ )}
+
+ )}
+ {hasRot && rotApartment && (
+
+ {t('deduction_apartment_label')}
+ {rotApartment}
+
+ )}
+
+
+
+
+
+
+ {t('deduction_claim_heading')}
+
+ {payoutRequests.length === 0 ? (
+ skvClaimable ? (
+
+ {t('deduction_claim_ready')}
+
+ ) : (
+
{t('deduction_claim_not_yet')}
+ )
+ ) : (
+ payoutRequests.map((req) => (
+
+
+ {req.name} · {formatDate(req.decided_at ?? req.submitted_at ?? req.created_at)}
+
+
+ {tInvoices(`rot_rut_status_${req.status}`)}
+ {req.decided_amount !== null &&
+ ` · ${formatCurrency(req.decided_amount, invoice.currency)}`}
+
+
+ ))
+ )}
+
+
+
+ )}
+
{/* 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
diff --git a/messages/en.json b/messages/en.json
index 13a9bbad..fde7180a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/sv.json b/messages/sv.json
index e63fc695..9cf969a3 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -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",