* 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>
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
/**
|
|
* Pure function to compute proposed journal entry lines for an invoice payment.
|
|
* Used by the PaymentBookingDialog to pre-fill the editable line grid.
|
|
*
|
|
* No DB or Supabase dependency: all inputs are plain data.
|
|
*/
|
|
import { resolveSekAmount, resolveSekAmountOrNull } from './currency-utils'
|
|
import { roundOre } from '@/lib/money'
|
|
import {
|
|
getRevenueAccount,
|
|
getOutputVatAccount,
|
|
InvoiceFxRateMissingError,
|
|
} from './invoice-entries'
|
|
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
|
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
|
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
|
import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
|
|
|
|
export interface ProposePaymentLinesInput {
|
|
invoice: {
|
|
invoice_number: string | null
|
|
total: number
|
|
total_sek?: number | null
|
|
subtotal: number
|
|
subtotal_sek?: number | null
|
|
vat_amount: number
|
|
vat_amount_sek?: number | null
|
|
currency: string
|
|
exchange_rate?: number | null
|
|
vat_treatment: VatTreatment
|
|
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
|
|
* preview IS the booked entry and must re-propagate the tag like the
|
|
* no-override generator path does. Per-item bags are not split out here
|
|
* (the preview groups per rate); users can retag lines in the grid.
|
|
*/
|
|
default_dimensions?: Record<string, string> | null
|
|
}
|
|
accountingMethod: 'accrual' | 'cash'
|
|
entityType: EntityType
|
|
paymentAccount?: string
|
|
exchangeRateDifference?: number
|
|
/**
|
|
* company_settings.ore_rounding. Combined with the per-invoice override via
|
|
* getDisplayTotal (SEK only, default-on) to decide whether the proposal
|
|
* expects the customer to pay the rounded "Att betala" from the PDF: then
|
|
* the bank leg is the rounded amount and 3740 carries the residual.
|
|
*/
|
|
companyOreRounding?: boolean
|
|
}
|
|
|
|
function toFormAmount(n: number): string {
|
|
const rounded = Math.round(n * 100) / 100
|
|
return rounded === 0 ? '' : rounded.toString()
|
|
}
|
|
|
|
/**
|
|
* Resolve the journal_entries.source_type used when booking an invoice payment.
|
|
*
|
|
* Mirrors the branching in app/api/invoices/[id]/mark-paid/route.ts: revenue is
|
|
* only recognised at payment (kontantmetoden / invoice_cash_payment) when the
|
|
* invoice has no prior issuance verifikat AND the company is on the cash method.
|
|
* Otherwise the payment clears the receivable (invoice_paid).
|
|
*
|
|
* Shared so the dialog's voucher preview and the route's actual booking always
|
|
* resolve the same series: they must not drift.
|
|
*/
|
|
export function resolveInvoicePaymentSourceType(opts: {
|
|
invoiceAlreadyBooked: boolean
|
|
accountingMethod: 'accrual' | 'cash'
|
|
}): 'invoice_cash_payment' | 'invoice_paid' {
|
|
const useCashEntry = !opts.invoiceAlreadyBooked && opts.accountingMethod === 'cash'
|
|
return useCashEntry ? 'invoice_cash_payment' : 'invoice_paid'
|
|
}
|
|
|
|
/**
|
|
* Propose journal entry lines for an invoice payment.
|
|
*
|
|
* Accrual: Debit paymentAccount, Credit 1510, optional exchange rate diff.
|
|
* Cash: Debit paymentAccount, Credit 30xx + 26xx per VAT rate group.
|
|
*/
|
|
export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[] {
|
|
const { invoice, accountingMethod, entityType, exchangeRateDifference } = input
|
|
const paymentAccount = input.paymentAccount || '1930'
|
|
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
|
|
|
|
// Öresavrundning: when it applies (SEK, enabled, non-integer total) the
|
|
// customer pays the rounded "Att betala" from the PDF, not the stored öre
|
|
// total. Propose the bank leg at the rounded amount and let 3740 carry the
|
|
// residual, so the default booking matches what actually hits the bank.
|
|
// getDisplayTotal returns delta 0 whenever rounding does not apply.
|
|
const roundingDelta = getDisplayTotal(
|
|
{ total: invoice.total, currency: invoice.currency, ore_rounding: invoice.ore_rounding },
|
|
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, deductionSek)
|
|
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta, deductionSek)
|
|
|
|
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
|
|
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
|
|
const bag = invoice.default_dimensions
|
|
if (bag && Object.keys(bag).length > 0) {
|
|
return lines.map((line) => ({ ...line, dimensions: { ...bag } }))
|
|
}
|
|
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
|
|
* (förlust). Same polarity as buildInvoicePaymentClearingLines.
|
|
*/
|
|
function oreRoundingLine(roundingDelta: number): FormLine {
|
|
return {
|
|
account_number: '3740',
|
|
debit_amount: roundingDelta < 0 ? toFormAmount(Math.abs(roundingDelta)) : '',
|
|
credit_amount: roundingDelta > 0 ? toFormAmount(roundingDelta) : '',
|
|
line_description: 'Öresavrundning',
|
|
}
|
|
}
|
|
|
|
function proposeAccrualLines(
|
|
invoice: ProposePaymentLinesInput['invoice'],
|
|
paymentAccount: string,
|
|
desc: string,
|
|
exchangeRateDifference?: number,
|
|
roundingDelta = 0,
|
|
deductionSek = 0
|
|
): FormLine[] {
|
|
// 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) {
|
|
const actualSekReceived = bookedSekAmount + exchangeRateDifference
|
|
|
|
lines.push({
|
|
account_number: paymentAccount,
|
|
debit_amount: toFormAmount(actualSekReceived),
|
|
credit_amount: '',
|
|
line_description: desc,
|
|
})
|
|
|
|
lines.push({
|
|
account_number: '1510',
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(bookedSekAmount),
|
|
line_description: desc,
|
|
})
|
|
|
|
if (exchangeRateDifference > 0) {
|
|
lines.push({
|
|
account_number: '3960',
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(exchangeRateDifference),
|
|
line_description: 'Valutakursvinst',
|
|
})
|
|
} else {
|
|
lines.push({
|
|
account_number: '7960',
|
|
debit_amount: toFormAmount(Math.abs(exchangeRateDifference)),
|
|
credit_amount: '',
|
|
line_description: 'Valutakursförlust',
|
|
})
|
|
}
|
|
} else {
|
|
const amount = Math.round(bookedSekAmount * 100) / 100
|
|
lines.push({
|
|
account_number: paymentAccount,
|
|
debit_amount: toFormAmount(amount + roundingDelta),
|
|
credit_amount: '',
|
|
line_description: desc,
|
|
})
|
|
lines.push({
|
|
account_number: '1510',
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(amount),
|
|
line_description: desc,
|
|
})
|
|
if (roundingDelta !== 0) {
|
|
lines.push(oreRoundingLine(roundingDelta))
|
|
}
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
function proposeCashLines(
|
|
invoice: ProposePaymentLinesInput['invoice'],
|
|
paymentAccount: string,
|
|
desc: string,
|
|
entityType: EntityType,
|
|
roundingDelta = 0,
|
|
deductionSek = 0
|
|
): FormLine[] {
|
|
const lines: FormLine[] = []
|
|
const isForeign = invoice.currency !== 'SEK'
|
|
|
|
// The cash-method preview IS the entry: PaymentBookingDialog submits these
|
|
// lines verbatim. A foreign invoice with no rate therefore must not be
|
|
// pre-filled with the raw foreign numbers relabelled as kronor: refuse with
|
|
// the same error the server generator raises (createInvoiceCashEntry). The
|
|
// dialog resolves the proposal inside a try/catch and surfaces the refusal as
|
|
// a translated toast, so throwing here is a visible dead-end, not a crash.
|
|
const toSek = (amount: number): number => {
|
|
const sek = resolveSekAmountOrNull(amount, null, invoice.currency, invoice.exchange_rate)
|
|
if (sek === null) throw new InvoiceFxRateMissingError(invoice.currency)
|
|
return sek
|
|
}
|
|
|
|
// Build credit lines per VAT rate group. Free-text / blank rows carry no
|
|
// amounts and never book: drop them first.
|
|
const creditLines: FormLine[] = []
|
|
const billableItems = (invoice.items ?? []).filter((item) => item.line_type !== 'text')
|
|
|
|
if (billableItems.length > 0) {
|
|
const hasPerLineVat = billableItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
|
|
|
|
if (!hasPerLineVat) {
|
|
// Legacy: single rate from invoice level
|
|
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
|
const subtotal = billableItems.reduce((sum, item) => sum + item.line_total, 0)
|
|
creditLines.push({
|
|
account_number: revenueAccount,
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(toSek(subtotal)),
|
|
line_description: (invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'),
|
|
})
|
|
|
|
const totalVat = billableItems.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
|
|
if (totalVat > 0) {
|
|
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
|
|
creditLines.push({
|
|
account_number: vatAccount,
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(toSek(totalVat)),
|
|
line_description: 'Utgående moms',
|
|
})
|
|
}
|
|
} else {
|
|
// Group items by vat_rate
|
|
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
|
|
for (const item of billableItems) {
|
|
const rate = item.vat_rate ?? 0
|
|
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
|
|
group.subtotal += item.line_total
|
|
group.vatAmount += item.vat_amount || 0
|
|
rateGroups.set(rate, group)
|
|
}
|
|
|
|
for (const [rate, group] of rateGroups) {
|
|
const treatment = rate === 0 && (invoice.vat_treatment === 'reverse_charge' || invoice.vat_treatment === 'export')
|
|
? invoice.vat_treatment
|
|
: getVatTreatmentForRate(rate)
|
|
const revenueAccount = getRevenueAccount(treatment, entityType)
|
|
|
|
creditLines.push({
|
|
account_number: revenueAccount,
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(Math.round(toSek(group.subtotal) * 100) / 100),
|
|
line_description: (invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'),
|
|
})
|
|
|
|
const roundedVat = Math.round(toSek(group.vatAmount) * 100) / 100
|
|
if (roundedVat !== 0) {
|
|
const vatAccount = getOutputVatAccount(treatment)
|
|
creditLines.push({
|
|
account_number: vatAccount,
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(roundedVat),
|
|
line_description: `Utgående moms ${rate}%`,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback: invoice-level amounts
|
|
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
|
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
|
|
creditLines.push({
|
|
account_number: revenueAccount,
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(subtotalSek),
|
|
line_description: (invoice.invoice_number ? `Försäljning faktura ${invoice.invoice_number}` : 'Försäljning faktura'),
|
|
})
|
|
|
|
if (invoice.vat_amount > 0) {
|
|
const vatSek = resolveSekAmount(invoice.vat_amount, invoice.vat_amount_sek, invoice.currency, invoice.exchange_rate)
|
|
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
|
|
creditLines.push({
|
|
account_number: vatAccount,
|
|
debit_amount: '',
|
|
credit_amount: toFormAmount(vatSek),
|
|
line_description: (invoice.invoice_number ? `Utgående moms faktura ${invoice.invoice_number}` : 'Utgående moms faktura'),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Debit: balance guarantee
|
|
const totalCredits = creditLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
|
const debitAmount = isForeign
|
|
? 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 - 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)
|
|
|
|
if (roundingDelta !== 0) {
|
|
lines.push(oreRoundingLine(roundingDelta))
|
|
}
|
|
|
|
return lines
|
|
}
|