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
@@ -390,6 +390,20 @@ describe('POST /api/invoices/[id]/send', () => {
},
)
it('does not allocate a number or send when the registered company has no VAT number', async () => {
enqueue({ data: makeInvoice({ ...invoice, invoice_number: null }), error: null })
enqueue({ data: { ...company, vat_registered: true, vat_number: null }, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_VAT_NUMBER_MISSING')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects custom recipients from a non-admin company member before allocation', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
+5
View File
@@ -41,6 +41,7 @@ import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
@@ -197,6 +198,10 @@ export const POST = withRouteContext(
})
}
if (!hasRequiredSellerVatNumber(company as CompanySettings, invoice as Invoice)) {
return errorResponseFromCode('INVOICE_SEND_VAT_NUMBER_MISSING', opLog, { requestId })
}
const hasAdditionalRecipients =
(bodyResult.data.additional_cc?.length ?? 0) > 0
|| (bodyResult.data.additional_bcc?.length ?? 0) > 0
+144
View File
@@ -716,4 +716,148 @@ describe('PUT /api/settings', () => {
expect(response.status).toBe(400)
})
it('allows a bank-details save when stored VAT state is incomplete (bank dialog)', async () => {
// Pre-existing inconsistency: registered without a VAT number. The invoice
// bank-details dialog has no VAT fields and must not be blocked by it.
const settings = {
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: null,
moms_period: 'quarterly',
onboarding_complete: true,
}
enqueueMany([
{ data: settings }, // oldSettings
{ data: { role: 'owner' } }, // payment-instructions role gate
{ data: { id: 's1', bank_name: 'Testbanken', bankgiro: '223-8194' } }, // update
{ data: null, count: 5 }, // deadlines count
])
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: { bank_name: 'Testbanken', bankgiro: '223-8194' },
}), { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('still rejects enabling VAT registration without a VAT number', async () => {
enqueue({
data: {
entity_type: 'aktiebolag',
vat_registered: false,
vat_number: null,
moms_period: 'quarterly',
onboarding_complete: true,
},
})
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: { vat_registered: true },
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('Momsregistreringsnummer')
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects clearing the VAT number while the company stays registered', async () => {
enqueue({
data: {
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: 'SE556012579001',
moms_period: 'quarterly',
onboarding_complete: true,
},
})
// Explicit null is a clear, not an omission: it must not fall back to the
// stored number during validation.
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: { vat_number: null },
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('Momsregistreringsnummer')
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects clearing the moms period while the company stays registered', async () => {
enqueue({
data: {
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: 'SE556012579001',
moms_period: 'quarterly',
onboarding_complete: true,
},
})
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: { moms_period: null },
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('Momsperiod')
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('rejects enabling periodisk sammanställning while the VAT registration is incomplete', async () => {
enqueue({
data: {
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: null,
moms_period: 'quarterly',
vat_has_eu_trade: true,
onboarding_complete: true,
},
})
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: { periodisk_sammanstallning_enabled: true },
}), { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('Momsregistreringsnummer')
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('allows an unrelated save when a stored 40m/period conflict already exists', async () => {
// Stored state violates the 40m-monthly rule; a save that touches neither
// group must still go through.
enqueueMany([
{
data: {
entity_type: 'aktiebolag',
vat_registered: true,
vat_number: 'SE556012579001',
moms_period: 'quarterly',
vat_taxable_base_over_40m: true,
onboarding_complete: true,
},
},
{ data: { id: 's1', company_name: 'Testbolaget AB' } }, // update
{ data: null, count: 5 }, // deadlines count
])
const response = await PUT(createMockRequest('/api/settings', {
method: 'PUT',
body: { company_name: 'Testbolaget AB' },
}), { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
})
+37 -7
View File
@@ -182,14 +182,31 @@ export const PUT = withRouteContext(
body.employer_seasonal = false
}
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
// Validate: VAT-registered must have VAT number (ML 17 kap. 24 §, the
// invoice needs it) and moms period (SFL 26 kap.).
// Each cross-field check runs only when the request touches a field in its
// group: a partial save of unrelated settings (e.g. the invoice bank-details
// dialog) must not be rejected for a pre-existing inconsistency it cannot
// fix from that surface. Explicit null counts as touched, it clears a value,
// so it must not fall back to the stored one during validation.
// PS/EU-trade edits are in the completeness group: enabling the EU sales
// list on an incomplete VAT registration must keep failing like it did
// when the check ran on every save.
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period
if (effectiveVatRegistered === true) {
const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number
const effectiveMomsPeriod =
body.moms_period !== undefined ? body.moms_period : oldSettings?.moms_period
const touchesVatCompleteness =
body.vat_registered !== undefined ||
body.vat_number !== undefined ||
body.moms_period !== undefined ||
body.vat_has_eu_trade !== undefined ||
body.periodisk_sammanstallning_enabled !== undefined
if (touchesVatCompleteness && effectiveVatRegistered === true) {
const effectiveVatNumber =
body.vat_number !== undefined ? body.vat_number : oldSettings?.vat_number
if (!effectiveVatNumber) {
return NextResponse.json(
{ 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 §)' },
{ status: 400 }
)
}
@@ -201,21 +218,34 @@ export const PUT = withRouteContext(
}
}
const touchesVat40m =
body.vat_registered !== undefined ||
body.vat_taxable_base_over_40m !== undefined ||
body.moms_period !== undefined
const effectiveVatTaxableBaseOver40m =
body.vat_taxable_base_over_40m ?? oldSettings?.vat_taxable_base_over_40m ?? false
if (effectiveVatRegistered && effectiveVatTaxableBaseOver40m && effectiveMomsPeriod !== 'monthly') {
if (
touchesVat40m &&
effectiveVatRegistered &&
effectiveVatTaxableBaseOver40m &&
effectiveMomsPeriod !== 'monthly'
) {
return NextResponse.json(
{ error: 'Företag med beskattningsunderlag över 40 miljoner kronor måste redovisa moms varje månad.' },
{ status: 400 },
)
}
const touchesPs =
body.periodisk_sammanstallning_enabled !== undefined ||
body.vat_registered !== undefined ||
body.vat_has_eu_trade !== undefined
const effectivePsEnabled =
body.periodisk_sammanstallning_enabled ??
oldSettings?.periodisk_sammanstallning_enabled ??
false
const effectiveEuTrade = body.vat_has_eu_trade ?? oldSettings?.vat_has_eu_trade ?? false
if (effectivePsEnabled && (!effectiveVatRegistered || !effectiveEuTrade)) {
if (touchesPs && effectivePsEnabled && (!effectiveVatRegistered || !effectiveEuTrade)) {
return NextResponse.json(
{ error: 'Periodisk sammanställning kräver momsregistrering och EU-handel.' },
{ status: 400 },
@@ -255,6 +255,38 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
},
)
it('rejects issuance when the registered company has no VAT number', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: {
data: {
accounting_method: 'accrual',
entity_type: 'enskild_firma',
bankgiro: '123-4567',
vat_registered: true,
vat_number: null,
},
error: null,
},
}),
)
const res = await markSent(
makeMarkSentRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`,
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_VAT_NUMBER_MISSING')
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('rejects delivery notes with VALIDATION_ERROR (regardless of status)', async () => {
// Critical: the delivery-note guard must run BEFORE the status check
// so a sent delivery note still returns 400 (per the documented
@@ -51,6 +51,7 @@ import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
import {
hasRequiredInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
import { eventBus } from '@/lib/events'
import type { CompanySettings, EntityType, Invoice } from '@/types'
@@ -217,7 +218,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// decision, payable invoices need a currency-matching account.
const { data: settings, error: settingsError } = await ctx.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', ctx.companyId!)
.maybeSingle()
if (settingsError || !settings) {
@@ -238,6 +239,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
details: { currency: typed.currency },
})
}
if (!hasRequiredSellerVatNumber(companySettings, typed)) {
return v1ErrorResponseFromCode('INVOICE_SEND_VAT_NUMBER_MISSING', ctx.log, {
requestId: ctx.requestId,
})
}
const accountingMethod = companySettings.accounting_method ?? 'accrual'
const entityType = (companySettings.entity_type ?? 'enskild_firma') as EntityType
const isRealInvoice = !typed.document_type || typed.document_type === 'invoice'
@@ -353,6 +353,30 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
},
)
it('rejects issuance when the registered company has no VAT number', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: { data: DRAFT_INVOICE, error: null },
company_settings: {
data: { ...COMPANY_SETTINGS, vat_registered: true, vat_number: null },
error: null,
},
}),
)
const res = await sendInvoice(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('INVOICE_SEND_VAT_NUMBER_MISSING')
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns VALIDATION_ERROR for malformed JSON', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -78,6 +78,7 @@ import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
import { eventBus } from '@/lib/events'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
@@ -357,6 +358,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
if (!hasRequiredSellerVatNumber(settings, typed)) {
return v1ErrorResponseFromCode('INVOICE_SEND_VAT_NUMBER_MISSING', ctx.log, {
requestId: ctx.requestId,
})
}
const hasAdditionalRecipients =
(bodyResult.data.additional_cc?.length ?? 0) > 0
|| (bodyResult.data.additional_bcc?.length ?? 0) > 0