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:
@@ -1451,3 +1451,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] Skeptic BLOCK on woo failed-order removal fixed by: freeze guards repeated on the DELETE statement (TOCTOU), is_paid=false + legacy_transaction_id null guards, orderRemoves gated on !orderIsPaid. No BEFORE DELETE trigger/RPC: the is_paid guard makes the cascade race unreachable (refund children only exist under paid parents).
|
||||
[2026-09-01] EB claim guard, skeptic round (PR #2116): active-company standing state (enabled cash_accounts + enabled accounts on its live-ish rows) outranks sibling claims COMPANY-wide, not row-wide: a bank-list renewal arrives on a fresh row and must not switch a working feed off. pending_selection rows neither claim nor remember deselections (unconfirmed callback output; also stops fail-closed writes from poisoning later connects). Guard-disabled accounts are never mirrored from the callback (mirroring enabled:false can promote the seeded primary 1930 manual row and disable it under a foreign identity) and the selection save skips allocation+mirror for disabled never-mirrored accounts, so the no-slot-burned invariant holds end to end. Deselection carry got a picker note; enabling an account clears the guard flags. Legacy both-companies-enabled overlaps stay untouched (Swedish review advisory: prod sweep is a follow-up, not this PR).
|
||||
[2026-09-01] EB claim guard round 2 (skeptic re-verify): pending_selection rows are asymmetric, not excluded: their ENABLED accounts still claim (attach-created rows hold offered accounts with no cash rows until saved; excluding them reopened the attach-window double-booking), while their disabled flags stay out of deselection memory (unconfirmed callback output). Both fetchAllRows claim queries order('id'): unordered .range() pagination can silently skip rows at page boundaries, and a skipped row is a missed claim (fail-open).
|
||||
[2026-09-01] Settings PUT cross-field VAT validations scoped to touched field groups (vat-completeness, 40m-monthly, periodisk sammanstallning), not fixed at onboarding: partial saves from surfaces without VAT fields (invoice bank-details dialog) were hard-blocked by pre-existing vat_registered-without-number state (Marketio Lab case). The invariant still holds on every save that touches its group; explicit null now counts as a clear instead of falling back to the stored value during validation. Onboarding-side VAT number collection left as follow-up.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-1
@@ -534,8 +534,11 @@ export function makeCompanySettings(
|
||||
website: null,
|
||||
pays_salaries: false,
|
||||
f_skatt: true,
|
||||
// A coherent momsregistrerad company: registered implies a number on file
|
||||
// (ML 17 kap. 24 §). Tests exercising the missing-number state override
|
||||
// vat_number to null explicitly.
|
||||
vat_registered: true,
|
||||
vat_number: null,
|
||||
vat_number: 'SE556012579001',
|
||||
moms_period: 'quarterly',
|
||||
periodisk_sammanstallning_period: 'quarterly',
|
||||
vat_taxable_base_over_40m: false,
|
||||
|
||||
Reference in New Issue
Block a user