fix(rot-rut): payment-side booking, reminders and claim completeness (#1652)
* fix(rot-rut): payment-side booking, reminders and claim completeness Follow-ups from the 2026-08-17 ROT/RUT audit (dev_docs/rot_rut_audit_2026_08_17.md). Payment side (fakturamodellen: the customer pays total minus avdraget, the rest is a 1513 receivable on Skatteverket): - createInvoicePaymentJournalEntry without an explicit paymentAmount used to book invoice.total on 1930/1510. Every no-lines mark-paid path (MCP mark_invoice_as_paid, v1 API, no-body dashboard route, Stripe) settles the outstanding amount, so on a ROT/RUT invoice 1510 went negative by the deduction and 1930 was overstated; same defect for any previously part-paid invoice. It now books the outstanding amount (remaining_amount, else total minus paid_amount); a fully outstanding invoice keeps the total_sek path. - proposePaymentLines had no deduction awareness: the payment dialog pre-filled D1930 total / K1510 total, which the settlement plan rejected as an overpayment, so a ROT/RUT invoice could not be marked paid from the UI. Accrual: bank + 1510 carry total minus avdrag; cash method: bank gets the customer share, 1513 the avdrag, revenue + moms in full. Foreign invoices without a booking rate refuse (1513 is a kronor receivable). Dialog passes deduction_total. - Reminders and dröjsmålsränta were computed on invoice.total: a privatperson was dunned for the Skatteverket share and charged interest on it. New reminderPrincipal() = the invoice's "Att betala" (öre-rounded total minus avdrag) drives the processor's interest base and all three templates. Claim completeness (HUSFL 2009:194: art av arbete + antal arbetstimmar): - work_type and labor_hours were optional at creation but hard blockers at begäran-file time, when the invoice is numbered, booked and paid and cannot be edited. validateDeductionLines() now requires a same-kind arbetstyp and hours > 0 (schablontjänster exempt) on every deduction line; wired into validateInvoice, CreateInvoiceItemSchema (field-level issues) and the editor schema with inline errors under the ROT/RUT strip. Fixed the labor_hours register (valueAsNumber overrode setValueAs: an emptied field became NaN and failed validation with no visible error). The Underlag card now shows whenever any row is flagged, matching the payload/server predicate. Yearly ceilings: - COMBINED_MAX 75 000 kr: ROT + RUT share one ceiling per person (ROT capped at 50 000 inside it). deductionCapWarnings() carries the per-kind and the combined check plus optional prior-year totals; validateInvoice forwards them; the editor uses the same helper and fetches what the customer has already been granted in the invoice year (per customer, warning only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): treat remaining_amount left at DEFAULT 0 as unmaintained when booking a payment Rows written by paths that bypass buildInvoiceWriteData (imports, sandbox seed, legacy migrations) carry remaining_amount = 0 while unpaid; prod has ~330 such open invoices. Booking 0 would have failed the engine's positive- amount rule, so the outstanding helper derives total - paid - deduction when the stored value is not positive. Test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): review follow-ups on #1652 - ROT/RUT completeness moves to the invoice-level schema (CreateInvoiceSchema / UpdateInvoiceSchema share one refine) so it only applies to real invoices and skips text rows; the editor gates its mirror on the document type via a ref. Tests moved accordingly (CodeRabbit). - Prior-year deduction lookup follows the PAYMENT year (paid_at, else invoice_date for open invoices), paginates via fetchAllRows, and clears the total on a failed request instead of leaving a stale one. - rot-rut-file derives its schablon flags from SCHABLON_WORK_TYPES so the validator and the generator cannot drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): pick the prior-year deductions client-side (phantom-columns ceiling) The runtime-built .or() filter counted as an unresolvable query expression for the no-phantom-columns guard. A customer has few deduction invoices, so fetch them all and select the payment year in code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- 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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
79240cb2ed
commit
e030393fe6
@@ -60,13 +60,17 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
|
||||
import {
|
||||
ROT_WORK_TYPES,
|
||||
RUT_WORK_TYPES,
|
||||
ROT_MAX,
|
||||
RUT_MAX,
|
||||
computeDeduction,
|
||||
deductionCapWarnings,
|
||||
deductionTypeForWorkType,
|
||||
parseArticleHouseworkType,
|
||||
SCHABLON_WORK_TYPES,
|
||||
type PriorYearDeductions,
|
||||
} from '@/lib/invoices/rot-rut-rules'
|
||||
import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK } from '@/lib/customers/mask-personal-number'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { isFiscalYear } from '@/lib/invariants'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
@@ -173,6 +177,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
}
|
||||
}, [paymentLinksEnabled])
|
||||
|
||||
// The item schema is memoised on translations only; whether ROT/RUT claim
|
||||
// completeness applies depends on the document type (proformas, delivery
|
||||
// notes and self-billing have no deduction model and the strip never
|
||||
// renders), so read that through a ref at validation time.
|
||||
const rotRutCompletenessAppliesRef = useRef(false)
|
||||
|
||||
const schema = useMemo(() => {
|
||||
const itemSchema = z.object({
|
||||
// 'text' rows carry only a (possibly empty) description: a free-text or
|
||||
@@ -227,6 +237,21 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
})
|
||||
}
|
||||
}
|
||||
// ROT/RUT claim completeness, mirrored from CreateInvoiceItemSchema:
|
||||
// arbetstyp + arbetstimmar are what the Skatteverket claim needs, and
|
||||
// creation is the last moment the line is editable.
|
||||
if (item.deduction_type && rotRutCompletenessAppliesRef.current && item.line_type !== 'text') {
|
||||
const workType = item.work_type?.trim() || null
|
||||
if (!workType) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['work_type'], message: t('deduction_work_type_required') })
|
||||
} else if (deductionTypeForWorkType(workType) !== item.deduction_type) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['work_type'], message: t('deduction_work_type_mismatch') })
|
||||
}
|
||||
const isSchablon = workType != null && SCHABLON_WORK_TYPES.includes(workType)
|
||||
if (!isSchablon && !(typeof item.labor_hours === 'number' && item.labor_hours > 0)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['labor_hours'], message: t('deduction_hours_required') })
|
||||
}
|
||||
}
|
||||
if (item.line_type === 'text') return
|
||||
if (item.description.trim().length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['description'], message: t('validation_description_required') })
|
||||
@@ -903,6 +928,76 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
const isSelfBilled = mode === 'self_billed'
|
||||
// ROT/RUT is an own-issued, B2C concept: never shown for a received self-bill.
|
||||
const isInvoiceDoc = watchDocumentType === 'invoice' && !isSelfBilled
|
||||
rotRutCompletenessAppliesRef.current = isInvoiceDoc
|
||||
|
||||
const watchInvoiceDate = watch('invoice_date')
|
||||
|
||||
// ROT/RUT yearly-ceiling context: what this customer has already been
|
||||
// granted in the invoice's calendar year (SEK), across issued invoices with
|
||||
// a deduction. Per customer, not per personnummer (the number is only ever
|
||||
// ciphertext here), and blind to other providers, so it feeds a warning,
|
||||
// never a block: the customer still owns their remaining headroom.
|
||||
const [priorYearDeductions, setPriorYearDeductions] = useState<PriorYearDeductions | null>(null)
|
||||
const invoiceYear = (watchInvoiceDate || '').slice(0, 4)
|
||||
useEffect(() => {
|
||||
if (!isInvoiceDoc || !company?.id || !watchCustomerId || !isFiscalYear(invoiceYear)) {
|
||||
setPriorYearDeductions(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
// The ceiling follows the year the buyer PAID (Skatteverket attributes the
|
||||
// skattereduktion to the payment year), so paid invoices count by paid_at
|
||||
// and open ones by invoice_date. A customer has few deduction invoices, so
|
||||
// fetch them all (paginated: PostgREST caps plain selects) and pick the
|
||||
// year here rather than through a runtime-built OR filter.
|
||||
fetchAllRows<{
|
||||
id: string
|
||||
currency: string | null
|
||||
exchange_rate: number | null
|
||||
paid_at: string | null
|
||||
invoice_date: string
|
||||
invoice_items: Array<{ deduction_type: 'rot' | 'rut' | null; deduction_amount: number | null }> | null
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, currency, exchange_rate, paid_at, invoice_date, invoice_items(deduction_type, deduction_amount)')
|
||||
.eq('company_id', company.id)
|
||||
.eq('customer_id', watchCustomerId)
|
||||
.eq('document_type', 'invoice')
|
||||
.is('credited_invoice_id', null)
|
||||
.not('status', 'in', '(draft,cancelled,credited)')
|
||||
.gt('deduction_total', 0)
|
||||
.order('id')
|
||||
.range(from, to),
|
||||
)
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
const totals: PriorYearDeductions = { rot: 0, rut: 0 }
|
||||
for (const inv of data) {
|
||||
if (initial?.id && inv.id === initial.id) continue
|
||||
if ((inv.paid_at ?? inv.invoice_date ?? '').slice(0, 4) !== invoiceYear) continue
|
||||
const isSek = !inv.currency || inv.currency === 'SEK'
|
||||
const rate = inv.exchange_rate
|
||||
if (!isSek && !(typeof rate === 'number' && rate > 0)) continue
|
||||
for (const it of inv.invoice_items ?? []) {
|
||||
if (!it.deduction_type || !it.deduction_amount) continue
|
||||
const sek = isSek ? it.deduction_amount : it.deduction_amount * (rate as number)
|
||||
totals[it.deduction_type] += roundOre(sek)
|
||||
}
|
||||
}
|
||||
setPriorYearDeductions(totals)
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed lookup must not leave a stale total from another customer or
|
||||
// year on screen; no prior context = per-invoice check only.
|
||||
if (!cancelled) setPriorYearDeductions(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// supabase client is stable; initial?.id only changes with the invoice.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isInvoiceDoc, company?.id, watchCustomerId, invoiceYear, initial?.id])
|
||||
const deductionByKind = { rot: 0, rut: 0 }
|
||||
if (isInvoiceDoc) {
|
||||
for (const item of watchItems) {
|
||||
@@ -920,7 +1015,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
}
|
||||
}
|
||||
const deductionTotal = Math.round((deductionByKind.rot + deductionByKind.rut) * 100) / 100
|
||||
const hasAnyDeduction = deductionTotal > 0
|
||||
// Same helper as the server validator (validateInvoice → deductionCapWarnings):
|
||||
// per-kind and shared yearly ceilings, on top of what this customer already
|
||||
// has this year. Statutory Swedish text (Skatteverket wording, stays Swedish
|
||||
// in both locales like the server warnings it mirrors).
|
||||
const capWarnings = isInvoiceDoc && deductionTotal > 0
|
||||
? deductionCapWarnings(deductionByKind, { currency: watchCurrency }, priorYearDeductions)
|
||||
: []
|
||||
// Any flagged row, not only rows with a positive amount yet: the payload
|
||||
// sanitizer and the server both key on deduction_type, so the card (with
|
||||
// the personnummer the server will demand) must appear on the same predicate.
|
||||
const hasAnyDeduction = deductionTotal > 0 || (isInvoiceDoc && watchItems.some((i) => Boolean(i.deduction_type)))
|
||||
const hasAnyRotLine = isInvoiceDoc && watchItems.some((i) => i.deduction_type === 'rot')
|
||||
// The kundkort's personnummer reaches this component as ciphertext (direct
|
||||
// table read) or as the masked display form (rows from the API), so the
|
||||
@@ -1994,7 +2099,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
value={workField.value ?? ''}
|
||||
onValueChange={(v) => workField.onChange(v || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-56">
|
||||
<SelectTrigger
|
||||
className="h-8 w-56"
|
||||
aria-label={t('deduction_work_type_placeholder')}
|
||||
aria-invalid={Boolean(errors.items?.[index]?.work_type) || undefined}
|
||||
>
|
||||
<SelectValue placeholder={t('deduction_work_type_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -2014,10 +2123,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
inputMode="decimal"
|
||||
placeholder={t('deduction_hours_placeholder')}
|
||||
className="h-8 w-32 text-right tabular-nums"
|
||||
aria-label={t('deduction_hours_placeholder')}
|
||||
aria-invalid={Boolean(errors.items?.[index]?.labor_hours) || undefined}
|
||||
{...register(`items.${index}.labor_hours`, {
|
||||
valueAsNumber: true,
|
||||
setValueAs: (v) =>
|
||||
v === '' || Number.isNaN(v) ? null : Number(v),
|
||||
// valueAsNumber would override setValueAs and
|
||||
// turn an emptied field into NaN, which the
|
||||
// schema rejects with no visible error.
|
||||
setValueAs: (v) => {
|
||||
if (v === '' || v == null) return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{(() => {
|
||||
@@ -2036,6 +2152,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
{(errors.items?.[index]?.work_type || errors.items?.[index]?.labor_hours) && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
{errors.items?.[index]?.work_type?.message ?? errors.items?.[index]?.labor_hours?.message}
|
||||
</p>
|
||||
)}
|
||||
{/* Labor-only disclosure (Skatteverket fakturamodellen).
|
||||
30%/50% applies to the full line total: the seller
|
||||
must ensure the line is 100% labor; material has
|
||||
@@ -2283,13 +2404,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(deductionByKind.rot > ROT_MAX || deductionByKind.rut > RUT_MAX) && (
|
||||
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t('deduction_cap_over')}
|
||||
{deductionByKind.rot > ROT_MAX && ` (ROT ${ROT_MAX.toLocaleString('sv-SE')} kr)`}
|
||||
{deductionByKind.rut > RUT_MAX && ` (RUT ${RUT_MAX.toLocaleString('sv-SE')} kr)`}
|
||||
{'. '}
|
||||
{t('deduction_cap_check')}
|
||||
{capWarnings.length > 0 && (
|
||||
<div className="space-y-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
{capWarnings.map((w) => (
|
||||
<p key={w}>{w}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -165,6 +165,7 @@ export default function PaymentBookingDialog({
|
||||
items: invoice.items,
|
||||
default_dimensions: invoice.default_dimensions,
|
||||
ore_rounding: invoice.ore_rounding,
|
||||
deduction_total: invoice.deduction_total,
|
||||
},
|
||||
accountingMethod,
|
||||
entityType,
|
||||
|
||||
Reference in New Issue
Block a user