feat: custom inbound mail domains, rot/rut payout file, invoice email texts, security hardening (#878)

* fix(security): guard MCP test keys, RLS role gate + voucher RPC guards, /api MFA gate, deps

- MCP: force dry-run / block writes for test-mode API keys in tools/call (extensions/general/mcp-server)
- DB: current_user_can_write role gate on write policies (40 tables) + tenant guards, SET search_path, REVOKE anon on commit_journal_entry / next_voucher_number / detect_voucher_gaps (migration 20260702093000)
- Middleware: MFA (AAL2) gate on cookie-authenticated /api routes via apiPathSkipsMfaGate
- Deps: npm audit fix clears mailparser/linkify-it/nodemailer/svix/uuid highs; xlsx -> SheetJS 0.20.3

Adds unit + pg-real tests. Does not touch in-progress ROT/RUT or invoice-email-texts work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): rot/rut begäran om utbetalning — HUS XML (V6), payout tracking + settlement, MCP tool

Generates Skatteverkets begäran-om-utbetalning file (schema V6) from paid
ROT/RUT invoices — no submission API exists, the file is uploaded manually
at skatteverket.se. Headless by design for now: API routes + MCP tool
(gnubok_generate_rot_rut_file), no UI surfaces.

- lib/invoices/rot-rut-file.ts: pure XML generator with deterministic
  per-invoice blockers (hours, work type, personnummer, property info,
  mixed rot+rut, XSD limits) + 31 January deadline warnings
- rot_rut_payout_requests(+items) tables: one active begäran per invoice
  (DB triggers incl. reactivation guard), RLS, audit, pg-real tests
- Settlement: POST /settle books debit 1930 / credit 1513 via the engine
  (source_type rot_rut_payout); partial payouts → partially_paid
- Work-type lists corrected against Begaran.xsd: IT-tjänster is rut-only,
  snöskottning/tillsyn/tvätt added (schablontjänster utfört-only)
- Fix: invoice-level fastighetsbeteckning was validated but never
  persisted — now stamped onto rot lines in build-invoice-write; API
  accepts bostadsrätt pair (lägenhetsnr + BRF orgnr, editor UI deferred)
- invoice_items.brf_org_number migration + MCP scope invoices:write

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): per-company editable invoice email texts

Add an "E-posttexter" section under Settings -> Fakturering where the
subject, greeting, body and sign-off of the standard invoice email can
be customized per company in Swedish and English. Fields pre-fill with
the standard texts and only diffs from the standard are stored
(company_settings.invoice_email_texts JSONB), so future improvements to
the stock wording still reach companies that have not customized. Each
field has a reset-to-standard button; cleared fields snap back.

Texts support a fixed placeholder set (invoice number, customer name,
first name, company, due date, amount) substituted at send time in a
single pass; unknown placeholders stay literal. Custom texts are
HTML-escaped after substitution, newlines become <br> in the HTML
variant, and subject lines are flattened to a single header line.
Overrides apply to standard invoices only - credit notes, proforma and
delivery notes keep the stock texts. All send paths (UI, v1 API, MCP
approval, recurring) pick the texts up via the existing settings row.

The Zod schema half of this change (InvoiceEmailTextsSchema in
lib/api/schemas.ts) was inadvertently included in 8291f745.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): accept PDFs with preamble before %PDF- header, surface content rejections as 400

detectFileMagic required the %PDF- signature at byte 0 (BOM aside),
rejecting genuine PDFs that carry a leading newline or junk bytes —
files every ISO 32000 reader opens fine. Now scan the first 1024 bytes
for the signature, matching real-reader behavior. Image types stay
strict at offset 0 to keep the anti-placeholder defense tight.

Magic-byte rejections were also mislabeled as DOC_UPLOAD_STORAGE_FAILED
(500 'Filen kunde inte sparas'), blaming storage for a client-side file
problem. Both upload routes now map them to a new
DOC_UPLOAD_INVALID_CONTENT (400) with an accurate message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): full keyboard flow for manual journal entry

Enter now drives the whole verifikat flow: verifikationstext drops into
the first row missing an account, konto commits advance to debet, Enter
on an empty debet hops to kredit, and an entered amount jumps to the
next row. Once the voucher balances, Enter opens the review (unchanged
gate) and the auto-focused confirm posts it — including through the
no-underlag warning dialog. Escape in the inline review goes back to
the form.

Also fixes an Enter footgun in AccountCombobox: a bare Enter on a
freshly focused field no longer selects the first account in the list —
selection now requires typing or arrow navigation; otherwise Enter
re-commits the current value or bubbles to the form-level handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: add custom inbound domains management for companies

- Implemented functionality to allow companies to claim and manage their own inbound email domains via Resend's API.
- Created a new table `company_inbound_domains` to store domain information, including status and DNS records.
- Added necessary RLS policies to restrict access based on user roles (owner/admin).
- Developed functions for domain normalization, validation, claiming, verification, and removal.
- Implemented webhook handling for domain status updates from Resend.
- Added comprehensive tests for RLS, constraints, and triggers related to the new domain management feature.

* fix: address PR #878 review findings and CI failures

- migrations: drop the ai_usage_tracking policy block from the role-gate
  migration — the table was removed by 20260504120000_remove_ai_subsystem
  and only lingers on staging as drift; a from-scratch chain (pg-real,
  Supabase preview) failed on it
- invoice-inbox: never flip a custom domain to verified off a domain.updated
  webhook alone — confirm the receiving capability with Resend first
  (fail-closed); normalize both sides of the orphan-adoption domain match
- rot/rut: block files where begärt belopp exceeds what the buyer paid
  (DEDUCTION_EXCEEDS_PAYMENT); tighten brf_org_number validation to real
  orgnr shapes; parameterize the settlement bank account (19xx, default 1930)
- rot/rut routes: log acting user on financial mutations, stop swallowing
  item mirror errors, narrow response projections (no customer ids through
  the invoice join); document the deliberate inline-XML decision
- documents: stop echoing raw storage-layer error messages to clients

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: round-2 CI + compliance findings on PR #878

- migrations: the role-gate migration targeted automation_webhooks, which
  20260515170000_webhooks_v2 renamed to webhooks on the canonical chain
  (staging kept the old name — drift); gate public.webhooks instead,
  dropping legacy schema-sync policy names defensively. Restore the
  20260623130000 owner fallback in next_voucher_number that the stale
  copied-verbatim body silently reverted (caught by engine.pg locally).
  Full migration chain verified from scratch against supabase/postgres:15.
- mcp: bump the tools/list payload ceiling 44K -> 45K — main's #877
  qualified-identifier schemas plus this branch's rot/rut tool crossed the
  ceiling only in combination; documented in the test's history log.
- rot/rut: refuse partial settlement before Skatteverkets beslut is
  recorded (would bypass the PATCH lifecycle and strand the request);
  block zero-kronor ärenden (ZERO_DEDUCTION); require sekelsiffra 16 on
  12-digit brf orgnr in both schema validation and normalizeBrfOrgNr

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: rename branch migrations off main's colliding versions

After the merge with main, two versions were shared by two files each
(20260702100000: rot_rut_payout_requests vs company_settings_dimensions_
enabled; 20260702130000: invoice_email_texts vs pending_operations_add_
create_dimension_value). psql-based CI applies by filename and doesn't
care, but Supabase branching records migrations by version (PK) — the
second file with the same version breaks the preview with a
schema_migrations_pkey duplicate. Neither branch migration is version-
recorded on staging or prod, so renaming to fresh 20260703 versions is
safe; nothing between the old and new positions depends on these objects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): scope the /api MFA-gate bypass to real Bearer-auth surfaces

Any Authorization header — attacker-controlled — used to skip the AAL2
gate for every /api route, so a stolen-password AAL1 cookie session could
reach cookie-authenticated routes (which ignore the header) by attaching
`Authorization: x`. The skip is now scoped to the surfaces whose auth
contract IS the header (/api/v1 API keys, the MCP endpoint's OAuth
tokens); pure Bearer callers elsewhere (cron secret, signed webhooks)
carry no cookie session and were never touched by the gate, which only
fires for cookie users. Superagent P2 on PR #878.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: normalize path separators in dimension statutory guard scan

The route scan compared walked file paths against a POSIX-path allowlist,
so the suite failed on Windows (backslash separators) while passing on
Linux CI. Normalize the scanned paths to forward slashes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-03 13:57:59 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 678f2ccffd
commit 237b77a366
61 changed files with 7695 additions and 394 deletions
+97
View File
@@ -426,6 +426,27 @@ describe('CreateInvoiceItemSchema', () => {
expect(result.success).toBe(false)
})
it('accepts orgnr-shaped brf_org_number values', () => {
for (const value of ['769600-0000', '7696000000', '167696000000']) {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: value }))
expect(result.success).toBe(true)
}
})
it('normalizes an empty brf_org_number to null', () => {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: '' }))
expect(result.success).toBe(true)
if (result.success) expect(result.data.brf_org_number).toBeNull()
})
it('rejects malformed brf_org_number values', () => {
// incl. a 12-digit value without the mandatory sekelsiffra 16 prefix
for (const value of ['---', '123', '76-96000000', 'ABC600-0000', '123456789012']) {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: value }))
expect(result.success).toBe(false)
}
})
it('rejects a product row with an empty description', () => {
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ description: ' ' }))
expect(result.success).toBe(false)
@@ -1307,6 +1328,82 @@ describe('UpdateSettingsSchema', () => {
expect(result.success).toBe(true)
})
})
describe('invoice_email_texts', () => {
it('accepts a valid nested partial', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_texts: { sv: { body: 'Tack för din beställning!' } },
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.invoice_email_texts).toEqual({
sv: { body: 'Tack för din beställning!' },
})
}
})
it('accepts both languages with all four fields', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_texts: {
sv: {
subject: 'Faktura {fakturanummer}',
greeting: 'Hejsan,',
body: 'Här kommer fakturan.',
signoff: 'Allt gott,',
},
en: {
subject: 'Invoice {fakturanummer}',
greeting: 'Hello,',
body: 'Please find the invoice attached.',
signoff: 'Best,',
},
},
})
expect(result.success).toBe(true)
})
it('accepts null to clear all overrides', () => {
const result = UpdateSettingsSchema.safeParse({ invoice_email_texts: null })
expect(result.success).toBe(true)
if (result.success) expect(result.data.invoice_email_texts).toBeNull()
})
it('rejects body over 2000 characters', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_texts: { sv: { body: 'x'.repeat(2001) } },
})
expect(result.success).toBe(false)
})
it('rejects subject over 200 characters', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_texts: { sv: { subject: 'x'.repeat(201) } },
})
expect(result.success).toBe(false)
})
it('rejects a non-string field value', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_texts: { sv: { subject: 123 } },
})
expect(result.success).toBe(false)
})
it('strips unknown keys inside a language object', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_texts: { sv: { body: 'Hej', subjct: 'typo' } },
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.invoice_email_texts).toEqual({ sv: { body: 'Hej' } })
}
})
it('rejects a bare string as the column value', () => {
const result = UpdateSettingsSchema.safeParse({ invoice_email_texts: 'Tack!' })
expect(result.success).toBe(false)
})
})
})
// ============================================================
+76
View File
@@ -285,6 +285,18 @@ export const CreateInvoiceItemSchema = z
work_type: z.string().max(64).nullable().optional(),
housing_designation: z.string().max(128).nullable().optional(),
apartment_number: z.string().max(32).nullable().optional(),
// Bostadsrättsföreningens orgnr (XSD BrfOrgNrTYPE). If present it must be
// a real orgnr shape — 10 digits (optional dash after position 6) or the
// 12-digit sekelsiffra form — or Skatteverkets schemavalidering rejects
// the whole file at upload time. Empty string = cleared field → null.
brf_org_number: z
.union([
z.string().regex(/^(\d{6}-?\d{4}|16\d{10})$/, 'Ogiltigt organisationsnummer (10 siffror, ev. med bindestreck)'),
z.literal(''),
])
.transform((v) => v || null)
.nullable()
.optional(),
// Periodisering (förutbetald intäkt): defer the line's net revenue over
// the service period. The revenue entry credits the 29xx interim account
// instead of the revenue account; output VAT is never deferred.
@@ -354,6 +366,18 @@ export const CreateInvoiceSchema = z.object({
// present (enforced via rot-rut-rules.validateInvoice in the API).
deduction_personnummer: z.string().max(20).optional(),
deduction_housing_designation: z.string().max(128).optional(),
// ROT i bostadsrätt: lägenhetsnummer + föreningens orgnr replace the
// fastighetsbeteckning (Begaran.xsd: LagenhetsNr + BrfOrgNr). Stamped onto
// the rot lines server-side, same as deduction_housing_designation.
deduction_apartment_number: z.string().max(25).optional(),
// Same orgnr shape rule as items[].brf_org_number; empty string = not set.
deduction_brf_org_number: z
.union([
z.string().regex(/^(\d{6}-?\d{4}|16\d{10})$/, 'Ogiltigt organisationsnummer (10 siffror, ev. med bindestreck)'),
z.literal(''),
])
.transform((v) => v || undefined)
.optional(),
// When true, save as an unnumbered draft: skip F-series allocation and the
// invoice.created event until the user finalizes via POST /invoices/{id}/finalize
// ("Granska och skapa"). An unnumbered draft is not yet an issued faktura
@@ -379,6 +403,36 @@ export const CreateCreditNoteSchema = z.object({
reason: z.string().optional(),
})
// ============================================================
// Rot/rut begäran om utbetalning (Skatteverkets husavdragstjänst)
// ============================================================
export const RotRutPayoutFileSchema = z.object({
deduction_type: z.enum(['rot', 'rut']),
invoice_ids: z.array(uuid).min(1).max(500),
// NamnPaBegaran — the XSD caps it at 16 chars; omitted → generated.
name: z.string().min(1).max(16).optional(),
})
export const RotRutRequestPatchSchema = z.object({
status: z.enum(['submitted', 'paid', 'partially_paid', 'rejected', 'cancelled']),
// Godkänt belopp from Skatteverkets beslut. Only meaningful together with
// paid/partially_paid/rejected.
decided_total: nonNegativeAmount.optional(),
})
export const RotRutSettleSchema = z.object({
payment_date: isoDate,
// Defaults server-side to decided_total ?? requested_total.
amount: z.number().positive().optional(),
// BAS 19xx account the payout landed on (1920 Bank, 1930 Företagskonto, …).
// Omitted → 1930. The engine validates existence against chart_of_accounts.
bank_account: z
.string()
.regex(/^19\d{2}$/, 'Bankkontot måste vara ett BAS 19xx-konto')
.optional(),
})
// ============================================================
// Articles (artikelregister)
// ============================================================
@@ -1165,6 +1219,24 @@ export const MatchSupplierInvoiceSchema = z.object({
// Settings schemas
// ============================================================
// Editable invoice email texts (standard invoices only). Nested JSONB —
// unknown keys inside are stripped (Zod default, consistent with this file).
// Empty strings pass validation; the template resolver treats whitespace-only
// as unset, and the UI prunes empties before saving so the stored object
// stays minimal. Subject is a mail header: CR/LF are stripped at render time
// regardless.
const InvoiceEmailTextsLangSchema = z.object({
subject: z.string().max(200, 'Ämnesraden får vara max 200 tecken').optional(),
greeting: z.string().max(200, 'Hälsningen får vara max 200 tecken').optional(),
body: z.string().max(2000, 'Brödtexten får vara max 2000 tecken').optional(),
signoff: z.string().max(200, 'Avslutningen får vara max 200 tecken').optional(),
})
export const InvoiceEmailTextsSchema = z.object({
sv: InvoiceEmailTextsLangSchema.optional(),
en: InvoiceEmailTextsLangSchema.optional(),
})
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
@@ -1242,6 +1314,10 @@ export const UpdateSettingsSchema = z.object({
invoice_company_name_position: z.enum(['header', 'footer']).optional(),
invoice_late_fee_text: z.string().nullable().optional(),
invoice_credit_terms_text: z.string().nullable().optional(),
// Editable invoice email texts — { sv?: {...}, en?: {...} }; null clears
// all overrides. Without this entry the generic PUT would silently strip
// the field (the schema is the de-facto column whitelist).
invoice_email_texts: InvoiceEmailTextsSchema.nullable().optional(),
// Invoice branding — colors enforced as #RRGGBB at the DB level too
// (see migration 20260526120200_invoice_branding.sql). The dedicated
// /api/settings/invoicing/branding route is the primary path; these
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
describe('apiPathSkipsMfaGate', () => {
it('skips the gate only on Bearer-auth surfaces when an Authorization header is present', () => {
expect(apiPathSkipsMfaGate('/api/v1/companies/abc/invoices', true)).toBe(true)
expect(apiPathSkipsMfaGate('/api/extensions/ext/mcp-server/mcp', true)).toBe(true)
})
it('never lets an Authorization header disable the gate on cookie-authenticated routes', () => {
// A stolen-password AAL1 cookie session must not bypass MFA by attaching
// a garbage Authorization header the route ignores (Superagent P2, #878).
expect(apiPathSkipsMfaGate('/api/bookkeeping/journal-entries/123', true)).toBe(false)
expect(apiPathSkipsMfaGate('/api/reports/full-archive', true)).toBe(false)
expect(apiPathSkipsMfaGate('/api/salary/employees/1', true)).toBe(false)
// Non-MCP extension routes authenticate via cookies in the dispatcher.
expect(apiPathSkipsMfaGate('/api/extensions/ext/invoice-inbox/custom-domain', true)).toBe(false)
})
it('does not skip Bearer-auth surfaces without an Authorization header', () => {
expect(apiPathSkipsMfaGate('/api/v1/companies/abc/invoices', false)).toBe(false)
expect(apiPathSkipsMfaGate('/api/extensions/ext/mcp-server/mcp', false)).toBe(false)
})
it('skips the gate for the AAL1 escape-hatch and OAuth routes', () => {
expect(apiPathSkipsMfaGate('/api/account/password', false)).toBe(true)
expect(apiPathSkipsMfaGate('/api/account/set-password', false)).toBe(true)
expect(apiPathSkipsMfaGate('/api/company', false)).toBe(true)
expect(apiPathSkipsMfaGate('/api/company/members', false)).toBe(true)
expect(apiPathSkipsMfaGate('/api/mcp-oauth/authorize', false)).toBe(true)
expect(apiPathSkipsMfaGate('/api/mcp-oauth/token', false)).toBe(true)
})
it('gates cookie-authenticated calls to sensitive dashboard routes', () => {
expect(apiPathSkipsMfaGate('/api/bookkeeping/journal-entries/123', false)).toBe(false)
expect(apiPathSkipsMfaGate('/api/salary/employees/1', false)).toBe(false)
expect(apiPathSkipsMfaGate('/api/reports/full-archive', false)).toBe(false)
expect(apiPathSkipsMfaGate('/api/documents/1', false)).toBe(false)
})
it('does not let a lookalike prefix bypass the account/company allowlist', () => {
// "/api/accounts" (plural, a different resource) must NOT match the
// "/api/account/" escape hatch.
expect(apiPathSkipsMfaGate('/api/accounts', false)).toBe(false)
expect(apiPathSkipsMfaGate('/api/account', false)).toBe(false)
})
})
+2
View File
@@ -243,6 +243,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_export_sie: 'reports:read',
gnubok_audit_package: 'reports:read',
gnubok_import_sie: 'bookkeeping:write',
// Rot/rut begäran om utbetalning (records a payout request on generate)
gnubok_generate_rot_rut_file: 'invoices:write',
// Supplier CRUD
gnubok_create_supplier: 'suppliers:write',
// Supplier invoice lifecycle
+49
View File
@@ -0,0 +1,49 @@
/**
* Decide whether an `/api` request should SKIP the middleware MFA (AAL2) gate.
*
* Most `/api` routes historically hand-roll `supabase.auth.getUser()` instead
* of `requireAuth()`, which means they never enforce MFA. The middleware gate
* (lib/supabase/middleware.ts) closes that gap for cookie sessions, but a few
* request classes must NOT be gated:
*
* - Bearer-authenticated SURFACES (`/api/v1/*` API keys, the MCP endpoint's
* OAuth tokens/API keys): the route validates the Authorization credential
* itself and never trusts the cookie session, so a logged-in AAL1 browser
* testing its own API key must not be blocked. This is scoped by PATH, not
* header presence — the header is attacker-controlled, and an Authorization
* header riding on a cookie-authenticated route must never disable the
* gate (the route would ignore the header and authenticate via cookies,
* i.e. a stolen-password session could bypass MFA with `Authorization: x`).
* Pure Bearer callers elsewhere (cron secret, signed webhooks) carry no
* cookie session, so the gate — which only fires for cookie users — never
* touches them and they need no exemption.
* - The AAL1 escape hatch: a user with MFA required but not yet verified (or a
* BankID-only user setting a first password) must still reach
* `/api/account/*` and `/api/company*` to COMPLETE onboarding / enroll MFA.
* - The MCP OAuth endpoints (`/api/mcp-oauth/*`) carry their own PKCE +
* single-use-code security and drive the connector authorize flow.
*
* Kept as a pure function so the allowlist is unit-testable in isolation.
*/
// Routes whose auth contract IS the Authorization header. Everything else
// under /api/extensions/ext/ authenticates via requireAuth (cookies) in the
// dispatcher and must stay behind the gate.
const BEARER_AUTH_PREFIXES = ['/api/v1/', '/api/extensions/ext/mcp-server/mcp']
export function apiPathSkipsMfaGate(
pathname: string,
hasAuthorizationHeader: boolean,
): boolean {
if (
hasAuthorizationHeader &&
BEARER_AUTH_PREFIXES.some((prefix) => pathname.startsWith(prefix))
) {
return true
}
return (
pathname.startsWith('/api/account/') ||
pathname.startsWith('/api/company') ||
pathname.startsWith('/api/mcp-oauth/')
)
}
+67
View File
@@ -0,0 +1,67 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { CreateJournalEntryInput, JournalEntry } from '@/types'
import { createJournalEntry, findFiscalPeriod } from './engine'
/**
* Settlement voucher for a rot/rut payout from Skatteverket.
*
* When the agency pays out a begäran (one lump sum per request), the 1513
* receivable created at invoicing (fakturamodellen) clears against the bank:
*
* Debit 19xx bank account (default 1930) [amount]
* Credit 1513 Skattereduktion rot/rut [amount]
*
* One voucher per payout request — that mirrors the actual bank transaction.
* At partial approval (delvis beviljad) the paid amount clears here and the
* remainder stays on 1513 until the user corrects it (kundfordran/kundförlust
* depending on the outcome with the buyer) — deliberately manual, never
* guessed.
*/
export async function createRotRutPayoutEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
params: {
requestId: string
requestName: string
deductionType: 'rot' | 'rut'
paymentDate: string
amount: number
/** BAS 19xx account the payout landed on. Defaults to 1930. */
bankAccount?: string
},
): Promise<JournalEntry> {
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, params.paymentDate)
if (!fiscalPeriodId) {
throw new Error(`No open fiscal period found for payment date ${params.paymentDate}`)
}
const amount = Math.round(params.amount * 100) / 100
const bankAccount = params.bankAccount ?? '1930'
const label = params.deductionType === 'rot' ? 'ROT' : 'RUT'
const description = `Utbetalning ${label}-avdrag från Skatteverket (${params.requestName})`
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: params.paymentDate,
description,
source_type: 'rot_rut_payout',
source_id: params.requestId,
lines: [
{
account_number: bankAccount,
debit_amount: amount,
credit_amount: 0,
line_description: description,
},
{
account_number: '1513',
debit_amount: 0,
credit_amount: amount,
line_description: description,
},
],
}
return createJournalEntry(supabase, companyId, userId, input)
}
@@ -116,6 +116,61 @@ describe('validateDocumentMagicBytes — application/xhtml+xml', () => {
})
})
describe('validateDocumentMagicBytes — PDF header offset tolerance', () => {
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer =>
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
const withPreamble = (preamble: string): ArrayBuffer => {
const pdf = new Uint8Array(pdfBuffer())
const lead = new TextEncoder().encode(preamble)
const combined = new Uint8Array(lead.length + pdf.length)
combined.set(lead, 0)
combined.set(pdf, lead.length)
return toArrayBuffer(combined)
}
it('accepts a PDF with a leading newline before %PDF- (ISO 32000 preamble)', () => {
expect(validateDocumentMagicBytes(withPreamble('\n'), 'application/pdf')).toBeNull()
})
it('accepts a PDF with leading whitespace/junk before %PDF-', () => {
expect(validateDocumentMagicBytes(withPreamble(' '), 'application/pdf')).toBeNull()
expect(validateDocumentMagicBytes(withPreamble('\r\n\r\n<junk>'), 'application/pdf')).toBeNull()
})
it('accepts a PDF with a UTF-8 BOM before %PDF-', () => {
const pdf = new Uint8Array(pdfBuffer())
const combined = new Uint8Array(3 + pdf.length)
combined.set([0xEF, 0xBB, 0xBF], 0)
combined.set(pdf, 3)
expect(validateDocumentMagicBytes(toArrayBuffer(combined), 'application/pdf')).toBeNull()
})
it('rejects when %PDF- appears only beyond the first 1024 bytes', () => {
expect(validateDocumentMagicBytes(withPreamble('x'.repeat(1025)), 'application/pdf')).toMatch(
/kunde inte verifieras/,
)
})
it('still rejects HTML and plain text declared as PDF', () => {
const toBuffer = (text: string): ArrayBuffer =>
toArrayBuffer(new TextEncoder().encode(text))
expect(
validateDocumentMagicBytes(toBuffer('<html><body>Your invoice</body></html>'), 'application/pdf'),
).toMatch(/kunde inte verifieras/)
expect(
validateDocumentMagicBytes(toBuffer('JVBERi0xLjQKJcOkw7zDtsO'), 'application/pdf'),
).toMatch(/kunde inte verifieras/)
})
it('images stay strict at offset 0 — a leading byte still rejects', () => {
const png = new Uint8Array([0x0A, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
expect(validateDocumentMagicBytes(toArrayBuffer(png), 'image/png')).toMatch(
/kunde inte verifieras/,
)
})
})
describe('uploadDocument', () => {
it('computes SHA-256 hash, stores metadata, emits document.uploaded', async () => {
const doc = makeDocumentAttachment({
+16 -10
View File
@@ -65,16 +65,22 @@ export function validateDocumentFile(file: { size: number; type?: string }): str
*/
function detectFileMagic(bytes: Uint8Array): string | null {
if (bytes.length < 4) return null
// PDF: %PDF- (allow a leading UTF-8 BOM as some tools prepend one)
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
if (
bytes.length >= offset + 5 &&
bytes[offset] === 0x25 &&
bytes[offset + 1] === 0x50 &&
bytes[offset + 2] === 0x44 &&
bytes[offset + 3] === 0x46 &&
bytes[offset + 4] === 0x2D
) return 'application/pdf'
// PDF: %PDF- anywhere in the first 1024 bytes. ISO 32000 readers accept a
// preamble before the header (Acrobat scans the first 1 KB), and real-world
// invoice PDFs arrive with leading newlines/junk — requiring offset 0
// rejected files every normal reader opens. Image types stay strict at
// offset 0: genuine image files always start with their signature, and the
// looseness is not needed there to keep the anti-placeholder defense tight.
const pdfScanEnd = Math.min(bytes.length - 5, 1024)
for (let i = 0; i <= pdfScanEnd; i++) {
if (
bytes[i] === 0x25 &&
bytes[i + 1] === 0x50 &&
bytes[i + 2] === 0x44 &&
bytes[i + 3] === 0x46 &&
bytes[i + 4] === 0x2D
) return 'application/pdf'
}
// PNG: 89 50 4E 47
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
// JPEG: FF D8 FF
@@ -141,4 +141,220 @@ describe('invoice email templates', () => {
expect(svHtml).toMatch(/1[\s\u00a0]000,00 EUR/)
})
})
describe('custom email texts (invoice_email_texts)', () => {
const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
const enCustomer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' })
const fullOverrides = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: {
sv: {
subject: 'Er faktura {fakturanummer} \u2013 {f\u00f6retag}',
greeting: 'Hejsan {f\u00f6rnamn}!',
body: 'H\u00e4r kommer m\u00e5nadens faktura.',
signoff: 'Allt gott,',
},
en: {
subject: 'Your invoice {fakturanummer}',
greeting: 'Hello {f\u00f6rnamn}!',
body: "Please find this month's invoice attached.",
signoff: 'Best,',
},
},
})
it('renders sv overrides in the HTML variant, keeping structural parts', () => {
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: fullOverrides })
expect(html).toContain('Hejsan Erik!')
expect(html).toContain('H\u00e4r kommer m\u00e5nadens faktura.')
expect(html).toContain('Allt gott,')
expect(html).not.toContain('Tack f\u00f6r ditt f\u00f6rtroende')
expect(html).not.toContain('Med v\u00e4nliga h\u00e4lsningar,')
// Structural parts and the footer question line stay generated
expect(html).toContain('Betalningsinformation')
expect(html).toContain('Har du fr\u00e5gor om fakturan?')
})
it('renders sv overrides in the text variant', () => {
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: fullOverrides })
expect(text).toContain('Hejsan Erik!')
expect(text).toContain('H\u00e4r kommer m\u00e5nadens faktura.')
expect(text).toContain('Allt gott,')
expect(text).not.toContain('Med v\u00e4nliga h\u00e4lsningar,')
})
it('substitutes placeholders in the subject', () => {
const subject = generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: fullOverrides })
expect(subject).toBe('Er faktura 1042 \u2013 Acme AB')
})
it('uses the en overrides for English customers', () => {
const subject = generateInvoiceEmailSubject({ invoice, customer: enCustomer, company: fullOverrides })
expect(subject).toBe('Your invoice 1042')
const html = generateInvoiceEmailHtml({ invoice, customer: enCustomer, company: fullOverrides })
expect(html).toContain('Hello Jane!')
})
it('falls back per language: sv-only overrides leave English customers on stock texts', () => {
const svOnly = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { body: 'H\u00e4r kommer fakturan.' } },
})
const html = generateInvoiceEmailHtml({ invoice, customer: enCustomer, company: svOnly })
expect(html).toContain('Hi Jane,')
expect(html).toContain('Thank you for your business')
expect(generateInvoiceEmailSubject({ invoice, customer: enCustomer, company: svOnly }))
.toBe('Invoice 1042 from Acme AB')
})
it('falls back per field: only overridden fields change', () => {
const bodyOnly = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { body: 'H\u00e4r kommer fakturan.' } },
})
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: bodyOnly })
expect(html).toContain('H\u00e4r kommer fakturan.')
expect(html).toContain('Hej Erik,')
expect(html).toContain('Med v\u00e4nliga h\u00e4lsningar,')
expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: bodyOnly }))
.toBe('Faktura 1042 fr\u00e5n Acme AB')
})
it('treats whitespace-only overrides as unset', () => {
const blank = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { body: ' ', subject: '\n' } },
})
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: blank })
expect(html).toContain('Tack f\u00f6r ditt f\u00f6rtroende')
expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: blank }))
.toBe('Faktura 1042 fr\u00e5n Acme AB')
})
it('substitutes all six placeholders with per-language formatting', () => {
const allPlaceholders = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: {
sv: { body: '{fakturanummer} {kundnamn} {f\u00f6rnamn} {f\u00f6retag} {f\u00f6rfallodatum} {belopp}' },
en: { body: '{fakturanummer} {kundnamn} {f\u00f6rnamn} {f\u00f6retag} {f\u00f6rfallodatum} {belopp}' },
},
})
const svText = generateInvoiceEmailText({ invoice, customer: svCustomer, company: allPlaceholders })
expect(svText).toContain('1042 Erik Andersson Erik Acme AB 2026-06-21')
expect(svText).toMatch(/12[\s\u00a0]500,00 SEK/)
const enText = generateInvoiceEmailText({ invoice, customer: enCustomer, company: allPlaceholders })
expect(enText).toContain('1042 Jane Doe Jane Acme AB 2026-06-21 12,500.00 SEK')
})
it('leaves unknown placeholders literal', () => {
const typo = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { subject: 'Faktura {fakturanumer}', body: 'Se {bilaga}' } },
})
expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: typo }))
.toBe('Faktura {fakturanumer}')
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: typo })
expect(text).toContain('Se {bilaga}')
})
it('is forgiving about placeholder case and spacing', () => {
const spaced = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { greeting: 'Hej { F\u00f6rnamn }!' } },
})
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: spaced })
expect(text).toContain('Hej Erik!')
})
it('escapes HTML in custom texts but keeps the text variant verbatim', () => {
const xss = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { body: '<script>alert(1)</script> & "quoted"' } },
})
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: xss })
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt; &amp; &quot;quoted&quot;')
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: xss })
expect(text).toContain('<script>alert(1)</script> & "quoted"')
})
it('escapes substituted placeholder values in the HTML variant', () => {
const trickyCustomer = makeCustomer({ name: 'Bj\u00f6rk & S\u00f6ner <AB>', language: 'sv' })
const withName = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { body: 'Till {kundnamn}.' } },
})
const html = generateInvoiceEmailHtml({ invoice, customer: trickyCustomer, company: withName })
expect(html).toContain('Till Bj\u00f6rk &amp; S\u00f6ner &lt;AB&gt;.')
const text = generateInvoiceEmailText({ invoice, customer: trickyCustomer, company: withName })
expect(text).toContain('Till Bj\u00f6rk & S\u00f6ner <AB>.')
})
it('converts newlines in the body to <br> in HTML and keeps them in text', () => {
const multiline = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { body: 'Rad 1\nRad 2' } },
})
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: multiline })
expect(html).toContain('Rad 1<br>Rad 2')
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: multiline })
expect(text).toContain('Rad 1\nRad 2')
})
it('flattens newlines in a custom subject (header injection)', () => {
const inject = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: {
sv: { subject: 'Faktura {fakturanummer}\r\nBcc: attacker@example.com' },
},
})
const subject = generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: inject })
expect(subject).toBe('Faktura 1042 Bcc: attacker@example.com')
expect(subject).not.toMatch(/[\r\n]/)
})
it('does not re-substitute placeholder-like values (single pass)', () => {
const weirdCustomer = makeCustomer({ name: '{belopp} AB', language: 'sv' })
const greetByName = makeCompanySettings({
company_name: 'Acme AB',
invoice_email_texts: { sv: { greeting: 'Hej {kundnamn}!' } },
})
const text = generateInvoiceEmailText({ invoice, customer: weirdCustomer, company: greetByName })
expect(text).toContain('Hej {belopp} AB!')
})
it('ignores overrides on credit notes', () => {
const creditInvoice = makeInvoice({
invoice_number: '1043',
due_date: '2026-05-22',
currency: 'SEK',
total: -5000,
credited_invoice_id: 'inv-orig',
})
const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer: svCustomer, company: fullOverrides })
expect(html).toContain('Bifogat hittar du en kreditfaktura')
expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.')
expect(generateInvoiceEmailSubject({ invoice: creditInvoice, customer: svCustomer, company: fullOverrides }))
.toBe('Kreditfaktura 1043 fr\u00e5n Acme AB')
})
it('ignores overrides on proforma invoices', () => {
const proforma = makeInvoice({ invoice_number: '1044', document_type: 'proforma' })
const html = generateInvoiceEmailHtml({ invoice: proforma, customer: svCustomer, company: fullOverrides })
expect(html).toContain('Tack f\u00f6r ditt f\u00f6rtroende')
expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.')
expect(generateInvoiceEmailSubject({ invoice: proforma, customer: svCustomer, company: fullOverrides }))
.toBe('Proformafaktura 1044 fr\u00e5n Acme AB')
})
it('ignores overrides on delivery notes', () => {
const deliveryNote = makeInvoice({ invoice_number: '1045', document_type: 'delivery_note' })
const html = generateInvoiceEmailHtml({ invoice: deliveryNote, customer: svCustomer, company: fullOverrides })
expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.')
expect(generateInvoiceEmailSubject({ invoice: deliveryNote, customer: svCustomer, company: fullOverrides }))
.toBe('F\u00f6ljesedel 1045 fr\u00e5n Acme AB')
})
})
})
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import {
escapeHtml,
userTextToHtml,
sanitizeSubjectLine,
applyPlaceholders,
} from '../user-text'
describe('escapeHtml', () => {
it('escapes all five special characters', () => {
expect(escapeHtml(`&<>"'`)).toBe('&amp;&lt;&gt;&quot;&#39;')
})
it('leaves normal text untouched', () => {
expect(escapeHtml('Hej Erik, här är fakturan.')).toBe('Hej Erik, här är fakturan.')
})
})
describe('userTextToHtml', () => {
it('escapes before converting newlines so user-typed <br> stays escaped', () => {
expect(userTextToHtml('a<br>b\nc')).toBe('a&lt;br&gt;b<br>c')
})
it('converts \\r\\n, \\r and \\n to <br>', () => {
expect(userTextToHtml('a\r\nb\rc\nd')).toBe('a<br>b<br>c<br>d')
})
})
describe('sanitizeSubjectLine', () => {
it('flattens newlines to spaces and trims', () => {
expect(sanitizeSubjectLine(' Faktura\r\n1042\n ')).toBe('Faktura 1042')
})
it('leaves a normal subject untouched', () => {
expect(sanitizeSubjectLine('Faktura 1042 från Acme AB')).toBe('Faktura 1042 från Acme AB')
})
})
describe('applyPlaceholders', () => {
const values = { fakturanummer: '1042', förnamn: 'Erik' }
it('substitutes known keys', () => {
expect(applyPlaceholders('Faktura {fakturanummer} till {förnamn}', values)).toBe(
'Faktura 1042 till Erik',
)
})
it('leaves unknown keys literal', () => {
expect(applyPlaceholders('{fakturanumer}', values)).toBe('{fakturanumer}')
})
it('is forgiving about case and inner whitespace', () => {
expect(applyPlaceholders('{ Förnamn }', values)).toBe('Erik')
})
it('leaves empty braces literal', () => {
expect(applyPlaceholders('a {} b', values)).toBe('a {} b')
})
it('does not re-substitute values (single pass)', () => {
expect(applyPlaceholders('{namn}', { namn: '{fakturanummer}' })).toBe('{fakturanummer}')
})
})
+96 -7
View File
@@ -1,5 +1,6 @@
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
import { applyPlaceholders, sanitizeSubjectLine, userTextToHtml } from './user-text'
type EmailLang = 'sv' | 'en'
@@ -68,10 +69,50 @@ const LABELS = {
},
} as const
// Placeholder keys available in company-editable email texts
// (company_settings.invoice_email_texts). Rendered as a legend in the
// settings UI; kept here rather than in messages/*.json because ICU message
// syntax treats literal braces as interpolation.
export const INVOICE_EMAIL_PLACEHOLDER_KEYS = [
'fakturanummer',
'kundnamn',
'förnamn',
'företag',
'förfallodatum',
'belopp',
] as const
// Display strings for the settings UI's input placeholder attributes.
// subject and greeting are functions in LABELS, so their pattern form is
// hand-written here; body/signoff reference LABELS directly so they cannot
// drift from the actual defaults.
export const INVOICE_EMAIL_DEFAULT_TEXTS = {
sv: {
subject: 'Faktura {fakturanummer} från {företag}',
greeting: 'Hej {förnamn},',
body: LABELS.sv.bodyInvoice,
signoff: LABELS.sv.sincerely,
},
en: {
subject: 'Invoice {fakturanummer} from {företag}',
greeting: 'Hi {förnamn},',
body: LABELS.en.bodyInvoice,
signoff: LABELS.en.sincerely,
},
} as const
function resolveLang(customer: Customer): EmailLang {
return customer.language === 'en' ? 'en' : 'sv'
}
// Custom texts apply ONLY to standard invoices. Credit notes, proforma and
// delivery notes always use the stock texts — a custom "Tack för ditt
// förtroende..." body or "Faktura..." subject would be wrong on those.
function isStandardInvoice(invoice: Invoice): boolean {
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
return docType === 'invoice' && !invoice.credited_invoice_id
}
function getDocumentLabel(invoice: Invoice, lang: EmailLang): string {
const L = LABELS[lang]
if (invoice.credited_invoice_id) return L.docCreditNote
@@ -100,6 +141,47 @@ export interface InvoiceEmailData {
company: CompanySettings
}
function buildPlaceholderValues(data: InvoiceEmailData, lang: EmailLang): Record<string, string> {
const { invoice, customer, company } = data
const fullName = (customer.name || '').trim()
return {
fakturanummer: invoice.invoice_number ?? '',
kundnamn: fullName,
förnamn: fullName ? fullName.split(' ')[0] : '',
företag: getCompanyPrimaryName(company),
förfallodatum: formatDate(invoice.due_date),
belopp: formatCurrencyForCustomer(invoice.total, invoice.currency, lang),
}
}
interface ResolvedCustomTexts {
subject?: string
greeting?: string
body?: string
signoff?: string
}
// Resolves the company's custom email texts for one language. Per-field
// fallback: missing / non-string / whitespace-only values return undefined
// and the caller uses the stock text. Returns RAW substituted strings —
// escaping is the caller's job per output variant (HTML vs text vs subject).
// Defensive typeof checks: rows can be written outside Zod (scripts, SQL).
function resolveCustomTexts(data: InvoiceEmailData, lang: EmailLang): ResolvedCustomTexts {
if (!isStandardInvoice(data.invoice)) return {}
const texts = data.company.invoice_email_texts
const langTexts = texts && typeof texts === 'object' ? texts[lang] : undefined
if (!langTexts || typeof langTexts !== 'object') return {}
const values = buildPlaceholderValues(data, lang)
const pick = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? applyPlaceholders(v.trim(), values) : undefined
return {
subject: pick(langTexts.subject),
greeting: pick(langTexts.greeting),
body: pick(langTexts.body),
signoff: pick(langTexts.signoff),
}
}
// Minimal hex validator — guards against branding values that bypass the
// settings UI and could inject CSS via crafted strings. Anything malformed
// falls back to the legacy default.
@@ -123,6 +205,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const custom = resolveCustomTexts(data, lang)
// Primary color drives the heading accent and the highlighted total. The
// accent is sanitized to a strict hex pattern — anything else falls back
@@ -154,10 +237,10 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<!-- Greeting -->
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">
${L.greeting(firstName)}
${custom.greeting !== undefined ? userTextToHtml(custom.greeting) : L.greeting(firstName)}
</p>
<p style="margin: 0;">
${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}
${custom.body !== undefined ? userTextToHtml(custom.body) : (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}
</p>
</div>
@@ -235,7 +318,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
${L.questions}
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
${L.sincerely}<br>
${custom.signoff !== undefined ? userTextToHtml(custom.signoff) : L.sincerely}<br>
<strong style="color: ${primaryColor};">${getCompanyPrimaryName(company)}</strong>
</p>
${company.org_number ? `
@@ -267,13 +350,14 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const firstName = customer.name ? customer.name.split(' ')[0] : ''
const custom = resolveCustomTexts(data, lang)
let text = `${L.documentFrom(documentType, getCompanyPrimaryName(company))}\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
text += `${L.greeting(firstName)}\n\n`
text += `${custom.greeting ?? L.greeting(firstName)}\n\n`
text += `${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}\n\n`
text += `${custom.body ?? (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}\n\n`
text += `${L.documentSummary(documentType)}\n`
text += `---\n`
@@ -295,7 +379,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
}
text += `${L.questions}\n\n`
text += `${L.sincerely}\n`
text += `${custom.signoff ?? L.sincerely}\n`
text += `${getCompanyDisplayName(company)}\n`
if (company.org_number) {
@@ -315,7 +399,12 @@ export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const lang = resolveLang(customer)
const L = LABELS[lang]
const documentType = getDocumentLabel(invoice, lang)
// Sanitization runs after substitution, so a pathological placeholder
// value containing a newline is also flattened to a single header line.
const custom = resolveCustomTexts(data, lang)
if (custom.subject !== undefined) return sanitizeSubjectLine(custom.subject)
const documentType = getDocumentLabel(invoice, lang)
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyPrimaryName(company))
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Utilities for safely rendering user-authored text in outgoing emails.
*
* Company-editable email texts (see company_settings.invoice_email_texts)
* are untrusted input: they must be escaped before interpolation into HTML
* templates and kept single-line when used as mail headers.
*/
export function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// Escape FIRST, then convert newlines — order matters: a '<br>' typed by the
// user must arrive escaped; only OUR <br> survives.
export function userTextToHtml(input: string): string {
return escapeHtml(input).replace(/\r\n|\r|\n/g, '<br>')
}
// Mail-header hygiene: a subject must be a single line (header injection).
export function sanitizeSubjectLine(input: string): string {
return input.replace(/[\r\n]+/g, ' ').trim()
}
// Fixed-set {x} substitution. Single pass: substituted VALUES are never
// re-scanned, so a customer named '{belopp}' stays literal. Unknown keys are
// left as-is so the user sees and fixes typos. Keys are matched after
// trim + toLowerCase (forgiving of '{ Förnamn }').
export function applyPlaceholders(
template: string,
values: Record<string, string>,
): string {
return template.replace(/\{([^{}]*)\}/g, (match, rawKey: string) => {
const value = values[rawKey.trim().toLowerCase()]
return value !== undefined ? value : match
})
}
+41
View File
@@ -676,6 +676,42 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Personnumret för ROT/RUT-avdraget är ogiltigt.',
message_en: 'The personnummer provided for the ROT/RUT deduction is invalid.',
},
// Rot/rut begäran om utbetalning (Skatteverkets husavdragstjänst)
ROT_RUT_REQUEST_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Begäran om utbetalning hittades inte.',
message_en: 'Payout request not found.',
},
ROT_RUT_NO_ELIGIBLE_INVOICES: {
httpStatus: 400,
message_sv: 'Ingen av de valda fakturorna kan ingå i filen. Se blockeringarna per faktura.',
message_en: 'None of the selected invoices can be included in the file. See the per-invoice blockers.',
},
ROT_RUT_INVOICES_BLOCKED: {
httpStatus: 400,
message_sv: 'En eller flera valda fakturor kan inte ingå i filen. Åtgärda blockeringarna eller välj bort fakturorna.',
message_en: 'One or more selected invoices cannot be included in the file. Fix the blockers or deselect the invoices.',
},
ROT_RUT_INVOICE_CONFLICT: {
httpStatus: 409,
message_sv: 'Minst en faktura ingår redan i en aktiv begäran om utbetalning.',
message_en: 'At least one invoice is already part of an active payout request.',
},
ROT_RUT_INVALID_STATUS_TRANSITION: {
httpStatus: 400,
message_sv: 'Statusändringen är inte tillåten för begäran i dess nuvarande läge.',
message_en: 'The status transition is not allowed from the request current state.',
},
ROT_RUT_SETTLE_INVALID_STATE: {
httpStatus: 400,
message_sv: 'Utbetalningen kan bara bokföras för en inskickad begäran som inte redan är bokförd.',
message_en: 'The payout can only be booked for a submitted request that is not already settled.',
},
ROT_RUT_FILE_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Filen kunde inte skapas.',
message_en: 'The payout file could not be created.',
},
INVOICE_CREATE_INSERT_FAILED: {
httpStatus: 500,
message_sv: 'Fakturan kunde inte sparas.',
@@ -1489,6 +1525,11 @@ const DOCUMENT: Record<string, StructuredErrorEntry> = {
message_sv: 'Filtypen stöds inte.',
message_en: 'Unsupported file type.',
},
DOC_UPLOAD_INVALID_CONTENT: {
httpStatus: 400,
message_sv: 'Filen kunde inte läsas som en giltig PDF eller bild. Kontrollera att filen inte är skadad.',
message_en: 'The file could not be read as a valid PDF or image. Check that the file is not corrupted.',
},
DOC_UPLOAD_STORAGE_FAILED: {
httpStatus: 500,
message_sv: 'Filen kunde inte sparas.',
+1 -1
View File
@@ -168,7 +168,7 @@ export type CoreEvent =
success: boolean // true iff the tool returned without throwing AND was invoked (not denied)
isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client
errorCode: string | null // structured error code from tool-result.toToolError when applicable
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | null
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
errorMessage: string | null // human-readable error message (truncated to 500 chars), null on success.
// Raw material for clustering real agent failures into curated gotchas —
// errorCode alone can't distinguish "period locked" from "unbalanced".
+434
View File
@@ -0,0 +1,434 @@
import { describe, it, expect } from 'vitest'
import type { Invoice, InvoiceItem } from '@/types'
import { makeInvoice } from '@/tests/helpers'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import {
buildRotRutFile,
evaluateInvoiceForFile,
isPastRequestDeadline,
normalizeBrfOrgNr,
} from '@/lib/invoices/rot-rut-file'
// Personnummer from Skatteverket's official example files (synthetic test
// identities published by the agency — never real people).
const PNR_A = '198406012388'
const PNR_B = '199604102393'
const TODAY = '2026-07-02'
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: 'invoice-1',
sort_order: 0,
description: 'Arbete',
quantity: 1,
unit: 'tim',
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
labor_hours: 25,
work_type: 'BYGG',
housing_designation: 'Stockholm Vasastan 1:23',
apartment_number: null,
brf_org_number: null,
created_at: '2026-06-01T00:00:00Z',
...overrides,
}
}
function makeRotInvoice(overrides: Partial<Invoice> = {}, items?: InvoiceItem[]): Invoice {
return makeInvoice({
status: 'paid',
paid_at: '2026-06-20T10:00:00Z',
deduction_total: 3000,
deduction_personnummer_encrypted: encryptPersonnummer(PNR_A),
deduction_personnummer_last4: PNR_A.slice(-4),
items: items ?? [makeItem()],
...overrides,
})
}
describe('buildRotRutFile — rot', () => {
it('produces a schema-shaped rot file for a paid invoice', () => {
const result = buildRotRutFile({
type: 'rot',
name: 'ROT 2026-07-02',
invoices: [makeRotInvoice()],
today: TODAY,
})
expect(result.blockers).toHaveLength(0)
expect(result.arenden).toHaveLength(1)
expect(result.requested_total).toBe(3000)
expect(result.file_name).toBe('rot_begaran_2026-07-02.xml')
const xml = result.xml!
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('xmlns:ns1="http://xmls.skatteverket.se/se/skatteverket/ht/begaran/6.0"')
expect(xml).toContain('xmlns:ns2="http://xmls.skatteverket.se/se/skatteverket/ht/komponent/begaran/6.0"')
expect(xml).toContain('<ns2:NamnPaBegaran>ROT 2026-07-02</ns2:NamnPaBegaran>')
expect(xml).toContain('<ns2:RotBegaran>')
expect(xml).toContain(`<ns2:Kopare>${PNR_A}</ns2:Kopare>`)
expect(xml).toContain('<ns2:BetalningsDatum>2026-06-20</ns2:BetalningsDatum>')
expect(xml).toContain('<ns2:PrisForArbete>12500</ns2:PrisForArbete>')
expect(xml).toContain('<ns2:BetaltBelopp>9500</ns2:BetaltBelopp>')
expect(xml).toContain('<ns2:BegartBelopp>3000</ns2:BegartBelopp>')
expect(xml).toContain('<ns2:Ovrigkostnad>0</ns2:Ovrigkostnad>')
expect(xml).toContain('<ns2:Fastighetsbeteckning>Stockholm Vasastan 1:23</ns2:Fastighetsbeteckning>')
expect(xml).toContain('<ns2:Bygg>')
expect(xml).toContain('<ns2:AntalTimmar>25</ns2:AntalTimmar>')
expect(xml).toContain('<ns2:Materialkostnad>0</ns2:Materialkostnad>')
})
it('emits ärende elements in the XSD sequence order', () => {
const xml = buildRotRutFile({
type: 'rot',
name: 'Ordning',
invoices: [makeRotInvoice()],
today: TODAY,
}).xml!
const order = [
'Kopare',
'BetalningsDatum',
'PrisForArbete',
'BetaltBelopp',
'BegartBelopp',
'FakturaNr',
'Ovrigkostnad',
'Fastighetsbeteckning',
'UtfortArbete',
]
const positions = order.map((el) => xml.indexOf(`<ns2:${el}`))
for (const pos of positions) expect(pos).toBeGreaterThan(-1)
for (let i = 1; i < positions.length; i++) {
expect(positions[i]).toBeGreaterThan(positions[i - 1])
}
})
it('aggregates hours per work type and orders work elements per XSD', () => {
const items = [
makeItem({ id: 'i1', work_type: 'VVS', labor_hours: 3, line_total: 3000, vat_amount: 750, deduction_amount: 900 }),
makeItem({ id: 'i2', work_type: 'EL', labor_hours: 2, line_total: 2000, vat_amount: 500, deduction_amount: 600 }),
makeItem({ id: 'i3', work_type: 'VVS', labor_hours: 2.4, line_total: 1000, vat_amount: 250, deduction_amount: 300 }),
]
const xml = buildRotRutFile({
type: 'rot',
name: 'Aggregering',
invoices: [makeRotInvoice({}, items)],
today: TODAY,
}).xml!
// 3 + 2.4 h VVS → 5 (whole hours per XSD long)
expect(xml).toMatch(/<ns2:Vvs>\s*<ns2:AntalTimmar>5<\/ns2:AntalTimmar>/)
expect(xml).toMatch(/<ns2:El>\s*<ns2:AntalTimmar>2<\/ns2:AntalTimmar>/)
// El precedes Vvs in the XSD sequence
expect(xml.indexOf('<ns2:El>')).toBeLessThan(xml.indexOf('<ns2:Vvs>'))
})
it('uses lägenhetsnummer + normalized BRF orgnr for bostadsrätt', () => {
const items = [
makeItem({ housing_designation: null, apartment_number: '1101', brf_org_number: '769600-0000' }),
]
const xml = buildRotRutFile({
type: 'rot',
name: 'Brf',
invoices: [makeRotInvoice({}, items)],
today: TODAY,
}).xml!
expect(xml).not.toContain('Fastighetsbeteckning')
expect(xml).toContain('<ns2:LagenhetsNr>1101</ns2:LagenhetsNr>')
expect(xml).toContain('<ns2:BrfOrgNr>167696000000</ns2:BrfOrgNr>')
})
it('escapes XML special characters and clamps NamnPaBegaran to 16 chars', () => {
const items = [makeItem({ housing_designation: 'Gränby 1:2 & "Södra" <3' })]
const result = buildRotRutFile({
type: 'rot',
name: 'Väldigt långt namn på begäran som klipps',
invoices: [makeRotInvoice({ invoice_number: 'F<&>2026' }, items)],
today: TODAY,
})
const xml = result.xml!
expect(xml).toContain('Gränby 1:2 &amp; &quot;Södra&quot; &lt;3')
expect(xml).toContain('<ns2:FakturaNr>F&lt;&amp;&gt;2026</ns2:FakturaNr>')
const name = xml.match(/<ns2:NamnPaBegaran>(.*)<\/ns2:NamnPaBegaran>/)?.[1]
expect(name).toBe('Väldigt långt na')
})
})
describe('buildRotRutFile — rut', () => {
function makeRutInvoice(items: InvoiceItem[]): Invoice {
return makeRotInvoice(
{ deduction_personnummer_encrypted: encryptPersonnummer(PNR_B) },
items,
)
}
it('wraps ärenden in HushallBegaran and accepts IT-tjänster', () => {
const items = [
makeItem({ deduction_type: 'rut', work_type: 'IT', labor_hours: 4, housing_designation: null, deduction_amount: 5000 }),
]
const xml = buildRotRutFile({
type: 'rut',
name: 'RUT juni',
invoices: [makeRutInvoice(items)],
today: TODAY,
}).xml!
expect(xml).toContain('<ns2:HushallBegaran>')
expect(xml).not.toContain('RotBegaran')
expect(xml).toMatch(/<ns2:ItTjanster>\s*<ns2:AntalTimmar>4<\/ns2:AntalTimmar>/)
// No property elements for rut
expect(xml).not.toContain('Fastighetsbeteckning')
})
it('reports schablontjänster as Utfort without hours', () => {
const items = [
makeItem({ deduction_type: 'rut', work_type: 'TVATT', labor_hours: null, housing_designation: null, deduction_amount: 250 }),
]
const xml = buildRotRutFile({
type: 'rut',
name: 'Schablon',
invoices: [makeRutInvoice(items)],
today: TODAY,
}).xml!
expect(xml).toMatch(/<ns2:TvattVidTvattinrattning>\s*<ns2:Utfort>true<\/ns2:Utfort>/)
expect(xml).not.toContain('AntalTimmar')
})
})
describe('eligibility blockers', () => {
it('NOT_PAID for unpaid invoices', () => {
const result = evaluateInvoiceForFile('rot', makeRotInvoice({ status: 'sent' }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('NOT_PAID')
})
it('MISSING_PAYMENT_DATE when paid without paid_at', () => {
const result = evaluateInvoiceForFile('rot', makeRotInvoice({ paid_at: null }))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PAYMENT_DATE')
})
it('NO_DEDUCTION_OF_TYPE when the invoice has no lines of the requested type', () => {
const result = evaluateInvoiceForFile('rut', makeRotInvoice())
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('NO_DEDUCTION_OF_TYPE')
})
it('MIXED_DEDUCTION_TYPES when rot and rut lines share an invoice', () => {
const items = [
makeItem(),
makeItem({ id: 'i2', deduction_type: 'rut', work_type: 'STAD', labor_hours: 2 }),
]
const result = evaluateInvoiceForFile('rot', makeRotInvoice({}, items))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MIXED_DEDUCTION_TYPES')
})
it('MISSING_PERSONNUMMER without an encrypted personnummer', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ deduction_personnummer_encrypted: null }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PERSONNUMMER')
})
it('PERSONNUMMER_UNREADABLE on undecryptable ciphertext', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ deduction_personnummer_encrypted: 'deadbeef' }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('PERSONNUMMER_UNREADABLE')
})
it('MISSING_WORK_TYPE when a deduction line has no arbetstyp', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ work_type: null })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_WORK_TYPE')
})
it('INVALID_WORK_TYPE for IT flagged as rot (rut-only service)', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ work_type: 'IT' })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('INVALID_WORK_TYPE')
})
it('MISSING_HOURS when a non-schablon line lacks labor hours', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ labor_hours: null })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_HOURS')
})
it('HOURS_OUT_OF_RANGE above 999 hours', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ labor_hours: 1200 })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('HOURS_OUT_OF_RANGE')
})
it('MISSING_PROPERTY when a rot invoice has no property info', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [makeItem({ housing_designation: null })]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PROPERTY')
})
it('INVALID_BRF_ORGNR on a malformed BRF orgnr', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [
makeItem({ housing_designation: null, apartment_number: '1101', brf_org_number: '123' }),
]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('INVALID_BRF_ORGNR')
})
it('PRICE_BELOW_MINIMUM when arbetskostnaden rounds below 2 kr', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [
makeItem({ line_total: 1, vat_amount: 0, deduction_amount: 0.3, labor_hours: 1 }),
]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('PRICE_BELOW_MINIMUM')
})
it('ZERO_DEDUCTION when the deduction rounds to 0 kr', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [
makeItem({ line_total: 100, vat_amount: 25, deduction_amount: 0, labor_hours: 1 }),
]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('ZERO_DEDUCTION')
})
it('DEDUCTION_EXCEEDS_PAYMENT when begärt belopp exceeds what the buyer paid', () => {
// 100 kr work, 60 kr deduction → buyer paid 40 kr < 60 kr requested.
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({}, [
makeItem({ line_total: 80, vat_amount: 20, deduction_amount: 60, labor_hours: 1 }),
]),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_EXCEEDS_PAYMENT')
})
it('allows begärt belopp equal to betalt belopp (50 % rut)', () => {
const result = evaluateInvoiceForFile(
'rut',
makeRotInvoice({}, [
makeItem({
deduction_type: 'rut',
work_type: 'STAD',
line_total: 80,
vat_amount: 20,
deduction_amount: 50,
labor_hours: 2,
housing_designation: null,
}),
]),
)
expect(result.ok).toBe(true)
})
it('collects blockers per invoice while still emitting eligible ones', () => {
const result = buildRotRutFile({
type: 'rot',
name: 'Blandat',
invoices: [makeRotInvoice(), makeRotInvoice({ status: 'sent', invoice_number: 'F-BAD' })],
today: TODAY,
})
expect(result.arenden).toHaveLength(1)
expect(result.blockers).toHaveLength(1)
expect(result.blockers[0].invoice_number).toBe('F-BAD')
expect(result.xml).not.toBeNull()
})
it('returns xml: null when nothing is eligible', () => {
const result = buildRotRutFile({
type: 'rot',
name: 'Tomt',
invoices: [makeRotInvoice({ status: 'sent' })],
today: TODAY,
})
expect(result.xml).toBeNull()
expect(result.requested_total).toBe(0)
})
})
describe('deadline + helpers', () => {
it('warns when the 31 January deadline has passed', () => {
const invoice = makeRotInvoice({ paid_at: '2024-12-30T00:00:00Z' })
const result = buildRotRutFile({ type: 'rot', name: 'Sen', invoices: [invoice], today: TODAY })
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0]).toContain('31 januari')
})
it('isPastRequestDeadline boundary behavior', () => {
expect(isPastRequestDeadline('2025-06-01', '2026-01-31')).toBe(false)
expect(isPastRequestDeadline('2025-06-01', '2026-02-01')).toBe(true)
expect(isPastRequestDeadline('2026-01-15', '2026-07-02')).toBe(false)
})
it('normalizeBrfOrgNr handles 10/12 digits and rejects the rest', () => {
expect(normalizeBrfOrgNr('769600-0000')).toBe('167696000000')
expect(normalizeBrfOrgNr('167696000000')).toBe('167696000000')
expect(normalizeBrfOrgNr('76960')).toBeNull()
// 12 digits without the sekelsiffra 16 prefix is not a valid orgnr.
expect(normalizeBrfOrgNr('123456789012')).toBeNull()
})
it('matches the shape of Skatteverkets official rot example', () => {
// Mirror exempel_rot_3st.xml ärende 2: fastighet + one work type.
const items = [
makeItem({
work_type: 'GLAS_PLAT',
labor_hours: 4,
housing_designation: 'TEST 1:7',
line_total: 1600,
vat_amount: 400,
deduction_amount: 600,
}),
]
const xml = buildRotRutFile({
type: 'rot',
name: 'Exempel Rot',
invoices: [makeRotInvoice({}, items)],
today: TODAY,
}).xml!
expect(xml).toMatch(
/<ns2:Arenden>\s*<ns2:Kopare>\d{12}<\/ns2:Kopare>\s*<ns2:BetalningsDatum>\d{4}-\d{2}-\d{2}<\/ns2:BetalningsDatum>\s*<ns2:PrisForArbete>2000<\/ns2:PrisForArbete>\s*<ns2:BetaltBelopp>1400<\/ns2:BetaltBelopp>\s*<ns2:BegartBelopp>600<\/ns2:BegartBelopp>/,
)
expect(xml).toMatch(/<ns2:Fastighetsbeteckning>TEST 1:7<\/ns2:Fastighetsbeteckning>\s*<ns2:UtfortArbete>\s*<ns2:GlasPlatarbete>/)
})
})
+39 -3
View File
@@ -51,6 +51,7 @@ export interface InvoiceWriteItemInput {
work_type?: string | null
housing_designation?: string | null
apartment_number?: string | null
brf_org_number?: string | null
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
@@ -71,6 +72,9 @@ export interface InvoiceWriteInput {
ore_rounding?: boolean
deduction_personnummer?: string
deduction_housing_designation?: string
/** ROT i bostadsrätt: lägenhetsnummer + föreningens orgnr instead of fastighetsbeteckning. */
deduction_apartment_number?: string
deduction_brf_org_number?: string
/** Dimensions PR7: invoice-level bag applied to every generated journal line. */
default_dimensions?: Record<string, string>
items: InvoiceWriteItemInput[]
@@ -127,6 +131,7 @@ export type InvoiceWriteItemRow = {
work_type: string | null
housing_designation: string | null
apartment_number: string | null
brf_org_number: string | null
accrual_period_start: string | null
accrual_period_end: string | null
accrual_balance_account: string | null
@@ -260,7 +265,23 @@ export async function buildInvoiceWriteData(params: {
let deductionPersonnummerEncrypted: string | null = null
let deductionPersonnummerLast4: string | null = null
if (documentType === 'invoice') {
const housingProvided = !!input.deduction_housing_designation?.trim()
// Housing info satisfies the ROT requirement in either of two shapes
// (Begaran.xsd V6): fastighetsbeteckning (småhus/ägarlägenhet) OR
// lägenhetsnummer + bostadsrättsföreningens orgnr (bostadsrätt).
const fastighetProvided = !!input.deduction_housing_designation?.trim()
const apartmentProvided = !!input.deduction_apartment_number?.trim()
const brfProvided = !!input.deduction_brf_org_number?.trim()
if ((apartmentProvided || brfProvided) && !(apartmentProvided && brfProvided)) {
return {
ok: false,
code: 'INVOICE_CREATE_ROT_RUT_VALIDATION',
details: {
errors: ['För bostadsrätt krävs både lägenhetsnummer och föreningens organisationsnummer.'],
warnings: [],
},
}
}
const housingProvided = fastighetProvided || (apartmentProvided && brfProvided)
const personnummerRaw = input.deduction_personnummer?.trim() || ''
const personnummerProvided = personnummerRaw.length > 0
@@ -377,6 +398,7 @@ export async function buildInvoiceWriteData(params: {
work_type: null,
housing_designation: null,
apartment_number: null,
brf_org_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
@@ -416,8 +438,22 @@ export async function buildInvoiceWriteData(params: {
deduction_amount: deductionAmount,
labor_hours: documentType === 'invoice' ? (item.labor_hours ?? null) : null,
work_type: documentType === 'invoice' ? (item.work_type ?? null) : null,
housing_designation: documentType === 'invoice' ? (item.housing_designation ?? null) : null,
apartment_number: documentType === 'invoice' ? (item.apartment_number ?? null) : null,
// Property info: per-line value wins, else the invoice-level claim-card
// value is stamped onto every deduction line so the Skatteverket file
// generator can read it off the line later. Non-deduction lines carry
// no property data (privacy by default).
housing_designation:
documentType === 'invoice' && deductionType
? (item.housing_designation ?? input.deduction_housing_designation?.trim() ?? null) || null
: null,
apartment_number:
documentType === 'invoice' && deductionType
? (item.apartment_number ?? input.deduction_apartment_number?.trim() ?? null) || null
: null,
brf_org_number:
documentType === 'invoice' && deductionType
? (item.brf_org_number ?? input.deduction_brf_org_number?.trim() ?? null) || null
: null,
// Periodisering (förutbetald intäkt): frozen onto the line. The schedule
// itself is created when the invoice is sent/booked. ROT/RUT lines never
// defer (schema-enforced); the guard above restricted this to real
+441
View File
@@ -0,0 +1,441 @@
import type { Invoice, InvoiceItem } from '@/types'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import type { DeductionType } from './rot-rut-rules'
/**
* Begäran om utbetalning — rot & rut (Skatteverkets husavdragstjänst).
*
* Generates the HUS XML file (schema V6) that is uploaded manually on
* Skatteverkets e-tjänst "Rot och rut – företag" → "Begär utbetalning via
* fil". There is NO submission API — the file replaces per-ärende manual
* entry, the upload + signature (e-legitimation) stays with the user.
*
* Schema (vendored in dev_docs/skatteverket/husavdrag/):
* root: http://xmls.skatteverket.se/se/skatteverket/ht/begaran/6.0
* types: http://xmls.skatteverket.se/se/skatteverket/ht/komponent/begaran/6.0
*
* Hard schema facts honoured here:
* - Rot and rut can NEVER be mixed in one file (choice of RotBegaran |
* HushallBegaran). One file per deduction type.
* - All amounts are whole kronor (xs:long). PrisForArbete min 2.
* - Kopare is a 12-digit personnummer.
* - NamnPaBegaran is 1–16 characters.
* - Element order inside an ärende is fixed: base fields, then (rot only)
* property fields, then UtfortArbete.
* - Ovrigkostnad is mandatory as soon as UtfortArbete reports hours or
* material. Accounted doesn't itemize övrig kostnad (travel/machines) —
* materials live on non-deduction rows — so 0 is emitted.
*
* Everything in this module is pure and deterministic: the caller passes the
* invoices and `today`; blockers are named per invoice, never guessed.
* File content stays Swedish (statutory surface, see .claude/rules/i18n.md).
*/
const ROOT_NS = 'http://xmls.skatteverket.se/se/skatteverket/ht/begaran/6.0'
const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/ht/komponent/begaran/6.0'
/**
* work_type code → XSD element. Array order = the XSD sequence order, which
* is also the emission order inside UtfortArbete. `schablon` services are
* reported as <Utfort>true</Utfort> — no hours, no material.
*/
const WORK_TYPE_ELEMENTS: Record<DeductionType, ReadonlyArray<{
code: string
element: string
schablon?: boolean
}>> = {
rot: [
{ code: 'BYGG', element: 'Bygg' },
{ code: 'EL', element: 'El' },
{ code: 'GLAS_PLAT', element: 'GlasPlatarbete' },
{ code: 'MARK_DRAN', element: 'MarkDraneringarbete' },
{ code: 'MURNING', element: 'Murning' },
{ code: 'MALNING', element: 'MalningTapetsering' },
{ code: 'VVS', element: 'Vvs' },
],
rut: [
{ code: 'STAD', element: 'Stadning' },
{ code: 'KLAD', element: 'KladOchTextilvard' },
{ code: 'SNOSKOTTNING', element: 'Snoskottning' },
{ code: 'TRADGARD', element: 'Tradgardsarbete' },
{ code: 'BARNPASS', element: 'Barnpassning' },
{ code: 'PERSONLIG_OMS', element: 'Personligomsorg' },
{ code: 'FLYTT', element: 'Flyttjanster' },
{ code: 'IT', element: 'ItTjanster' },
{ code: 'REPARATION', element: 'ReparationAvVitvaror' },
{ code: 'MOBLERING', element: 'Moblering' },
{ code: 'TILLSYN', element: 'TillsynAvBostad' },
{ code: 'TRANSPORT', element: 'TransportTillForsaljning', schablon: true },
{ code: 'TVATT', element: 'TvattVidTvattinrattning', schablon: true },
],
}
export type RotRutBlockerCode =
| 'NOT_PAID'
| 'MISSING_PAYMENT_DATE'
| 'NO_DEDUCTION_OF_TYPE'
| 'MIXED_DEDUCTION_TYPES'
| 'MISSING_PERSONNUMMER'
| 'PERSONNUMMER_UNREADABLE'
| 'MISSING_WORK_TYPE'
| 'INVALID_WORK_TYPE'
| 'MISSING_HOURS'
| 'HOURS_OUT_OF_RANGE'
| 'MISSING_PROPERTY'
| 'INVALID_BRF_ORGNR'
| 'PROPERTY_TOO_LONG'
| 'PRICE_BELOW_MINIMUM'
| 'DEDUCTION_EXCEEDS_PAYMENT'
| 'ZERO_DEDUCTION'
export interface RotRutBlocker {
invoice_id: string
invoice_number: string | null
code: RotRutBlockerCode
/** Swedish — shown as-is in UI and MCP output (statutory surface). */
message: string
}
/** One ärende (buyer + invoice) accepted into the file. */
export interface RotRutArende {
invoice_id: string
invoice_number: string | null
personnummer_last4: string
betalnings_datum: string
/** Whole kronor, as emitted. */
pris_for_arbete: number
betalt_belopp: number
begart_belopp: number
}
export interface BuildRotRutFileResult {
/** null when no invoice passed eligibility. */
xml: string | null
file_name: string
arenden: RotRutArende[]
blockers: RotRutBlocker[]
/** Non-blocking notices (e.g. deadline passed). Swedish. */
warnings: string[]
/** Sum of begart_belopp, whole kronor. */
requested_total: number
}
interface EvaluatedArende {
arende: RotRutArende
/** Emission-ready fragments, in XSD order. */
kopare: string
fakturaNr: string | null
property: { fastighet?: string; lagenhetsNr?: string; brfOrgNr?: string } | null
/** element name → { hours, schablon } aggregated over lines. */
work: Array<{ element: string; schablon: boolean; hours: number }>
}
function isDeductionLine(item: InvoiceItem, type: DeductionType): boolean {
return item.deduction_type === type && item.line_type !== 'text'
}
/**
* Normalize a BRF orgnr to the 12-digit form Skatteverkets exempel uses
* (sekelsiffra 16 + 10-digit orgnr). Returns null when the input can't be
* normalized deterministically.
*/
export function normalizeBrfOrgNr(raw: string): string | null {
const digits = raw.replace(/\D/g, '')
// 12-digit orgnr must carry sekelsiffra 16 (juridisk person) — anything
// else is not a valid Swedish orgnr and fails SKV's schema at upload.
if (digits.length === 12) return digits.startsWith('16') ? digits : null
if (digits.length === 10) return `16${digits}`
return null
}
/**
* Evaluate one invoice against the file rules for `type`. Returns either an
* emission-ready ärende or the FIRST blocker hit (one clear reason beats a
* pile). Exported so the eligible-list API can show per-invoice reasons with
* exactly the same logic that later generates the file.
*/
export function evaluateInvoiceForFile(
type: DeductionType,
invoice: Invoice,
): { ok: true; value: EvaluatedArende } | { ok: false; blocker: RotRutBlocker } {
const block = (code: RotRutBlockerCode, message: string): { ok: false; blocker: RotRutBlocker } => ({
ok: false,
blocker: { invoice_id: invoice.id, invoice_number: invoice.invoice_number ?? null, code, message },
})
const items = invoice.items ?? []
const typeLines = items.filter((i) => isDeductionLine(i, type))
const otherType: DeductionType = type === 'rot' ? 'rut' : 'rot'
const otherLines = items.filter((i) => isDeductionLine(i, otherType))
if (typeLines.length === 0) {
return block('NO_DEDUCTION_OF_TYPE', `Fakturan har inga ${type.toUpperCase()}-rader.`)
}
// One invoice must map to exactly one ärende in exactly one file. Mixed
// rot+rut invoices would need to live in two active begäran at once, which
// the double-request guard (rightly) refuses — ask the user to split.
if (otherLines.length > 0) {
return block(
'MIXED_DEDUCTION_TYPES',
'Fakturan blandar ROT- och RUT-rader. Skatteverket tillåter inte båda i samma fil — dela upp i separata fakturor.',
)
}
if (invoice.status !== 'paid') {
return block('NOT_PAID', 'Kunden måste ha betalat sin del av fakturan innan utbetalning kan begäras.')
}
const paidDate = invoice.paid_at ? String(invoice.paid_at).slice(0, 10) : null
if (!paidDate) {
return block('MISSING_PAYMENT_DATE', 'Fakturan saknar betalningsdatum.')
}
if (!invoice.deduction_personnummer_encrypted) {
return block('MISSING_PERSONNUMMER', 'Fakturan saknar köparens personnummer.')
}
let kopare: string
try {
kopare = decryptPersonnummer(invoice.deduction_personnummer_encrypted).replace(/\D/g, '')
} catch {
return block('PERSONNUMMER_UNREADABLE', 'Köparens personnummer kunde inte läsas — öppna fakturautkastet och ange det igen.')
}
if (kopare.length !== 12) {
return block('PERSONNUMMER_UNREADABLE', 'Köparens personnummer är inte 12 siffror.')
}
// Aggregate hours per work type, in XSD element order.
const elementMap = WORK_TYPE_ELEMENTS[type]
const hoursByCode = new Map<string, number>()
for (const line of typeLines) {
const code = line.work_type ?? ''
if (!code) {
return block('MISSING_WORK_TYPE', 'Alla ROT/RUT-rader måste ha en arbetstyp. Öppna fakturan och välj arbetstyp per rad.')
}
const def = elementMap.find((e) => e.code === code)
if (!def) {
return block(
'INVALID_WORK_TYPE',
`Arbetstypen "${code}" är inte giltig för ${type.toUpperCase()} enligt Skatteverkets filformat.`,
)
}
if (!def.schablon) {
const hours = line.labor_hours ?? 0
if (hours <= 0) {
return block('MISSING_HOURS', 'Alla ROT/RUT-rader måste ha antal arbetstimmar (schablontjänster undantagna).')
}
hoursByCode.set(code, (hoursByCode.get(code) ?? 0) + hours)
} else {
hoursByCode.set(code, hoursByCode.get(code) ?? 0)
}
}
const work: EvaluatedArende['work'] = []
for (const def of elementMap) {
if (!hoursByCode.has(def.code)) continue
const hours = Math.round(hoursByCode.get(def.code) ?? 0)
if (!def.schablon && (hours < 1 || hours > 999)) {
return block('HOURS_OUT_OF_RANGE', `Antal timmar för ${def.element} måste vara 1–999 (är ${hours}).`)
}
work.push({ element: def.element, schablon: def.schablon === true, hours })
}
// Property info (rot only): fastighetsbeteckning OR lägenhetsnummer + BRF
// orgnr, read off the rot lines (stamped there at save time).
let property: EvaluatedArende['property'] = null
if (type === 'rot') {
const fastighet = typeLines.map((l) => l.housing_designation?.trim()).find(Boolean) ?? null
const lagenhet = typeLines.map((l) => l.apartment_number?.trim()).find(Boolean) ?? null
const brfRaw = typeLines.map((l) => l.brf_org_number?.trim()).find(Boolean) ?? null
if (brfRaw && lagenhet) {
const brf = normalizeBrfOrgNr(brfRaw)
if (!brf) {
return block('INVALID_BRF_ORGNR', `Föreningens organisationsnummer "${brfRaw}" är ogiltigt (10 eller 12 siffror krävs).`)
}
if (lagenhet.length > 25) {
return block('PROPERTY_TOO_LONG', 'Lägenhetsnumret är längre än 25 tecken.')
}
property = { lagenhetsNr: lagenhet, brfOrgNr: brf }
} else if (fastighet) {
if (fastighet.length > 40) {
return block('PROPERTY_TOO_LONG', 'Fastighetsbeteckningen är längre än 40 tecken (Skatteverkets maxlängd).')
}
property = { fastighet }
} else {
return block(
'MISSING_PROPERTY',
'ROT kräver fastighetsbeteckning eller lägenhetsnummer + föreningens orgnr. Komplettera fakturan.',
)
}
}
// Amounts, whole kronor. PrisForArbete = arbetskostnad inkl moms for the
// flagged lines; BegartBelopp mirrors the deduction the invoice actually
// credited (1513); BetaltBelopp = what the buyer paid for the work.
const prisForArbete = Math.round(
typeLines.reduce((sum, l) => sum + (l.line_total ?? 0) + (l.vat_amount ?? 0), 0),
)
const begartBelopp = Math.round(
typeLines.reduce((sum, l) => sum + (l.deduction_amount ?? 0), 0),
)
const betaltBelopp = prisForArbete - begartBelopp
if (prisForArbete < 2) {
return block('PRICE_BELOW_MINIMUM', 'Arbetskostnaden måste vara minst 2 kr (Skatteverkets filformat).')
}
// A zero-kronor ärende is rejected (or silently ignored) by Skatteverket —
// an invoice whose deduction rounds to 0 has nothing to request.
if (begartBelopp < 1) {
return block('ZERO_DEDUCTION', 'Fakturans ROT/RUT-avdrag är 0 kr — det finns inget belopp att begära.')
}
// The buyer must have paid at least as much as is being requested
// (skattereduktionen är max 50 % av arbetskostnaden). Independent rounding
// of pris/begärt could otherwise even push BetaltBelopp negative, which
// Skatteverkets schema rejects outright.
if (begartBelopp > betaltBelopp) {
return block(
'DEDUCTION_EXCEEDS_PAYMENT',
`Begärt belopp (${begartBelopp} kr) överstiger vad kunden betalat för arbetet (${betaltBelopp} kr) — Skatteverket avslår. Kontrollera avdragsraderna.`,
)
}
return {
ok: true,
value: {
arende: {
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
personnummer_last4: kopare.slice(-4),
betalnings_datum: paidDate,
pris_for_arbete: prisForArbete,
betalt_belopp: betaltBelopp,
begart_belopp: begartBelopp,
},
kopare,
fakturaNr: invoice.invoice_number ? String(invoice.invoice_number).slice(0, 20) : null,
property,
work,
},
}
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/**
* The 31 January deadline: a begäran must reach Skatteverket no later than
* 31 January the year AFTER the buyer paid. Returns true when `paidDate`'s
* window has closed as of `today` (both YYYY-MM-DD).
*/
export function isPastRequestDeadline(paidDate: string, today: string): boolean {
const paidYear = Number(paidDate.slice(0, 4))
const deadline = `${paidYear + 1}-01-31`
return today > deadline
}
export function buildRotRutFile(params: {
type: DeductionType
/** NamnPaBegaran — clamped to the XSD's 16-char cap. */
name: string
invoices: Invoice[]
/** YYYY-MM-DD, injected for determinism. */
today: string
}): BuildRotRutFileResult {
const { type, invoices, today } = params
const arenden: RotRutArende[] = []
const evaluated: EvaluatedArende[] = []
const blockers: RotRutBlocker[] = []
const warnings: string[] = []
for (const invoice of invoices) {
const result = evaluateInvoiceForFile(type, invoice)
if (!result.ok) {
blockers.push(result.blocker)
continue
}
evaluated.push(result.value)
arenden.push(result.value.arende)
if (isPastRequestDeadline(result.value.arende.betalnings_datum, today)) {
warnings.push(
`Faktura ${result.value.arende.invoice_number ?? result.value.arende.invoice_id}: betalningen (${result.value.arende.betalnings_datum}) har passerat sista begäransdatum (31 januari året efter betalningsåret). Skatteverket kan avslå.`,
)
}
}
const fileName = `${type}_begaran_${today}.xml`
if (evaluated.length === 0) {
return { xml: null, file_name: fileName, arenden, blockers, warnings, requested_total: 0 }
}
const name = escapeXml(params.name.slice(0, 16))
const wrapper = type === 'rot' ? 'RotBegaran' : 'HushallBegaran'
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push(
`<ns1:Begaran xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:ns1="${ROOT_NS}" xmlns:ns2="${KOMPONENT_NS}">`,
)
lines.push(`\t<ns2:NamnPaBegaran>${name}</ns2:NamnPaBegaran>`)
lines.push(`\t<ns2:${wrapper}>`)
for (const ev of evaluated) {
lines.push('\t\t<ns2:Arenden>')
lines.push(`\t\t\t<ns2:Kopare>${ev.kopare}</ns2:Kopare>`)
lines.push(`\t\t\t<ns2:BetalningsDatum>${ev.arende.betalnings_datum}</ns2:BetalningsDatum>`)
lines.push(`\t\t\t<ns2:PrisForArbete>${ev.arende.pris_for_arbete}</ns2:PrisForArbete>`)
lines.push(`\t\t\t<ns2:BetaltBelopp>${ev.arende.betalt_belopp}</ns2:BetaltBelopp>`)
lines.push(`\t\t\t<ns2:BegartBelopp>${ev.arende.begart_belopp}</ns2:BegartBelopp>`)
if (ev.fakturaNr) {
lines.push(`\t\t\t<ns2:FakturaNr>${escapeXml(ev.fakturaNr)}</ns2:FakturaNr>`)
}
// Mandatory when UtfortArbete reports hours; Accounted books övriga
// kostnader (resor, maskiner) outside the deduction rows → always 0.
lines.push('\t\t\t<ns2:Ovrigkostnad>0</ns2:Ovrigkostnad>')
if (ev.property?.fastighet) {
lines.push(`\t\t\t<ns2:Fastighetsbeteckning>${escapeXml(ev.property.fastighet)}</ns2:Fastighetsbeteckning>`)
}
if (ev.property?.lagenhetsNr) {
lines.push(`\t\t\t<ns2:LagenhetsNr>${escapeXml(ev.property.lagenhetsNr)}</ns2:LagenhetsNr>`)
lines.push(`\t\t\t<ns2:BrfOrgNr>${ev.property.brfOrgNr}</ns2:BrfOrgNr>`)
}
if (ev.work.length > 0) {
lines.push('\t\t\t<ns2:UtfortArbete>')
for (const w of ev.work) {
if (w.schablon) {
lines.push(`\t\t\t\t<ns2:${w.element}>`)
lines.push('\t\t\t\t\t<ns2:Utfort>true</ns2:Utfort>')
lines.push(`\t\t\t\t</ns2:${w.element}>`)
} else {
lines.push(`\t\t\t\t<ns2:${w.element}>`)
lines.push(`\t\t\t\t\t<ns2:AntalTimmar>${w.hours}</ns2:AntalTimmar>`)
// Materials are invoiced on non-deduction rows in Accounted's
// fakturamodell — the file reports 0 (XSD requires the element).
lines.push('\t\t\t\t\t<ns2:Materialkostnad>0</ns2:Materialkostnad>')
lines.push(`\t\t\t\t</ns2:${w.element}>`)
}
}
lines.push('\t\t\t</ns2:UtfortArbete>')
}
lines.push('\t\t</ns2:Arenden>')
}
lines.push(`\t</ns2:${wrapper}>`)
lines.push('</ns1:Begaran>')
const requestedTotal = arenden.reduce((sum, a) => sum + a.begart_belopp, 0)
return {
xml: lines.join('\n'),
file_name: fileName,
arenden,
blockers,
warnings,
requested_total: requestedTotal,
}
}
+18 -10
View File
@@ -37,9 +37,13 @@ export const RUT_MAX = 75000
export type DeductionType = 'rot' | 'rut'
/** Skatteverket work codes used by Husavdragstjänsten. Maps a free-text */
/** "what the worker did" label to the official code the Skatteverket file */
/** will need. v1 stores both — the label is shown on the PDF; the code is */
/** stored as `work_type` for the future submission file. */
/** "what the worker did" label to the official code. The code drives which */
/** element the begäran-om-utbetalning file (Begaran.xsd V6) reports the */
/** hours under — see WORK_TYPE_ELEMENTS in lib/invoices/rot-rut-file.ts. */
/** The lists mirror the XSD exactly: rot work types are the seven */
/** ArendeUtfortArbeteRotTYPE elements (IT-tjänster is a RUT service and was */
/** removed from the rot list 2026-07); rut covers all thirteen */
/** ArendeUtfortArbeteRutTYPE elements incl. the two schablontjänster. */
export const ROT_WORK_TYPES = [
{ code: 'BYGG', label: 'Byggnadsarbete' },
{ code: 'EL', label: 'Elarbete' },
@@ -48,20 +52,24 @@ export const ROT_WORK_TYPES = [
{ code: 'MURNING', label: 'Murnings- och putsarbete' },
{ code: 'MALNING', label: 'Mål- och tapetseringsarbete' },
{ code: 'VVS', label: 'VVS-arbete' },
{ code: 'IT', label: 'IT-tjänster i hemmet' },
] as const
export const RUT_WORK_TYPES = [
{ code: 'STAD', label: 'Städning, tvätt och vård av kläder' },
{ code: 'KLAD', label: 'Klädvård i hemmet' },
{ code: 'STAD', label: 'Städning' },
{ code: 'KLAD', label: 'Kläd- och textilvård' },
{ code: 'SNOSKOTTNING', label: 'Snöskottning' },
{ code: 'TRADGARD', label: 'Trädgårdsarbete' },
{ code: 'BARNPASS', label: 'Barnpassning' },
{ code: 'PERSONLIG_OMS', label: 'Personlig omsorg' },
{ code: 'FLYTT', label: 'Flytthjälp' },
{ code: 'REPARATION', label: 'Reparation av vitvaror' },
{ code: 'FLYTT', label: 'Flyttjänster' },
{ code: 'IT', label: 'IT-tjänster i hemmet' },
{ code: 'MOBLERING', label: 'Möblering och tillsyn av bostad' },
{ code: 'TRANSPORT', label: 'Transport till och från återvinning' },
{ code: 'REPARATION', label: 'Reparation av vitvaror' },
{ code: 'MOBLERING', label: 'Möblering' },
{ code: 'TILLSYN', label: 'Tillsyn av bostad' },
// Schablontjänster: reported as utförd/ej utförd in the Skatteverket file,
// never with hours or material.
{ code: 'TRANSPORT', label: 'Transport till försäljning (schablon)' },
{ code: 'TVATT', label: 'Tvätt vid tvättinrättning (schablon)' },
] as const
export interface ItemForDeduction {
+206
View File
@@ -0,0 +1,206 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice } from '@/types'
import {
buildRotRutFile,
evaluateInvoiceForFile,
type BuildRotRutFileResult,
type RotRutBlocker,
} from './rot-rut-file'
import type { DeductionType } from './rot-rut-rules'
/**
* Shared service behind the rot/rut payout-file API routes and the MCP tool
* (gnubok_generate_rot_rut_file) — one implementation of "which invoices can
* go into a begäran" and "record the begäran", so the two surfaces can never
* drift apart.
*/
export interface RotRutCandidateSummary {
invoice_id: string
invoice_number: string | null
customer_name: string | null
personnummer_last4: string
betalnings_datum: string
pris_for_arbete: number
begart_belopp: number
}
export interface RotRutBlockedSummary {
invoice_id: string
invoice_number: string | null
customer_name: string | null
code: string
message: string
}
type InvoiceWithCustomer = Invoice & { customer?: { name?: string | null } | null }
/**
* Paid deduction-carrying invoices not yet claimed by an active begäran,
* evaluated against the file rules. Invoices whose deduction belongs solely
* to the other type are omitted entirely (they're the other list's business).
*/
export async function listRotRutCandidates(
supabase: SupabaseClient,
companyId: string,
type: DeductionType,
): Promise<
| { ok: true; eligible: RotRutCandidateSummary[]; blocked: RotRutBlockedSummary[] }
| { ok: false; dbError: unknown }
> {
const { data: invoices, error } = await supabase
.from('invoices')
.select('*, items:invoice_items(*), customer:customers(id, name)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.eq('status', 'paid')
.gt('deduction_total', 0)
.order('paid_at', { ascending: true })
if (error) return { ok: false, dbError: error }
const { data: activeItems, error: activeError } = await supabase
.from('rot_rut_payout_request_items')
.select('invoice_id, request:rot_rut_payout_requests!inner(id, status, company_id)')
.eq('request.company_id', companyId)
.not('request.status', 'in', '("cancelled","rejected")')
if (activeError) return { ok: false, dbError: activeError }
const activeInvoiceIds = new Set((activeItems ?? []).map((r) => r.invoice_id))
const eligible: RotRutCandidateSummary[] = []
const blocked: RotRutBlockedSummary[] = []
for (const invoice of (invoices ?? []) as unknown as InvoiceWithCustomer[]) {
if (activeInvoiceIds.has(invoice.id)) continue
const result = evaluateInvoiceForFile(type, invoice)
if (result.ok) {
eligible.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
customer_name: invoice.customer?.name ?? null,
personnummer_last4: result.value.arende.personnummer_last4,
betalnings_datum: result.value.arende.betalnings_datum,
pris_for_arbete: result.value.arende.pris_for_arbete,
begart_belopp: result.value.arende.begart_belopp,
})
} else if (result.blocker.code !== 'NO_DEDUCTION_OF_TYPE') {
blocked.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
customer_name: invoice.customer?.name ?? null,
code: result.blocker.code,
message: result.blocker.message,
})
}
}
return { ok: true, eligible, blocked }
}
export type CreateRotRutRequestResult =
| { ok: true; request: Record<string, unknown>; file: BuildRotRutFileResult }
| {
ok: false
code:
| 'ROT_RUT_REQUEST_NOT_FOUND'
| 'ROT_RUT_NO_ELIGIBLE_INVOICES'
| 'ROT_RUT_INVOICES_BLOCKED'
| 'ROT_RUT_INVOICE_CONFLICT'
| 'ROT_RUT_FILE_CREATE_FAILED'
blockers?: RotRutBlocker[]
missingInvoiceIds?: string[]
}
/**
* Generate the begäran file for the given invoices and record the request +
* items. All-or-nothing: any blocked invoice rejects the whole call with the
* per-invoice blockers. The DB trigger enforce_single_active_rot_rut_request
* stays the authoritative double-request guard (surfaced as INVOICE_CONFLICT).
*
* Document archiving is deliberately NOT done here — it needs the storage
* bucket and differs per surface (the API route archives, best-effort).
*/
export async function createRotRutPayoutRequest(
supabase: SupabaseClient,
companyId: string,
userId: string,
params: {
type: DeductionType
invoiceIds: string[]
name?: string
today?: string
},
): Promise<CreateRotRutRequestResult> {
const today = params.today ?? new Date().toISOString().slice(0, 10)
const name = (params.name ?? `${params.type.toUpperCase()} ${today}`).slice(0, 16)
const { data: invoices, error: invoicesError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.in('id', params.invoiceIds)
if (invoicesError) {
return { ok: false, code: 'ROT_RUT_FILE_CREATE_FAILED' }
}
const foundIds = new Set((invoices ?? []).map((i) => i.id))
const missing = params.invoiceIds.filter((id) => !foundIds.has(id))
if (missing.length > 0) {
return { ok: false, code: 'ROT_RUT_REQUEST_NOT_FOUND', missingInvoiceIds: missing }
}
const file = buildRotRutFile({
type: params.type,
name,
invoices: (invoices ?? []) as unknown as Invoice[],
today,
})
if (!file.xml) {
return { ok: false, code: 'ROT_RUT_NO_ELIGIBLE_INVOICES', blockers: file.blockers }
}
if (file.blockers.length > 0) {
return { ok: false, code: 'ROT_RUT_INVOICES_BLOCKED', blockers: file.blockers }
}
const { data: payoutRequest, error: insertError } = await supabase
.from('rot_rut_payout_requests')
.insert({
company_id: companyId,
user_id: userId,
deduction_type: params.type,
name,
status: 'generated',
requested_total: file.requested_total,
file_name: file.file_name,
})
.select()
.single()
if (insertError || !payoutRequest) {
return { ok: false, code: 'ROT_RUT_FILE_CREATE_FAILED' }
}
const itemRows = file.arenden.map((a) => ({
request_id: payoutRequest.id,
invoice_id: a.invoice_id,
requested_amount: a.begart_belopp,
}))
const { error: itemsError } = await supabase
.from('rot_rut_payout_request_items')
.insert(itemRows)
if (itemsError) {
// Roll back the header row — without items the request is meaningless.
await supabase.from('rot_rut_payout_requests').delete().eq('id', payoutRequest.id)
const conflict =
(itemsError as { code?: string }).code === '23505' ||
itemsError.message?.includes('active rot/rut payout request')
return { ok: false, code: conflict ? 'ROT_RUT_INVOICE_CONFLICT' : 'ROT_RUT_FILE_CREATE_FAILED' }
}
return { ok: true, request: payoutRequest, file }
}
@@ -74,7 +74,8 @@ describe('dimension filter — statutory exclusion', () => {
const reportRoutes = walk(join(ROOT, 'app/api/reports'))
const importers = reportRoutes
.filter((f) => readFileSync(f, 'utf8').includes('lib/reports/dimension-filter'))
.map((f) => f.slice(ROOT.length + 1))
// Normalize to POSIX separators so the allowlist matches on Windows too.
.map((f) => f.slice(ROOT.length + 1).replace(/\\/g, '/'))
.sort()
// Exactly the P&L-safe routes — nothing more (statutory leak), nothing
+29
View File
@@ -1,6 +1,7 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import { shouldEnforceMfa } from '@/lib/auth/mfa'
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
import { userHasPassword } from '@/lib/auth/has-password'
@@ -44,6 +45,34 @@ export async function updateSession(request: NextRequest) {
// Get the pathname
const pathname = request.nextUrl.pathname
// ── API routes ──────────────────────────────────────────────────────────
// API routes authenticate themselves (requireAuth, API-key Bearer, cron
// secret, webhook signatures). Middleware runs on them for ONE reason: to
// close the MFA gap. Many legacy routes hand-roll supabase.auth.getUser()
// instead of requireAuth(), so without this an authenticated-but-not-MFA-
// verified (AAL1) cookie session could reach them on the hosted product.
// Gate ONLY cookie sessions. Bearer-auth SURFACES (/api/v1, the MCP
// endpoint) and the AAL1 escape-hatch / OAuth routes pass straight through
// (see apiPathSkipsMfaGate) — header presence alone never skips the gate,
// since the header is attacker-controlled and cookie-authenticated routes
// ignore it. Pure Bearer callers (cron, webhooks) carry no cookie session,
// so the `user` guard below already excludes them. Everything else about
// /api auth stays the route's own responsibility.
if (pathname.startsWith('/api')) {
const skipMfaGate = apiPathSkipsMfaGate(
pathname,
request.headers.get('authorization') !== null,
)
if (!skipMfaGate && user && shouldEnforceMfa(user)) {
const { data: aal } =
await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
return NextResponse.json({ error: 'MFA-verifiering krävs.' }, { status: 403 })
}
}
return supabaseResponse
}
// If the refresh token is stale/invalid, clear the session cookies
// so the browser stops sending them on every request.
// Skip on auth routes — the callback needs PKCE cookies intact.