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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user