diff --git a/DECISIONS.md b/DECISIONS.md index 0ac8df32..dd1e56bf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1451,3 +1451,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 1e25fc93..f716176c 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -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 }) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index ad5f54de..64011ab2 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -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 diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index 76365e53..33aaad12 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -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) + }) }) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 0c30ecc7..82e15248 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -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 }, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts index c496cce1..0bc91c43 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -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 diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts index ac362411..58796f2e 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts @@ -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' diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index e9579ede..b40dc0c7 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -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({ diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 5aaaeb33..5c3a7c37 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -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 diff --git a/lib/email/__tests__/__snapshots__/brand-mail-snapshots.test.ts.snap b/lib/email/__tests__/__snapshots__/brand-mail-snapshots.test.ts.snap index 0c690232..9a46fd55 100644 --- a/lib/email/__tests__/__snapshots__/brand-mail-snapshots.test.ts.snap +++ b/lib/email/__tests__/__snapshots__/brand-mail-snapshots.test.ts.snap @@ -176,7 +176,7 @@ exports[`invoice mail (template class: invoice) > unbranded: template output unc

Org.nr: 556000-0000 - + | VAT: SE556012579001 | Innehar F-skattsedel

@@ -314,7 +314,7 @@ exports[`reminder mail (template class: reminder) > unbranded: canonical action

Org.nr: 199001011234 - + | VAT: SE556012579001

diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts index 1a97c2c4..1d9155f9 100644 --- a/lib/errors/__tests__/get-error-message.test.ts +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -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') diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 7081536e..754d3d9c 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1151,6 +1151,14 @@ const INVOICE: Record = { 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.', diff --git a/lib/invoices/__tests__/issue-and-book-invoice.test.ts b/lib/invoices/__tests__/issue-and-book-invoice.test.ts index 69aee7a7..024f861a 100644 --- a/lib/invoices/__tests__/issue-and-book-invoice.test.ts +++ b/lib/invoices/__tests__/issue-and-book-invoice.test.ts @@ -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')) diff --git a/lib/invoices/__tests__/recurring-schedule-service.test.ts b/lib/invoices/__tests__/recurring-schedule-service.test.ts index 7fe14e6f..43914ab0 100644 --- a/lib/invoices/__tests__/recurring-schedule-service.test.ts +++ b/lib/invoices/__tests__/recurring-schedule-service.test.ts @@ -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 diff --git a/lib/invoices/__tests__/seller-vat-number.test.ts b/lib/invoices/__tests__/seller-vat-number.test.ts new file mode 100644 index 00000000..78c47320 --- /dev/null +++ b/lib/invoices/__tests__/seller-vat-number.test.ts @@ -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) + }) +}) diff --git a/lib/invoices/issue-and-book-invoice.ts b/lib/invoices/issue-and-book-invoice.ts index bfce057b..4258928b 100644 --- a/lib/invoices/issue-and-book-invoice.ts +++ b/lib/invoices/issue-and-book-invoice.ts @@ -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) diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts index d362023c..33c61fa7 100644 --- a/lib/invoices/recurring-schedule-service.ts +++ b/lib/invoices/recurring-schedule-service.ts @@ -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, diff --git a/lib/invoices/seller-vat-number.ts b/lib/invoices/seller-vat-number.ts new file mode 100644 index 00000000..268e9194 --- /dev/null +++ b/lib/invoices/seller-vat-number.ts @@ -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, +): boolean { + return !invoice.credited_invoice_id + && invoice.document_type !== 'delivery_note' + && invoice.document_type !== 'proforma' +} + +export function hasRequiredSellerVatNumber( + company: Pick, + invoice: Pick, +): boolean { + if (!invoiceRequiresSellerVatNumber(invoice)) return true + if (!company.vat_registered) return true + return !!company.vat_number?.trim() +} diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts index 88282b63..94c7817c 100644 --- a/lib/pending-operations/__tests__/executors.test.ts +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -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() diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index d466645d..b85e9127 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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) { diff --git a/tests/helpers.ts b/tests/helpers.ts index ff0ffdb8..c7dd7590 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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,