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:
co-authored by
Claude Fable 5
parent
b5da51ea0a
commit
0406e628e1
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user