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:
Jakob Wennberg
2026-08-17 20:49:27 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 79240cb2ed
commit e030393fe6
19 changed files with 740 additions and 66 deletions
+1
View File
@@ -1041,3 +1041,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-17] Replay-masking skeptic round (PR #1639): explicit data-ph tags now resolve BEFORE the th chrome fallback in replayMaskText (a single closest over tags-plus-th let a th nested in a masked container win on DOM proximity, CodeRabbit); seven missed text-leak sites got call-site masks (delete-invoice number, credit-page number, IB voucher ref, TIC orgnr since TIC serves it unnormalized so the separator scrub cannot be relied on, articles search term, dimension segment labels, activate-account buttons); the attribute channel (placeholders prefilled with effective values, title tooltips) is handled with rrweb's blockClass: user-data placeholders carry ph-no-capture, which removes the element from the recording while app UX keeps the founder-approved prefill-override pattern intact. Chose ph-no-capture over stripping the placeholders because the prefilled effective value IS the UX.
[2026-08-17] Skattekontoutdrag file import (Sebastian's request) writes into skattekonto_transactions, not into transactions as a pseudo-bank with 1630 unlocked in BankFileConfirmStep: rows inherit the skattekonto_rules 1630 booking engine, matching, drift and both UIs for free, while the literal ask would bypass the rules and double against the SKV inbox for connected companies. Dedup pairs file hash-keys with API id-keys by CONTENT in both directions (import-time skip/promote against existing rows, sync-time takeover that rewrites an imported row's key in place so journal links survive connecting the API later). Import is free for everyone per the requireSkvCapability doctrine (manual paths never blocked); only sync/saldo stay capability-gated. The parse route hard-rejects files that fail detectSkattekontoFile and statements whose opening+sum!=closing, and warns on orgnr mismatch against company_settings: wrong-company imports are a known support-incident class.
[2026-08-17] articles.housework_type keeps two vocabularies (Skatteverket arbetstypskod, or bare ROT/RUT) instead of migrating legacy ROT/RUT rows: a kind-only row cannot be upgraded to a code without knowing the work, so the article form preserves the legacy choice as an explicit option and the invoice prefill treats it as kind-only; everything else normalizes to null and is rejected at the API.
[2026-08-17] ROT/RUT claim completeness (arbetstyp + arbetstimmar) is enforced at invoice creation (validateInvoice + CreateInvoiceItemSchema + editor), not only at begäran-file time: the file blocker fired when the invoice was already numbered/booked/paid with no repair path short of a credit note; schablontjänster (TRANSPORT/TVATT) stay hours-exempt. Yearly ceiling accumulation is per CUSTOMER in the editor (personnummer is ciphertext client-side) and warning-only; server warnings are still dropped on success, so the editor computes its own via the shared deductionCapWarnings helper.
+133 -14
View File
@@ -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,
+27
View File
@@ -278,6 +278,33 @@ describe('Enum schemas', () => {
// Invoice schemas
// ============================================================
describe('CreateInvoiceSchema: ROT/RUT line completeness', () => {
const paths = (r: { success: boolean; error?: { issues: Array<{ path: PropertyKey[] }> } }) =>
r.success ? [] : r.error!.issues.map((i) => i.path.join('.'))
const rutLine = (extra: Record<string, unknown>) => validInvoiceItem({ deduction_type: 'rut', ...extra })
it('requires a same-kind arbetstyp and hours > 0 on invoice documents', () => {
expect(paths(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({})] })))).toEqual(
expect.arrayContaining(['items.0.work_type', 'items.0.labor_hours']),
)
expect(paths(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'BYGG', labor_hours: 2 })] })))).toEqual(['items.0.work_type'])
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'STAD', labor_hours: 2 })] })).success).toBe(true)
// Schablontjänst: no hours needed
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [rutLine({ work_type: 'TVATT' })] })).success).toBe(true)
})
it('does not apply to proformas / delivery notes (server nulls the fields) nor to text rows', () => {
expect(CreateInvoiceSchema.safeParse(validInvoice({ document_type: 'proforma', items: [rutLine({})] })).success).toBe(true)
expect(CreateInvoiceSchema.safeParse(validInvoice({ items: [validInvoiceItem({ line_type: 'text', description: '', deduction_type: 'rut' })] })).success).toBe(true)
})
it('applies to UpdateInvoiceSchema too', () => {
const { save_as_draft: _s, ...body } = validInvoice({ items: [rutLine({})] }) as Record<string, unknown>
expect(UpdateInvoiceSchema.safeParse(body).success).toBe(false)
expect(UpdateInvoiceSchema.safeParse({ ...body, items: [rutLine({ work_type: 'STAD', labor_hours: 1 })] }).success).toBe(true)
})
})
describe('CreateInvoiceSchema', () => {
it('accepts a valid invoice', () => {
const result = CreateInvoiceSchema.safeParse(validInvoice())
+41 -3
View File
@@ -13,7 +13,13 @@ import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account'
import { HOUSEWORK_TYPE_VALUES, normalizeHouseworkType } from '@/lib/invoices/rot-rut-rules'
import {
DEDUCTION_LINE_ERRORS,
HOUSEWORK_TYPE_VALUES,
SCHABLON_WORK_TYPES,
deductionTypeForWorkType,
normalizeHouseworkType,
} from '@/lib/invoices/rot-rut-rules'
import { PERSONAL_NUMBER_INPUT_RE } from '@/lib/customers/mask-personal-number'
import type { AuditAction } from '@/types'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
@@ -469,7 +475,35 @@ export const CreateInvoiceItemSchema = z
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
export const CreateInvoiceSchema = z.object({
/**
* ROT/RUT claim completeness (HUSFL: art av arbete + antal arbetstimmar) at
* the invoice level, where document_type is known: only real invoices book a
* deduction (buildInvoiceWriteData nulls the fields for proformas, delivery
* notes and quotes), and free-text rows carry no claim. Field-level paths so
* the editor can point at the row; validateInvoice re-runs the same rules for
* callers that bypass this schema.
*/
function refineRotRutLineCompleteness(
data: { document_type?: string; items: Array<{ line_type?: string; deduction_type?: 'rot' | 'rut' | null; work_type?: string | null; labor_hours?: number | null }> },
ctx: z.RefinementCtx,
): void {
if (data.document_type && data.document_type !== 'invoice') return
data.items.forEach((item, index) => {
if (!item.deduction_type || item.line_type === 'text') return
const workType = item.work_type?.trim() || null
if (!workType) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['items', index, 'work_type'], message: DEDUCTION_LINE_ERRORS.workTypeMissing })
} else if (deductionTypeForWorkType(workType) !== item.deduction_type) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['items', index, 'work_type'], message: DEDUCTION_LINE_ERRORS.workTypeMismatch })
}
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: ['items', index, 'labor_hours'], message: DEDUCTION_LINE_ERRORS.hoursMissing })
}
})
}
const CreateInvoiceBaseSchema = z.object({
customer_id: uuid,
invoice_date: isoDate,
due_date: isoDate,
@@ -563,11 +597,15 @@ export const CreateInvoiceSchema = z.object({
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
export const CreateInvoiceSchema = CreateInvoiceBaseSchema.superRefine(refineRotRutLineCompleteness)
// Update (edit) an existing DRAFT invoice in place. Same shape as create minus
// `save_as_draft`: editing never (re)creates a draft or allocates a number, it
// only rewrites the draft's header + line items. The PATCH route guards that the
// target is still a draft (status='draft', no journal entry, not self-billed).
export const UpdateInvoiceSchema = CreateInvoiceSchema.omit({ save_as_draft: true })
export const UpdateInvoiceSchema = CreateInvoiceBaseSchema
.omit({ save_as_draft: true })
.superRefine(refineRotRutLineCompleteness)
export const CreateCreditNoteSchema = z.object({
credited_invoice_id: uuid,
@@ -1868,3 +1868,79 @@ describe('createInvoiceCashEntry: ROT/RUT-avdrag', () => {
expect(totalDebit).toBe(totalCredit)
})
})
describe('createInvoicePaymentJournalEntry: settles the outstanding amount, not the total', () => {
beforeEach(() => {
mockedCreateEntry.mockClear()
})
it('ROT/RUT invoice without paymentAmount clears 1510 by total minus the deduction (1513 keeps the rest)', async () => {
// 10 000 + 25 % = 12 500; ROT 30 % of labor incl. moms = 3 750 on 1513 at
// issue, so 1510 only ever carried 8 750. The no-lines mark-paid path used
// to book 12 500 here: 1510 went to -3 750 and 1930 was overstated.
const invoice = makeInvoice({
subtotal: 10000,
vat_amount: 2500,
total: 12500,
deduction_total: 3750,
paid_amount: 0,
remaining_amount: 8750,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
const bank = input.lines.find((l) => l.account_number === '1930')!
const ar = input.lines.find((l) => l.account_number === '1510')!
expect(bank.debit_amount).toBe(8750)
expect(ar.credit_amount).toBe(8750)
expect(input.description).toMatch(/^Inbetalning kundfaktura/)
})
it('previously part-paid invoice without paymentAmount clears only the remainder', async () => {
const invoice = makeInvoice({
total: 1250,
paid_amount: 500,
remaining_amount: 750,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1930')!.debit_amount).toBe(750)
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(750)
})
it('remaining_amount left at the DEFAULT 0 (import / seed paths) is treated as unmaintained', async () => {
const invoice = makeInvoice({
total: 12500,
deduction_total: 3750,
paid_amount: null,
remaining_amount: 0,
} as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(8750)
})
it('legacy row without remaining_amount derives it from total minus paid_amount', async () => {
const invoice = makeInvoice({ total: 1250, paid_amount: 250 } as Partial<Invoice>)
;(invoice as unknown as { remaining_amount?: number }).remaining_amount = undefined
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(1000)
})
it('fully outstanding invoice still books the full total (unchanged path)', async () => {
const invoice = makeInvoice({ total: 1250, paid_amount: 0, remaining_amount: 1250 } as Partial<Invoice>)
await createInvoicePaymentJournalEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-15')
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '1510')!.credit_amount).toBe(1250)
})
})
@@ -491,3 +491,68 @@ describe('proposePaymentLines: foreign currency without an exchange rate', () =>
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('14375')
})
})
describe('proposePaymentLines: ROT/RUT-avdrag (fakturamodellen)', () => {
// 10 000 labor + 25 % = 12 500; ROT 30 % of labor incl. moms = 3 750. The
// customer pays 8 750; 3 750 is a receivable on Skatteverket (1513).
const rotInvoice = () => ({ ...makeInvoiceInput(), deduction_total: 3750 })
it('accrual: bank and 1510 legs carry the customer share (total minus avdrag)', () => {
const lines = proposePaymentLines({
invoice: rotInvoice(),
accountingMethod: 'accrual',
entityType: 'aktiebolag',
})
expect(lines).toHaveLength(2)
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '8750' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '8750' })
})
it('accrual + öresavrundning: bank leg is the rounded "Att betala", 3740 carries the öre', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput({ total: 12500.4 }), deduction_total: 3750 },
accountingMethod: 'accrual',
entityType: 'aktiebolag',
companyOreRounding: true,
})
// Att betala on the PDF: round(12 500,40) - 3 750 = 8 750; 1510 clears
// 8 750,40; the 0,40 lands on 3740 (same polarity as the bank match).
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('8750')
expect(lines.find((l) => l.account_number === '1510')?.credit_amount).toBe('8750.4')
expect(lines.find((l) => l.account_number === '3740')?.debit_amount).toBe('0.4')
})
it('cash method: bank gets the customer share, 1513 the avdrag, revenue + moms in full', () => {
const lines = proposePaymentLines({
invoice: rotInvoice(),
accountingMethod: 'cash',
entityType: 'aktiebolag',
})
expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe('8750')
expect(lines.find((l) => l.account_number === '1513')?.debit_amount).toBe('3750')
expect(lines.find((l) => l.account_number === '3001')?.credit_amount).toBe('10000')
expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe('2500')
const debits = lines.reduce((s, l) => s + (parseFloat(l.debit_amount) || 0), 0)
const credits = lines.reduce((s, l) => s + (parseFloat(l.credit_amount) || 0), 0)
expect(Math.round((debits - credits) * 100)).toBe(0)
})
it('foreign invoice with a deduction and no booking rate refuses (1513 is a kronor receivable)', () => {
expect(() =>
proposePaymentLines({
invoice: { ...makeInvoiceInput({ currency: 'EUR', total_sek: 137500, exchange_rate: null }), deduction_total: 375 },
accountingMethod: 'accrual',
entityType: 'aktiebolag',
}),
).toThrow()
})
it('no deduction: unchanged full-total proposal', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput(), deduction_total: 0 },
accountingMethod: 'accrual',
entityType: 'aktiebolag',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' })
})
})
+43 -6
View File
@@ -548,6 +548,33 @@ export async function createInvoiceJournalEntry(
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* What the customer still owes on an invoice, in invoice currency:
* remaining_amount when the row carries it, else total minus paid_amount.
* remaining_amount is written as total minus the ROT/RUT deduction at
* creation (build-invoice-write.ts) and decremented per payment, so it is the
* one figure that already knows about both partial payments and the 1513
* share. Never falls back to the bare total when paid_amount is present.
*/
function invoiceOutstandingAmount(invoice: Invoice): number {
const inv = invoice as Invoice & {
remaining_amount?: number | null
paid_amount?: number | null
deduction_total?: number | null
}
// A payment is being booked, so a stored 0 cannot mean "settled": rows
// written by paths that bypass buildInvoiceWriteData (imports, sandbox seed,
// legacy migrations) leave the NOT NULL DEFAULT 0 in place. Treat 0 as
// unmaintained and derive: total minus prior payments minus the ROT/RUT
// share that was never the customer's to pay.
if (typeof inv.remaining_amount === 'number' && Number.isFinite(inv.remaining_amount) && inv.remaining_amount > 0) {
return roundOre(inv.remaining_amount)
}
const paid = typeof inv.paid_amount === 'number' ? inv.paid_amount : 0
const deduction = typeof inv.deduction_total === 'number' ? inv.deduction_total : 0
return roundOre(invoice.total - paid - deduction)
}
/**
* Create journal entry when an invoice is marked as paid
*
@@ -589,14 +616,24 @@ export async function createInvoicePaymentJournalEntry(
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
// When paymentAmount is provided, use it for the 1930/1510 line amounts.
// Otherwise use the full invoice total (backward compatible). Strict
// conversion on both: a rate-less foreign payment would otherwise clear
// 1510 with the raw foreign number relabelled as kronor (balanced against
// an equally wrong 1930 debit, so nothing downstream could catch it).
// Rows carrying total_sek or a usable rate convert exactly as before.
// Otherwise the payment settles what is still outstanding on the invoice:
// remaining_amount (total minus prior partial payments minus any ROT/RUT
// deduction, which sits on 1513 and is never the customer's to pay). Booking
// invoice.total here, as this path did before, credited 1510 for money that
// never arrived: 1510 went negative by the avdrag on every ROT/RUT invoice
// settled through mark-paid without lines, and 1930 was overstated by the
// same amount. Strict conversion on all three: a rate-less foreign payment
// would otherwise clear 1510 with the raw foreign number relabelled as
// kronor (balanced against an equally wrong 1930 debit, so nothing
// downstream could catch it). A fully outstanding invoice still converts via
// total_sek exactly as before, so legacy rows without a rate keep working.
const outstanding = invoiceOutstandingAmount(invoice)
const settlesFullTotal = Math.abs(outstanding - invoice.total) < 0.005
const bookedSekAmount = isPartial
? headerToSekOrThrow(paymentAmount, null, invoice.currency, invoice.exchange_rate)
: headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
: settlesFullTotal
? headerToSekOrThrow(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
: headerToSekOrThrow(outstanding, null, invoice.currency, invoice.exchange_rate)
const lines: CreateJournalEntryLineInput[] = []
+45 -7
View File
@@ -5,6 +5,7 @@
* No DB or Supabase dependency: all inputs are plain data.
*/
import { resolveSekAmount, resolveSekAmountOrNull } from './currency-utils'
import { roundOre } from '@/lib/money'
import {
getRevenueAccount,
getOutputVatAccount,
@@ -30,6 +31,14 @@ export interface ProposePaymentLinesInput {
items?: InvoiceItem[]
/** Per-invoice öresavrundning override; null = inherit the company setting. */
ore_rounding?: boolean | null
/**
* ROT/RUT-avdrag (fakturamodellen), invoice currency. The customer pays
* total minus this; the rest is a receivable on Skatteverket (1513) that
* was debited at issue (accrual) or is debited at payment (cash method).
* The proposal must therefore never expect the deduction on the bank leg:
* doing so is what made every ROT/RUT invoice fail the overpayment guard.
*/
deduction_total?: number | null
/**
* Dimensions PR7: the invoice's default bag. Stamped on every proposed
* line: the payment dialog always submits its (editable) lines, so the
@@ -97,9 +106,13 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
input.companyOreRounding === undefined ? undefined : { ore_rounding: input.companyOreRounding },
).roundingDelta
// 1513 is a kronor receivable, so the deduction converts with the invoice's
// booking rate or not at all: same refusal as generateRotRutLines.
const deductionSek = resolveDeductionSek(invoice)
const lines = accountingMethod === 'accrual'
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta)
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta)
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta, deductionSek)
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta, deductionSek)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
@@ -110,6 +123,14 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
return lines
}
function resolveDeductionSek(invoice: ProposePaymentLinesInput['invoice']): number {
const deduction = invoice.deduction_total ?? 0
if (deduction <= 0) return 0
const sek = resolveSekAmountOrNull(deduction, null, invoice.currency, invoice.exchange_rate)
if (sek === null) throw new InvoiceFxRateMissingError(invoice.currency)
return roundOre(sek)
}
/**
* The 3740 (öres- och kronutjämning) residual line. Customer paid over the
* stored total (rounded up) → credit (vinst); under (rounded down) → debit
@@ -129,14 +150,17 @@ function proposeAccrualLines(
paymentAccount: string,
desc: string,
exchangeRateDifference?: number,
roundingDelta = 0
roundingDelta = 0,
deductionSek = 0
): FormLine[] {
const bookedSekAmount = resolveSekAmount(
// The customer's share only: 1510 was debited total minus the ROT/RUT
// deduction at issue (1513 took the rest), so that is what the payment clears.
const bookedSekAmount = Math.round((resolveSekAmount(
invoice.total,
invoice.total_sek,
invoice.currency,
invoice.exchange_rate
)
) - deductionSek) * 100) / 100
const lines: FormLine[] = []
if (exchangeRateDifference && exchangeRateDifference !== 0) {
@@ -198,7 +222,8 @@ function proposeCashLines(
paymentAccount: string,
desc: string,
entityType: EntityType,
roundingDelta = 0
roundingDelta = 0,
deductionSek = 0
): FormLine[] {
const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK'
@@ -309,12 +334,25 @@ function proposeCashLines(
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
// Cash method: revenue + moms on the full amount, but the bank only ever
// receives the customer's share; the ROT/RUT deduction is debited to 1513
// (Skatteverket pays it later), mirroring createInvoiceCashEntry.
lines.push({
account_number: paymentAccount,
debit_amount: toFormAmount(debitAmount + roundingDelta),
debit_amount: toFormAmount(debitAmount - deductionSek + roundingDelta),
credit_amount: '',
line_description: desc,
})
if (deductionSek > 0) {
lines.push({
account_number: '1513',
debit_amount: toFormAmount(deductionSek),
credit_amount: '',
line_description: invoice.invoice_number
? `ROT/RUT-avdrag faktura ${invoice.invoice_number}`
: 'ROT/RUT-avdrag faktura',
})
}
lines.push(...creditLines)
@@ -6,6 +6,7 @@ import {
calculateReminderAmounts,
formatReminderTotalDue,
REMINDER_FEE_CURRENCY,
reminderPrincipal,
} from '../reminder-templates'
import { formatCurrency } from '@/lib/utils'
import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
@@ -253,3 +254,43 @@ describe('reminder email templates: statutory fee currency (Lag 1981:739)', () =
expect(html).toContain(formatCurrency(1_010, 'EUR'))
})
})
describe('reminder email templates: ROT/RUT-avdrag (fakturamodellen)', () => {
// 12 500 total with a 3 750 ROT deduction: the customer was asked for
// 8 750; the 3 750 is a claim on Skatteverket and must never be dunned.
const rotInvoice = makeInvoice({
invoice_number: 'F2026012',
invoice_date: '2026-04-15',
due_date: '2026-05-01',
currency: 'SEK',
total: 12_500,
deduction_total: 3_750,
} as Parameters<typeof makeInvoice>[0])
const rotData = { ...baseData, invoice: rotInvoice, interestAmount: 0, interestRate: 0, interestFromDate: '2026-05-02', interestDays: 0, reminderFee: 0 }
it('reminderPrincipal is the customer share', () => {
expect(reminderPrincipal(rotInvoice, company)).toBe(8_750)
expect(reminderPrincipal(invoice, company)).toBe(10_000)
})
it('HTML, text and subject never quote the pre-deduction total', () => {
const html = generateReminderEmailHtml(rotData)
const text = generateReminderEmailText(rotData)
const subject = generateReminderEmailSubject({ ...rotData, reminderFee: 60 })
for (const out of [html, text]) {
expect(out).toContain(formatCurrency(8_750, 'SEK'))
expect(out).not.toContain(formatCurrency(12_500, 'SEK'))
}
expect(subject).not.toContain(formatCurrency(12_500, 'SEK'))
})
it('folds interest and fee onto the customer share', () => {
const amounts = calculateReminderAmounts({
invoiceTotal: reminderPrincipal(rotInvoice, company),
interestAmount: 12.5,
reminderFee: 60,
currency: 'SEK',
})
expect(amounts.totalDue).toBe(8_822.5)
})
})
+18 -6
View File
@@ -1,5 +1,17 @@
import type { Invoice, Customer, CompanySettings } from '@/types'
import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
import { getAmountToPay } from '@/lib/invoices/rounding'
/**
* What the customer was asked to pay on the original invoice: the öre-rounded
* "Att betala" minus any ROT/RUT-avdrag (fakturamodellen: the deduction is a
* claim on Skatteverket, never on the customer). Reminders, dröjsmålsränta and
* the amount-to-pay line must all start from this figure, not invoice.total,
* or a privatperson is dunned for the SKV share and charged interest on it.
*/
export function reminderPrincipal(invoice: Invoice, company: CompanySettings): number {
return getAmountToPay(invoice, company).toPay
}
export interface ReminderEmailData {
invoice: Invoice
@@ -137,7 +149,7 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
const hasFee = reminderFee > 0
const hasSurcharges = hasInterest || hasFee
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
invoiceTotal: reminderPrincipal(invoice, company),
interestAmount,
reminderFee,
currency: invoice.currency,
@@ -226,7 +238,7 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Ursprungligt belopp:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(invoice.total, invoice.currency)}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${formatCurrency(reminderPrincipal(invoice, company), invoice.currency)}</td>
</tr>
${hasInterest ? `
<tr>
@@ -362,7 +374,7 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
const hasInterest = interestAmount > 0
const hasFee = reminderFee > 0
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
invoiceTotal: reminderPrincipal(invoice, company),
interestAmount,
reminderFee,
currency: invoice.currency,
@@ -391,7 +403,7 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
text += `Fakturanummer: ${invoice.invoice_number}\n`
text += `Fakturadatum: ${formatDate(invoice.invoice_date)}\n`
text += `Förfallodatum: ${formatDate(invoice.due_date)}\n`
text += `Ursprungligt belopp: ${formatCurrency(invoice.total, invoice.currency)}\n`
text += `Ursprungligt belopp: ${formatCurrency(reminderPrincipal(invoice, company), invoice.currency)}\n`
if (hasInterest) {
text += `Dröjsmålsränta (${interestRatePercent}% per år, ${interestDays} dagar): ${formatCurrency(interestAmount, invoice.currency)}\n`
}
@@ -438,14 +450,14 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
* the email.
*/
export function generateReminderEmailSubject(data: ReminderEmailData): string {
const { invoice, reminderLevel, interestAmount, reminderFee } = data
const { invoice, company, reminderLevel, interestAmount, reminderFee } = data
const config = REMINDER_CONFIG[reminderLevel]
const hasSurcharges = interestAmount > 0 || reminderFee > 0
// A SEK fee on a foreign-currency invoice renders as "1 010,00 € + 60 kr":
// two amounts in two currencies, never one mixed scalar.
const amounts = calculateReminderAmounts({
invoiceTotal: invoice.total,
invoiceTotal: reminderPrincipal(invoice, company),
interestAmount,
reminderFee,
currency: invoice.currency,
@@ -301,6 +301,7 @@ describe('buildInvoiceWriteData stored ROT/RUT personnummer (edit path)', () =>
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut' as const,
work_type: 'STAD',
labor_hours: 10,
}
@@ -371,6 +372,7 @@ describe('buildInvoiceWriteData kundkort personnummer fallback', () => {
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut' as const,
work_type: 'STAD',
labor_hours: 10,
}
@@ -514,6 +516,7 @@ describe('buildInvoiceWriteData kundkort fallback customer-type gate', () => {
unit_price: 500,
vat_rate: 25,
deduction_type: 'rut' as const,
work_type: 'STAD',
labor_hours: 10,
}],
},
+107 -4
View File
@@ -17,6 +17,11 @@ import {
HOUSEWORK_TYPE_VALUES,
ROT_WORK_TYPES,
RUT_WORK_TYPES,
validateDeductionLines,
deductionCapWarnings,
COMBINED_MAX,
DEDUCTION_LINE_ERRORS,
type ItemForDeduction,
type ValidateInvoiceItem,
} from '../rot-rut-rules'
@@ -258,7 +263,7 @@ describe('validateInvoice', () => {
it('RUT without housing_designation → no error (RUT does not require it)', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 5000, quantity: 1, deduction_type: 'rut' },
{ unit_price: 5000, quantity: 1, deduction_type: 'rut', work_type: 'STAD', labor_hours: 4 },
]
const result = validateInvoice(items, true, false)
expect(result.errors).toHaveLength(0)
@@ -273,7 +278,7 @@ describe('validateInvoice', () => {
it('warns about ROT cap when invoice alone exceeds 50 000', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 200000, quantity: 1, deduction_type: 'rot' }, // 60 000 deduction
{ unit_price: 200000, quantity: 1, deduction_type: 'rot', work_type: 'BYGG', labor_hours: 100 }, // 60 000 deduction
]
const result = validateInvoice(items, true, true)
expect(result.errors).toHaveLength(0)
@@ -317,7 +322,7 @@ describe('validateInvoice: foreign currency vs the kronor ceilings', () => {
it('warns when the SEK value breaches the cap even though the foreign figure does not', () => {
// 6 000 EUR avdrag looks tiny next to 50 000, but is 68 400 kr.
const items: ValidateInvoiceItem[] = [
{ unit_price: 20000, quantity: 1, deduction_type: 'rot' },
{ unit_price: 20000, quantity: 1, deduction_type: 'rot', work_type: 'BYGG', labor_hours: 10 },
]
const result = validateInvoice(items, true, true, { currency: 'EUR', exchangeRate: 11.4 })
expect(result.warnings).toHaveLength(1)
@@ -345,7 +350,7 @@ describe('validateInvoice: foreign currency vs the kronor ceilings', () => {
it('says the cap could not be checked when the invoice has no rate', () => {
const items: ValidateInvoiceItem[] = [
{ unit_price: 20000, quantity: 1, deduction_type: 'rot' },
{ unit_price: 20000, quantity: 1, deduction_type: 'rot', work_type: 'BYGG', labor_hours: 10 },
]
const result = validateInvoice(items, true, true, { currency: 'EUR' })
expect(result.errors).toHaveLength(0)
@@ -433,3 +438,101 @@ describe('workTypeLabel', () => {
expect(workTypeLabel(null)).toBeNull()
})
})
describe('validateDeductionLines (claim completeness at creation)', () => {
it('requires an arbetstyp and hours on every deduction line, once per message', () => {
const errors = validateDeductionLines([
{ unit_price: 500, quantity: 2, deduction_type: 'rut' },
{ unit_price: 500, quantity: 2, deduction_type: 'rot' },
])
expect(errors).toEqual([DEDUCTION_LINE_ERRORS.workTypeMissing, DEDUCTION_LINE_ERRORS.hoursMissing])
})
it('a work type from the other list is a mismatch, not a pass', () => {
const errors = validateDeductionLines([
{ unit_price: 500, quantity: 2, deduction_type: 'rut', work_type: 'BYGG', labor_hours: 2 },
])
expect(errors).toEqual([DEDUCTION_LINE_ERRORS.workTypeMismatch])
})
it('schablontjänster need no hours; everything else needs hours > 0', () => {
expect(validateDeductionLines([
{ unit_price: 500, quantity: 1, deduction_type: 'rut', work_type: 'TVATT' },
])).toEqual([])
expect(validateDeductionLines([
{ unit_price: 500, quantity: 1, deduction_type: 'rut', work_type: 'STAD', labor_hours: 0 },
])).toEqual([DEDUCTION_LINE_ERRORS.hoursMissing])
expect(validateDeductionLines([
{ unit_price: 500, quantity: 1, deduction_type: 'rut', work_type: 'STAD', labor_hours: 2.5 },
])).toEqual([])
})
it('non-deduction lines are ignored', () => {
expect(validateDeductionLines([{ unit_price: 500, quantity: 1 }])).toEqual([])
})
it('validateInvoice folds the line errors in', () => {
const result = validateInvoice(
[{ unit_price: 5000, quantity: 1, deduction_type: 'rut' }],
true,
false,
)
expect(result.errors).toContain(DEDUCTION_LINE_ERRORS.workTypeMissing)
expect(result.errors).toContain(DEDUCTION_LINE_ERRORS.hoursMissing)
})
})
describe('deductionCapWarnings: combined ceiling and prior-year accumulation', () => {
it('COMBINED_MAX is the shared 75 000 kr ceiling', () => {
expect(COMBINED_MAX).toBe(75000)
})
it('ROT 40 000 + RUT 40 000 on one invoice: neither per-kind cap trips, the combined one does', () => {
const warnings = deductionCapWarnings({ rot: 40000, rut: 40000 })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain(`gemensamma årsmaximum ${COMBINED_MAX.toLocaleString('sv-SE')} kr`)
expect(warnings[0]).toContain(`${sv(80000)} kr`)
})
it('a RUT breach alone does not add a redundant combined warning', () => {
const warnings = deductionCapWarnings({ rot: 1000, rut: 80000 })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toMatch(/^RUT-avdraget/)
})
it('ROT alone above 50 000 trips ROT only (combined not applicable to one kind)', () => {
const warnings = deductionCapWarnings({ rot: 60000, rut: 0 })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toMatch(/^ROT-avdraget/)
})
it('prior deductions this year push a modest invoice over the ceiling', () => {
// 3 x 20 000 kr ROT to the same person: the third invoice is the one that breaks 50 000.
expect(deductionCapWarnings({ rot: 20000, rut: 0 }, undefined, { rot: 40000, rut: 0 })).toEqual([
expect.stringContaining(`plus tidigare avdrag i år (${sv(40000)} kr)`),
])
expect(deductionCapWarnings({ rot: 20000, rut: 0 }, undefined, { rot: 20000, rut: 0 })).toEqual([])
// Prior RUT + this ROT: the combined ceiling binds across kinds.
expect(deductionCapWarnings({ rot: 20000, rut: 0 }, undefined, { rot: 0, rut: 60000 })).toEqual([
expect.stringContaining('gemensamma årsmaximum'),
])
})
it('foreign currency without a rate says so and never fabricates a combined check', () => {
const warnings = deductionCapWarnings({ rot: 4000, rut: 4000 }, { currency: 'EUR' })
expect(warnings).toHaveLength(2)
expect(warnings.every((w) => w.includes('saknar växelkurs'))).toBe(true)
})
it('validateInvoice forwards prior-year totals', () => {
const result = validateInvoice(
[{ unit_price: 20000, quantity: 1, deduction_type: 'rot', work_type: 'BYGG', labor_hours: 10 }],
true,
true,
undefined,
{ rot: 48000, rut: 0 },
)
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0]).toContain('plus tidigare avdrag')
})
})
+1
View File
@@ -336,6 +336,7 @@ export async function buildInvoiceWriteData(params: {
// below are stored with.
vat_rate: item.vat_rate !== undefined ? item.vat_rate : vatRules.rate,
labor_hours: item.labor_hours ?? null,
work_type: item.work_type ?? null,
housing_designation: item.housing_designation ?? null,
}))
+5 -1
View File
@@ -4,6 +4,7 @@ import {
generateReminderEmailHtml,
generateReminderEmailText,
generateReminderEmailSubject,
reminderPrincipal,
getReminderDaysConfig,
type ReminderDaysConfig,
} from '@/lib/email/reminder-templates'
@@ -291,8 +292,11 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
// Compute statutory late-payment interest (Räntelagen §6) using the
// company override if set, else Riksbankens referensränta + 8 pp.
const asOfDate = new Date().toISOString().split('T')[0]
// Interest accrues on what the customer actually owes: the invoice's
// "Att betala" (öre-rounded total minus any ROT/RUT-avdrag), never on the
// Skatteverket share sitting on 1513.
const interest = calculateLatePaymentInterest({
overdueAmount: invoice.total,
overdueAmount: reminderPrincipal(invoice as Invoice, company as CompanySettings),
dueDate: invoice.due_date,
asOfDate,
overrideRate: company.reminder_interest_rate_override,
+7 -3
View File
@@ -1,6 +1,6 @@
import type { Invoice, InvoiceItem } from '@/types'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { deductionSekConverter, type DeductionType } from './rot-rut-rules'
import { deductionSekConverter, SCHABLON_WORK_TYPES, type DeductionType } from './rot-rut-rules'
/**
* Begäran om utbetalning: rot & rut (Skatteverkets husavdragstjänst).
@@ -44,6 +44,10 @@ const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/ht/komponent/b
* is also the emission order inside UtfortArbete. `schablon` services are
* reported as <Utfort>true</Utfort>: no hours, no material.
*/
// `schablon` is derived from SCHABLON_WORK_TYPES (rot-rut-rules.ts), the one
// place that decides which services report utförd/ej utförd without hours:
// the invoice validator and this generator must never disagree on it.
const schablon = (code: string): boolean => SCHABLON_WORK_TYPES.includes(code)
const WORK_TYPE_ELEMENTS: Record<DeductionType, ReadonlyArray<{
code: string
element: string
@@ -70,8 +74,8 @@ const WORK_TYPE_ELEMENTS: Record<DeductionType, ReadonlyArray<{
{ code: 'REPARATION', element: 'ReparationAvVitvaror' },
{ code: 'MOBLERING', element: 'Moblering' },
{ code: 'TILLSYN', element: 'TillsynAvBostad' },
{ code: 'TRANSPORT', element: 'TransportTillForsaljning', schablon: true },
{ code: 'TVATT', element: 'TvattVidTvattinrattning', schablon: true },
{ code: 'TRANSPORT', element: 'TransportTillForsaljning', schablon: schablon('TRANSPORT') },
{ code: 'TVATT', element: 'TvattVidTvattinrattning', schablon: schablon('TVATT') },
],
}
+120 -22
View File
@@ -1,3 +1,5 @@
import { roundOre } from '@/lib/money'
/**
* ROT/RUT-avdrag rules.
*
@@ -46,6 +48,13 @@ export const ROT_MAX = 50000
/** Maximum yearly RUT deduction per person. SEK, same caveat as ROT_MAX. 2026 rule. */
export const RUT_MAX = 75000
/**
* ROT and RUT share one yearly ceiling per person: 75 000 kr in total, with
* ROT capped at 50 000 kr inside it (the 2024 H2 separation was temporary).
* SEK, same caveat as ROT_MAX.
*/
export const COMBINED_MAX = 75000
export type DeductionType = 'rot' | 'rut'
/**
@@ -280,6 +289,47 @@ export function computeDeductionTotalsByKind(items: ItemForDeduction[]): {
export interface ValidateInvoiceItem extends ItemForDeduction {
housing_designation?: string | null
/** Skatteverket arbetstypskod (ROT_WORK_TYPES / RUT_WORK_TYPES). */
work_type?: string | null
}
/**
* Schablontjänster are reported to Skatteverket as utförd/ej utförd, never
* with hours, so they are the one case where labor_hours is not required.
*/
export const SCHABLON_WORK_TYPES: readonly string[] = ['TRANSPORT', 'TVATT']
export const DEDUCTION_LINE_ERRORS = {
workTypeMissing: 'Arbetstyp krävs på alla ROT/RUT-rader.',
workTypeMismatch: 'Arbetstypen på raden hör inte till vald skattereduktion (ROT/RUT).',
hoursMissing: 'Antal arbetstimmar krävs på ROT/RUT-rader (schablontjänster undantagna).',
} as const
/**
* Per-line claim completeness: what the begäran om utbetalning to Skatteverket
* needs from every deduction line (HUSFL 2009:194: art av arbete och antal
* arbetstimmar). Checked at invoice creation because that is the last moment
* the line is editable: once the invoice is numbered, booked and paid, a
* missing arbetstyp used to surface only as a file-generation blocker with
* no repair path short of a credit note. Returns each message at most once.
*/
export function validateDeductionLines(items: ValidateInvoiceItem[]): string[] {
const errors = new Set<string>()
for (const item of items) {
if (!item.deduction_type) continue
const workType = item.work_type?.trim() || null
if (!workType) {
errors.add(DEDUCTION_LINE_ERRORS.workTypeMissing)
} else if (deductionTypeForWorkType(workType) !== item.deduction_type) {
errors.add(DEDUCTION_LINE_ERRORS.workTypeMismatch)
}
const isSchablon = workType != null && SCHABLON_WORK_TYPES.includes(workType)
const hours = item.labor_hours
if (!isSchablon && !(typeof hours === 'number' && Number.isFinite(hours) && hours > 0)) {
errors.add(DEDUCTION_LINE_ERRORS.hoursMissing)
}
}
return [...errors]
}
export interface ValidationResult {
@@ -308,6 +358,7 @@ export function validateInvoice(
personnummerProvided: boolean,
housingDesignationProvided: boolean,
money?: DeductionCurrencyContext,
priorYear?: PriorYearDeductions | null,
): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
@@ -319,38 +370,65 @@ export function validateInvoice(
errors.push('Personnummer krävs för ROT/RUT-avdrag.')
}
// Arbetstyp + arbetstimmar per line: required by the Skatteverket claim,
// and only fixable while the invoice is still a draft.
errors.push(...validateDeductionLines(items))
// ROT requires fastighetsbeteckning per Skatteverket's Husavdragstjänst.
// RUT does not (in 2026 the Skatteverket file accepts RUT without it).
if (hasAnyRot && !housingDesignationProvided) {
errors.push('Fastighetsbeteckning krävs för ROT-avdrag.')
}
const { rot, rut } = computeDeductionTotalsByKind(items)
warnings.push(...deductionCapWarnings(computeDeductionTotalsByKind(items), money, priorYear))
// computeDeductionTotalsByKind works in invoice currency; the ceilings are
// kronor. Convert before comparing, and never label a foreign figure "kr".
return { errors, warnings }
}
/** Deductions already claimed for the same person earlier in the year, in SEK. */
export interface PriorYearDeductions {
rot: number
rut: number
}
/**
* Yearly-ceiling warnings for one invoice's deductions (invoice currency),
* optionally on top of what the same person has already been granted this
* year (SEK). Three ceilings: ROT 50 000, RUT 75 000, and the shared 75 000
* (COMBINED_MAX) that ROT + RUT together must not exceed. Warnings, never
* errors: we cannot see claims made through other providers, so the customer
* still has to check their own remaining headroom.
*
* `totals` works in invoice currency; the ceilings are kronor. Convert before
* comparing, and never label a foreign figure "kr".
*/
export function deductionCapWarnings(
totals: { rot: number; rut: number },
money?: DeductionCurrencyContext,
priorYear?: PriorYearDeductions | null,
): string[] {
const warnings: string[] = []
const currencyLabel = (money?.currency ?? 'SEK').toUpperCase()
const toSek = deductionSekConverter(money)
const advice = 'Kunden behöver kontrollera sitt återstående utrymme själv.'
const priorRot = Math.max(0, priorYear?.rot ?? 0)
const priorRut = Math.max(0, priorYear?.rut ?? 0)
// Warning-text amounts: sv-SE digits, always two decimals, same convention
// as maxText below.
const svAmount = (n: number): string =>
n.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const maxTextOf = (max: number): string => `${max.toLocaleString('sv-SE')} kr`
const pushCapWarning = (kind: 'ROT' | 'RUT', amount: number, max: number): void => {
const rotSek = toSek ? toSek(totals.rot) : null
const rutSek = toSek ? toSek(totals.rut) : null
const pushCapWarning = (kind: 'ROT' | 'RUT', amount: number, amountSek: number | null, prior: number, max: number): void => {
if (amount <= 0) return
const maxText = `${max.toLocaleString('sv-SE')} kr`
const maxText = maxTextOf(max)
const priorText = prior > 0 ? ` plus tidigare avdrag i år (${svAmount(prior)} kr)` : ''
if (currencyLabel === 'SEK') {
if (amount <= max) return
warnings.push(
`${kind}-avdraget på denna faktura (${svAmount(amount)} kr) överstiger årsmaximum ${maxText}. ` + advice,
)
return
}
if (!toSek) {
if (amountSek === null) {
// No booking rate: we cannot know whether the ceiling is breached.
// Saying so beats both silence and a fabricated kronor comparison.
warnings.push(
@@ -359,17 +437,37 @@ export function validateInvoice(
)
return
}
const amountSek = toSek(amount)
if (amountSek <= max) return
if (amountSek + prior <= max) return
const figure = currencyLabel === 'SEK'
? `${svAmount(amount)} kr`
: `${svAmount(amount)} ${currencyLabel} = ${svAmount(amountSek)} kr`
warnings.push(
`${kind}-avdraget på denna faktura (${svAmount(amount)} ${currencyLabel} = ${svAmount(amountSek)} kr) ` +
`överstiger årsmaximum ${maxText}. ` + advice,
`${kind}-avdraget på denna faktura (${figure})${priorText} överstiger årsmaximum ${maxText}. ` + advice,
)
}
pushCapWarning('ROT', rot, ROT_MAX)
pushCapWarning('RUT', rut, RUT_MAX)
pushCapWarning('ROT', totals.rot, rotSek, priorRot, ROT_MAX)
pushCapWarning('RUT', totals.rut, rutSek, priorRut, RUT_MAX)
return { errors, warnings }
// The shared ceiling: only worth its own line when neither kind already
// tripped its own (a RUT breach of 75 000 implies the combined breach), and
// only when both kinds are in play across the year, otherwise the per-kind
// ceiling is the binding one (ROT alone caps at 50 000 anyway).
if (rotSek !== null && rutSek !== null) {
const rotYear = rotSek + priorRot
const rutYear = rutSek + priorRut
const combined = rotYear + rutYear
const bothKinds = rotYear > 0 && rutYear > 0
if (bothKinds && combined > COMBINED_MAX && rotYear <= ROT_MAX && rutYear <= RUT_MAX) {
const thisInvoice = roundOre(rotSek + rutSek)
const priorSum = priorRot + priorRut
const priorText = priorSum > 0 ? ` plus tidigare avdrag i år (${svAmount(priorSum)} kr)` : ''
warnings.push(
`ROT- och RUT-avdragen på denna faktura (${svAmount(thisInvoice)} kr)${priorText} överstiger tillsammans ` +
`det gemensamma årsmaximum ${maxTextOf(COMBINED_MAX)}. ` + advice,
)
}
}
return warnings
}
+3
View File
@@ -3572,6 +3572,9 @@
"deduction_cap_over": "The invoice's deduction exceeds the annual cap",
"deduction_cap_check": "The customer needs to check their remaining allowance themselves.",
"deduction_summary_label": "Tax reduction ROT/RUT",
"deduction_work_type_required": "Choose a work type for the ROT/RUT row.",
"deduction_work_type_mismatch": "The work type does not belong to the selected tax reduction (ROT/RUT).",
"deduction_hours_required": "Enter the number of labor hours (flat-rate services exempt).",
"to_pay_label": "Amount to pay",
"total_incl_vat_label": "Total incl. VAT",
"review_customer_missing_title": "Customer details could not be loaded",
+3
View File
@@ -3572,6 +3572,9 @@
"deduction_cap_over": "Fakturans avdrag överstiger årstaket",
"deduction_cap_check": "Kunden behöver kontrollera sitt återstående utrymme själv.",
"deduction_summary_label": "Skattereduktion ROT/RUT",
"deduction_work_type_required": "Välj arbetstyp för ROT/RUT-raden.",
"deduction_work_type_mismatch": "Arbetstypen hör inte till vald skattereduktion (ROT/RUT).",
"deduction_hours_required": "Ange antal arbetstimmar (schablontjänster undantagna).",
"to_pay_label": "Att betala",
"total_incl_vat_label": "Totalt inkl. moms",
"review_customer_missing_title": "Kunduppgifterna kunde inte laddas",