fix(invoices): scope-check article ids in buildInvoiceWriteData so every invoice write refuses a foreign company's article (part of #2059) (#2339)

Part of #2059 (Part 2, the hardening bug).

The FK on invoice_items.article_id proves the article exists, not that it
belongs to the writing company: FK validation ignores RLS, and the v1 routes
run on the service-role client with no RLS at all. Only the two MCP commit
executors checked tenancy; the cookie POST/PATCH, v1 POST/PATCH, webshop and
sales-order writers passed items[].article_id straight through the builder.

Move the check to the one point every writer converges on: buildInvoiceWriteData
collects the distinct article ids from product lines, runs one select scoped
on company_id, and refuses with the new INVOICE_CREATE_ARTICLE_INVALID (400,
Swedish message via the structured-error registry) on any miss. The MCP
executor checks stay as the tamper gate for staged rows.

Tests: builder unit cases (miss refused with details, dedupe + happy path,
no article ids means no query, DB error surfaces as dbError), cookie PATCH
and v1 POST refusal cases, and the existing v1 persist + MCP update tests now
answer the builder's scoped select.


Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 18:39:08 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 8313f527c9
commit ea4da0eb07
6 changed files with 217 additions and 4 deletions
+5
View File
@@ -888,6 +888,11 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Ett angivet bokföringskonto finns inte eller är inte ett aktivt balans- eller intäktskonto (klass 1-3).',
message_en: 'A supplied posting account does not exist or is not an active balance-sheet or revenue account (class 1-3).',
},
INVOICE_CREATE_ARTICLE_INVALID: {
httpStatus: 400,
message_sv: 'En angiven artikel finns inte i företaget.',
message_en: 'A supplied article does not exist in this company.',
},
INVOICE_CREATE_POSTING_ACCOUNT_VAT_CONFLICT: {
httpStatus: 400,
message_sv: 'Ett balanskonto (klass 1-2) kan bara användas på rader utan moms. Använd ett intäktskonto (3xxx) för momspliktiga rader.',