fix(settings): scope cross-field VAT validations to saves that touch them (#2121)

* fix(settings): scope cross-field VAT validations to saves that touch them

The settings PUT validated the whole effective record on every partial
update, so companies stored as vat_registered without a vat_number were
blocked from saving anything through the endpoint, including the invoice
bank-details dialog, which has no VAT fields (reported by a user stuck on
"Momsregistreringsnummer kravs...").

Each cross-field check (VAT completeness, 40m-monthly, periodisk
sammanstallning) now runs only when the request body touches a field in
its group, so the invariant still holds whenever VAT config is edited.
Explicit null now counts as clearing a value during validation instead of
falling back to the stored one, closing a latent hole where
{ vat_number: null } passed validation but wrote null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u

* fix(invoices): gate issuance on the seller VAT number (skeptic finding)

The settings scoping in the previous commit removed what was accidentally
the only enforcement of "momsregistrerad implies momsregnr on file": with
bank details saveable again, a registered company without a stored VAT
number could issue a faktura charging moms with no seller VAT number in
the footer (mandatory element, ML (2023:200) 17 kap. 24 §).

Issuance is now gated the same way the payment account is, at all four
independent issuance points (issueAndBookInvoice, dashboard send, v1 send,
v1 mark-sent), with a structured error pointing at Installningar -> Skatt.
Credit notes, proformas, and delivery notes are exempt like the payment
gate exempts them.

Also, per the Swedish review and the secondary skeptic finding:
- PS/EU-trade edits join the VAT-completeness touch group, so enabling
  periodisk sammanstallning on an incomplete registration keeps failing.
- The stale ML 11 kap. 8 citation is updated to ML 17 kap. 24.

The makeCompanySettings fixture now models a coherent registered company
(vat_number set); the missing-number tests override it explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u

* fix(invoices): extend the seller-VAT-number gate to the headless issuance paths

Skeptic round 2 found three more issuance points beside the four gated in
the previous commit: the recurring auto-send service (cron, no human in
the loop), and the MCP staged-operation executors send_invoice and
mark_invoice_sent. Each carried the payment-account gate but not the VAT
gate; mark_invoice_sent additionally had a narrow settings select that
would have made a naive gate silently pass, now widened.

Recurring auto-send fails soft, matching its other guards: the invoice
stays a numbered draft with the standard schedule warning. The executors
return the structured Swedish message. Peppol send was verified
self-gating (BIS preflight requires the supplier VAT number).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u

* test(email): refresh brand-mail snapshots for the coherent VAT fixture

The makeCompanySettings fixture now carries a VAT number, so the invoice
and reminder mail footers correctly render the VAT line; the snapshots
predate that. Also cites ML 17 kap. 22-23 (andringsfaktura content list)
in the seller-vat-number docstring per the Swedish review suggestion,
documenting why credit notes are exempt. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 19:30:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b5da51ea0a
commit 0406e628e1
21 changed files with 500 additions and 13 deletions
@@ -176,7 +176,7 @@ exports[`invoice mail (template class: invoice) > unbranded: template output unc
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
Org.nr: 556000-0000
| VAT: SE556012579001
| Innehar F-skattsedel
</p>
@@ -314,7 +314,7 @@ exports[`reminder mail (template class: reminder) > unbranded: canonical action
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
Org.nr: 199001011234
| VAT: SE556012579001
</p>
</div>
@@ -285,7 +285,7 @@ describe('getErrorMessage: payment-file route messages surface (issue #945)', ()
it('surfaces a "... krävs ..." message instead of the generic 400', () => {
const msg = getErrorMessage(
{ error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' },
{ error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 17 kap. 24 §)' },
{ context: 'settings', statusCode: 400 },
)
expect(msg).toContain('krävs')
+8
View File
@@ -1151,6 +1151,14 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
description: 'Lägg till ett betalningskonto med IBAN för fakturans valuta under Inställningar → Fakturering.',
},
},
INVOICE_SEND_VAT_NUMBER_MISSING: {
httpStatus: 400,
message_sv: 'Företaget är momsregistrerat men saknar momsregistreringsnummer, som måste anges på fakturan (ML 17 kap. 24 §). Lägg till det under Inställningar → Skatt innan du skickar fakturan.',
message_en: 'The company is VAT-registered but has no VAT number, which is a mandatory invoice element (ML 17 kap. 24 §). Add it under Inställningar → Skatt (Settings → Tax) before issuing the invoice.',
remediation: {
description: 'Lägg till företagets momsregistreringsnummer under Inställningar → Skatt.',
},
},
INVOICE_SEND_NUMBER_ASSIGN_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tilldela fakturanummer.',
@@ -126,6 +126,30 @@ describe('issueAndBookInvoice', () => {
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('rejects a VAT-registered company without VAT number, before number allocation', async () => {
const broken = { ...settings, vat_registered: true, vat_number: null } as CompanySettings
const result = await issue(makeDraft({ invoice_number: null }), broken)
expect(result).toEqual({ ok: false, errorCode: 'INVOICE_SEND_VAT_NUMBER_MISSING' })
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('issues for an unregistered company without VAT number', async () => {
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS flip
const unregistered = {
...settings,
vat_registered: false,
vat_number: null,
defer_invoice_booking: true,
} as CompanySettings
const result = await issue(makeDraft(), unregistered)
expect(result).toEqual({ ok: true, journalEntryId: null, partialFailures: [] })
})
it('fails with INVOICE_CREATE_NUMBER_ASSIGN_FAILED when numbering fails', async () => {
mockEnsureInvoiceNumber.mockRejectedValue(new Error('sequence exhausted'))
@@ -508,6 +508,22 @@ describe('executeRecurringSchedule auto-send', () => {
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('keeps the invoice a draft when the registered company has no VAT number', async () => {
enqueue({ data: customer, error: null })
enqueue({ data: { vat_registered: true }, error: null }) // company_settings VAT gate
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({ data: makeCompleteInvoice(), error: null })
enqueue({ data: { ...company, vat_registered: true, vat_number: null }, error: null }) // company_settings (auto-send)
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(false)
expect(result.warning).toContain('Auto-utskick misslyckades')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not reserve an auto-send delivery when configured recipients exceed the limit', async () => {
enqueue({ data: customer, error: null })
enqueue({ data: { vat_registered: true }, error: null }) // company_settings VAT gate
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { hasRequiredSellerVatNumber } from '../seller-vat-number'
const realInvoice = { credited_invoice_id: null, document_type: 'invoice' as const }
describe('hasRequiredSellerVatNumber', () => {
it('requires a VAT number for a registered company issuing a real invoice', () => {
expect(
hasRequiredSellerVatNumber({ vat_registered: true, vat_number: null }, realInvoice),
).toBe(false)
expect(
hasRequiredSellerVatNumber({ vat_registered: true, vat_number: ' ' }, realInvoice),
).toBe(false)
expect(
hasRequiredSellerVatNumber(
{ vat_registered: true, vat_number: 'SE556012579001' },
realInvoice,
),
).toBe(true)
})
it('does not require a VAT number for an unregistered company', () => {
expect(
hasRequiredSellerVatNumber({ vat_registered: false, vat_number: null }, realInvoice),
).toBe(true)
})
it('exempts credit notes, proformas, and delivery notes', () => {
const broken = { vat_registered: true, vat_number: null }
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: 'inv-0', document_type: 'invoice' }),
).toBe(true)
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: null, document_type: 'proforma' }),
).toBe(true)
expect(
hasRequiredSellerVatNumber(broken, { credited_invoice_id: null, document_type: 'delivery_note' }),
).toBe(true)
})
})
+5
View File
@@ -14,6 +14,7 @@ import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
import { uploadDocument } from '@/lib/core/documents/document-service'
import type { Logger } from '@/lib/logger'
import type {
@@ -167,6 +168,10 @@ export async function issueAndBookInvoice(
}
}
if (!hasRequiredSellerVatNumber(settings, invoice as Invoice)) {
return { ok: false, errorCode: 'INVOICE_SEND_VAT_NUMBER_MISSING' }
}
// Assign the number only after all payment-instruction guards pass.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
@@ -53,6 +53,7 @@ import {
import {
hasRequiredInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
import { createLogger } from '@/lib/logger'
import type {
Invoice,
@@ -581,6 +582,12 @@ async function sendInvoiceFromSchedule(
})
return false
}
if (!hasRequiredSellerVatNumber(company, invoice)) {
log.warn('registered company has no VAT number; recurring schedule cannot auto-send', {
invoiceId: invoice.id,
})
return false
}
const recipients = resolveInvoiceEmailRecipients({
to: invoice.customer.email,
configuredCc: company.invoice_email_cc_addresses,
+32
View File
@@ -0,0 +1,32 @@
import type { CompanySettings, Invoice } from '@/types'
/**
* A momsregistrerad seller must state its momsregistreringsnummer on every
* faktura (ML (2023:200) 17 kap. 24 §). Issuing without it produces a formally
* defective invoice and a defective input-VAT underlag for the buyer, so
* issuance is gated the same way the payment account is.
*
* Proformas and delivery notes are not tax documents. Credit notes are
* exempted deliberately: an ändringsfaktura has its own mandatory-content
* list (ML 17 kap. 22-23 §§: unambiguous reference to the original, the
* change, own number and date, negative amounts, VAT per original rate)
* which does not include the seller's VAT number, and blocking a correction
* of an already-issued invoice would trap a company that only needs to fix
* its settings.
*/
export function invoiceRequiresSellerVatNumber(
invoice: Pick<Invoice, 'credited_invoice_id' | 'document_type'>,
): boolean {
return !invoice.credited_invoice_id
&& invoice.document_type !== 'delivery_note'
&& invoice.document_type !== 'proforma'
}
export function hasRequiredSellerVatNumber(
company: Pick<CompanySettings, 'vat_registered' | 'vat_number'>,
invoice: Pick<Invoice, 'credited_invoice_id' | 'document_type'>,
): boolean {
if (!invoiceRequiresSellerVatNumber(invoice)) return true
if (!company.vat_registered) return true
return !!company.vat_number?.trim()
}
@@ -513,6 +513,76 @@ describe('commitPendingOperation: invoice send payment account guard', () => {
)
})
describe('commitPendingOperation: seller VAT number guard', () => {
it('rejects mark_invoice_sent for a registered company without VAT number', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: null,
credited_invoice_id: null,
}),
error: null,
})
enqueue({
data: { bankgiro: '123-4567', vat_registered: true, vat_number: null },
error: null,
})
enqueue({ data: null, error: null }) // dispatcher rejected update
const op = makePendingOp({
operation_type: 'mark_invoice_sent',
params: { invoice_id: 'invoice-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
expect(mockRecordManualInvoiceDelivery).not.toHaveBeenCalled()
})
it('rejects send_invoice for a registered company without VAT number', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: null,
customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }),
items: [],
}),
error: null,
})
enqueue({
data: {
company_name: 'Test AB',
bankgiro: '123-4567',
vat_registered: true,
vat_number: null,
},
error: null,
})
enqueue({ data: null, error: null }) // dispatcher's rejected update
const op = makePendingOp({
operation_type: 'send_invoice',
params: { invoice_id: 'invoice-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
expect(supabase.from).not.toHaveBeenCalledWith('invoice_deliveries')
})
})
describe('commitPendingOperation: invoice send recipient limit', () => {
it('rejects an oversized configured recipient set before reservation and allocation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
+20 -1
View File
@@ -128,6 +128,7 @@ import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
import {
exceedsInvoiceEmailRecipientLimit,
invoiceEmailRecipientCount,
@@ -2478,6 +2479,15 @@ async function commitSendInvoice(
}
}
if (!hasRequiredSellerVatNumber(company as CompanySettings, invoice as Invoice)) {
return {
error:
getErrorEntry('INVOICE_SEND_VAT_NUMBER_MISSING')?.message_sv
?? 'Momsregistreringsnummer saknas i företagsinställningarna.',
status: 400,
}
}
const recipients = resolveInvoiceEmailRecipients({
to: customer.email,
configuredCc: company.invoice_email_cc_addresses,
@@ -2711,7 +2721,7 @@ async function commitMarkInvoiceSent(
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('accounting_method, defer_invoice_booking, entity_type, invoice_payment_accounts, bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic')
.select('accounting_method, defer_invoice_booking, entity_type, invoice_payment_accounts, bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, vat_registered, vat_number')
.eq('company_id', companyId)
.single()
@@ -2726,6 +2736,15 @@ async function commitMarkInvoiceSent(
}
}
if (!hasRequiredSellerVatNumber(settings as CompanySettings, invoice as Invoice)) {
return {
error:
getErrorEntry('INVOICE_SEND_VAT_NUMBER_MISSING')?.message_sv
?? 'Momsregistreringsnummer saknas i företagsinställningarna.',
status: 400,
}
}
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {