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