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",