feat(invoicing): artikelregister (product/article catalog) with per-article revenue account (#703)

* feat(invoicing): artikelregister (product/article catalog) with per-article revenue account

Add a lean, non-inventory article catalog (artikelregister) so users can define
reusable invoice-line presets (name, unit, price excl VAT, VAT rate) with an
optional per-article BAS class-3 revenue-account override.

- DB: articles table (RLS via user_company_ids(), audit + updated_at triggers,
  unique-per-company article_number), generate_article_number RPC (atomic +
  idempotent), company_settings counter, nullable invoice_items.revenue_account
  + article_id, pending_operations CHECK expansion.
- Engine: generatePerRateLines groups revenue by (vat_rate, account) —
  byte-identical with no override, balance-safe when split (last account absorbs
  the rounding remainder), reverse_charge/export still force 3308/3305.
- API: /api/articles CRUD (soft-deactivate); override validated against
  chart_of_accounts (active class-3) and frozen onto invoice lines at create.
- Propagation: override carried through send/mark-sent/credit/convert/cash and
  the staged commit paths (recurring deferred — documented inline).
- MCP: gnubok_list/create/update_article (staged, scoped, risk-tiered).
- UI: articles register (list/detail/form) + nav + bilingual i18n + invoice-line
  article picker & "Spara som artikel" quick-create.
- Tests: engine regression, route, and pg-real (RPC/RLS/triggers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): strip ILIKE _ wildcard from gnubok_list_articles search

Underscore is a single-character ILIKE wildcard; stripping it (alongside the
existing %,()\* set) keeps a stray char in the article search from matching
every row. Read-only + RLS-scoped, so no security impact — addresses PR #703
reviewer + compliance-swarm CC6.3 notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-09 21:05:37 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 07ecb98389
commit c0b006fcc1
32 changed files with 3047 additions and 16 deletions
+44
View File
@@ -28,6 +28,14 @@ const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactl
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
/** BAS class-3 revenue account — exactly 4 digits starting with 3 (försäljning/intäkt). */
const revenueAccount = z
.string()
.regex(/^3\d{3}$/, 'Revenue account must be a 4-digit BAS class-3 account (3xxx)')
/** Swedish VAT rate as an integer percent. */
const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
/** Time string (HH:MM or HH:MM:SS) */
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
@@ -190,6 +198,11 @@ export const CreateInvoiceItemSchema = z.object({
unit: z.string().min(1, 'Unit is required'),
unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(),
// Article linkage. `article_id` ties the line to a catalog article (free-text
// lines omit it). `revenue_account` is the optional BAS class-3 override the
// engine books to; the API validates it against chart_of_accounts before use.
article_id: uuid.nullable().optional(),
revenue_account: revenueAccount.nullable().optional(),
// ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
// the client schema — the API computes it from rot-rut-rules.ts so a
// tampered client can't expand the 1513 receivable beyond the line total.
@@ -232,6 +245,37 @@ export const CreateCreditNoteSchema = z.object({
reason: z.string().optional(),
})
// ============================================================
// Articles (artikelregister)
// ============================================================
export const ArticleTypeSchema = z.enum(['vara', 'tjanst'])
export const CreateArticleSchema = z.object({
name: z.string().min(1, 'Article name is required').max(200),
type: ArticleTypeSchema.optional(),
unit: z.string().min(1).max(32).optional(),
price_excl_vat: nonNegativeAmount,
vat_rate: vatRatePercent.optional(),
// Optional BAS class-3 revenue-account override. Null/omitted = derive from
// the invoice's VAT treatment (current behaviour).
revenue_account: revenueAccount.nullable().optional(),
// Margin/display only; never posted.
cost_price: nonNegativeAmount.nullable().optional(),
ean: z.string().max(32).nullable().optional(),
// ROT/RUT arbetstyp; only meaningful for type === 'tjanst'.
housework_type: z.string().max(64).nullable().optional(),
name_en: z.string().max(200).nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
// Manual article number; omit to auto-generate via generate_article_number.
article_number: z.string().max(64).nullable().optional(),
})
// PATCH allows every create field plus toggling the soft-delete flag.
export const UpdateArticleSchema = CreateArticleSchema.partial().extend({
active: z.boolean().optional(),
})
// Self-billing received (mottagen självfaktura, ML 17 kap 15§). The customer
// issued the invoice on our behalf; for us it is a sale. We store the
// counterparty's number in external_invoice_number and never assign one from
+30
View File
@@ -0,0 +1,30 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Assign an article number to an article row via the generate_article_number
* RPC. Idempotent: if the row already has a number, the RPC returns it unchanged
* without consuming a sequence number. Concurrency is handled inside the RPC via
* a row lock on the article plus an atomic counter on company_settings — two
* callers racing on the same article both return the same number and the counter
* advances by exactly one.
*
* Mirrors lib/invoices/ensure-invoice-number.ts. Unlike invoice numbers, article
* numbers are master data and carry no BFL sequence/immutability obligation, so
* a gap is harmless.
*/
export async function ensureArticleNumber(
supabase: SupabaseClient,
companyId: string,
articleId: string,
): Promise<string> {
const { data, error } = await supabase.rpc('generate_article_number', {
p_company_id: companyId,
p_article_id: articleId,
})
if (error || !data) {
throw new Error(`Failed to assign article number: ${error?.message ?? 'no value returned'}`)
}
return data as string
}
+28
View File
@@ -0,0 +1,28 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* True when `account` exists in the company's chart of accounts as an ACTIVE
* class-3 (revenue/intäkt) account. Used to guard the optional per-article
* revenue-account override so a typo or a non-revenue account can never be
* pinned to an article (and later booked). Never trust the client.
*
* Throws on an unexpected DB error so the route wrapper maps it to the canonical
* envelope; a simple "account not found" resolves to `false`, not an error.
*/
export async function isValidRevenueAccount(
supabase: SupabaseClient,
companyId: string,
account: string,
): Promise<boolean> {
const { data, error } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.eq('account_class', 3)
.eq('is_active', true)
.eq('account_number', account)
.maybeSingle()
if (error) throw error
return !!data
}
+10
View File
@@ -11,6 +11,8 @@ export const API_KEY_SCOPES = {
'transactions:write': { label: 'Transaktioner — skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' },
'customers:read': { label: 'Kunder — läs', description: 'Lista kunder (1 verktyg)' },
'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' },
'articles:read': { label: 'Artiklar — läs', description: 'Lista artiklar i artikelregistret (1 verktyg)' },
'articles:write': { label: 'Artiklar — skriv', description: 'Skapa och uppdatera artiklar (2 verktyg)' },
'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' },
'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater (3 verktyg)' },
@@ -42,6 +44,7 @@ export const ALL_SCOPES: ApiKeyScope[] = Object.keys(API_KEY_SCOPES) as ApiKeySc
export const DEFAULT_SCOPES: ApiKeyScope[] = [
'transactions:read',
'customers:read',
'articles:read',
'invoices:read',
'suppliers:read',
'reports:read',
@@ -71,6 +74,7 @@ export const DEFAULT_SCOPES: ApiKeyScope[] = [
export const DEFAULT_OAUTH_SCOPES: ApiKeyScope[] = [
'transactions:read',
'customers:read',
'articles:read',
'invoices:read',
'suppliers:read',
'reports:read',
@@ -109,6 +113,7 @@ export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCO
export const STAGING_SCOPES: ApiKeyScope[] = [
'transactions:write',
'customers:write',
'articles:write',
'invoices:write',
'suppliers:write',
'bookkeeping:write',
@@ -140,6 +145,7 @@ export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | n
export const SCOPE_GROUPS = [
{ domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const },
{ domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const },
{ domain: 'articles', label: 'Artiklar', read: 'articles:read' as const, write: 'articles:write' as const },
{ domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const },
{ domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: 'suppliers:write' as const },
{ domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null },
@@ -168,6 +174,10 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// Customers
gnubok_list_customers: 'customers:read',
gnubok_create_customer: 'customers:write',
// Articles (artikelregister)
gnubok_list_articles: 'articles:read',
gnubok_create_article: 'articles:write',
gnubok_update_article: 'articles:write',
// Invoices
gnubok_list_invoices: 'invoices:read',
gnubok_create_invoice: 'invoices:write',
@@ -298,6 +298,113 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
})
})
describe('createInvoiceJournalEntry — per-article revenue account override', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('without an override, two 25% lines collapse into one 3001 revenue line (unchanged behaviour)', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'A', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 }),
makeItem({ id: 'item-2', description: 'B', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100 }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const rev3001 = input.lines.filter((l) => l.account_number === '3001')
expect(rev3001).toHaveLength(1)
expect(rev3001[0].credit_amount).toBe(1000)
const vat2611 = input.lines.filter((l) => l.account_number === '2611')
expect(vat2611).toHaveLength(1)
expect(vat2611[0].credit_amount).toBe(250)
})
it('splits one rate into two revenue accounts but keeps a single VAT line, balanced', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'Goods', unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 }), // no override → 3001
makeItem({ id: 'item-2', description: 'Consulting', unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, revenue_account: '3041' }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3001')?.credit_amount).toBe(600)
expect(input.lines.find((l) => l.account_number === '3041')?.credit_amount).toBe(400)
const vat = input.lines.filter((l) => l.account_number === '2611')
expect(vat).toHaveLength(1)
expect(vat[0].credit_amount).toBe(250)
const debit = input.lines.reduce((s, l) => s + l.debit_amount, 0)
const credit = input.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(debit).toBe(credit)
expect(debit).toBe(1250)
})
it('ignores a per-line override on reverse charge — revenue stays on 3308', async () => {
const invoice = makeInvoice({
subtotal: 5000,
vat_amount: 0,
total: 5000,
vat_treatment: 'reverse_charge',
vat_rate: 0,
items: [
makeItem({ unit_price: 5000, line_total: 5000, vat_rate: 0, vat_amount: 0, revenue_account: '3041' }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
expect(input.lines.find((l) => l.account_number === '3308')?.credit_amount).toBe(5000)
expect(input.lines.find((l) => l.account_number === '3041')).toBeUndefined()
})
it('absorbs rounding on the last account so a split rate still balances against 1510', async () => {
// Two 25% lines to different accounts whose individual SEK rounding would
// otherwise drift from the rate-level total (10.005 → 10.01 each = 20.02,
// but the rate total is round(20.01) = 20.01).
const invoice = makeInvoice({
subtotal: 20.01,
vat_amount: 5.0,
total: 25.01,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'A', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5 }),
makeItem({ id: 'item-2', description: 'B', unit_price: 10.005, line_total: 10.005, vat_rate: 25, vat_amount: 2.5, revenue_account: '3041' }),
],
})
await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][3]
const revSum = input.lines
.filter((l) => l.account_number === '3001' || l.account_number === '3041')
.reduce((s, l) => s + l.credit_amount, 0)
expect(Math.round(revSum * 100) / 100).toBe(20.01)
const debit = Math.round(input.lines.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100
const credit = Math.round(input.lines.reduce((s, l) => s + l.credit_amount, 0) * 100) / 100
expect(debit).toBe(credit)
expect(debit).toBe(25.01)
})
})
describe('createCreditNoteJournalEntry — per-line VAT', () => {
beforeEach(() => {
vi.clearAllMocks()
+48 -12
View File
@@ -108,29 +108,65 @@ function generatePerRateLines(
return lines
}
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
// Group items by vat_rate (preserve first-seen rate order). Within each rate,
// sub-group revenue by the resolved BAS account so a per-line/article account
// override produces its own credit line. VAT stays aggregated per rate (the
// VAT account is a function of the treatment, never of the revenue override).
type RateGroup = {
vatAmount: number
// resolved revenue account -> summed line_total (first-seen account order)
byAccount: Map<string, number>
}
const rateGroups = new Map<number, RateGroup>()
for (const item of items) {
const rate = item.vat_rate ?? 0
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
const treatment = rate === 0 && (invoiceVatTreatment === 'reverse_charge' || invoiceVatTreatment === 'export')
? invoiceVatTreatment
: getVatTreatmentForRate(rate)
// reverse_charge / export force the statutory revenue account (3308/3305);
// a per-line override only applies to ordinary domestic rates so EU/export
// sales keep landing in the right VAT-declaration ruta.
const isSpecialTreatment = treatment === 'reverse_charge' || treatment === 'export'
const account = !isSpecialTreatment && item.revenue_account
? item.revenue_account
: getRevenueAccount(treatment, entityType)
const group = rateGroups.get(rate) ?? { vatAmount: 0, byAccount: new Map<string, number>() }
group.vatAmount += item.vat_amount || 0
group.byAccount.set(account, (group.byAccount.get(account) ?? 0) + item.line_total)
rateGroups.set(rate, group)
}
// Generate revenue + VAT lines per rate group
// Generate revenue + VAT lines per rate group.
for (const [rate, group] of rateGroups) {
const treatment = rate === 0 && (invoiceVatTreatment === 'reverse_charge' || invoiceVatTreatment === 'export')
? invoiceVatTreatment
: getVatTreatmentForRate(rate)
const revenueAccount = getRevenueAccount(treatment, entityType)
const roundedSubtotal = Math.round(toSek(group.subtotal) * 100) / 100
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: roundedSubtotal,
line_description: `Försäljning faktura ${invoiceTagText}`,
// The rate-level rounded subtotal is the balance anchor — identical to the
// pre-override single-account behaviour. When a rate splits across multiple
// accounts, distribute that exact total so independent per-account rounding
// can never introduce a 1-öre imbalance against the 1510 debit: every
// account but the last rounds normally; the last absorbs the remainder.
const rateSubtotalSek = Math.round(
toSek(Array.from(group.byAccount.values()).reduce((sum, v) => sum + v, 0)) * 100
) / 100
const accounts = Array.from(group.byAccount.entries())
let allocated = 0
accounts.forEach(([account, subtotal], idx) => {
const isLast = idx === accounts.length - 1
const credit = isLast
? Math.round((rateSubtotalSek - allocated) * 100) / 100
: Math.round(toSek(subtotal) * 100) / 100
allocated = Math.round((allocated + credit) * 100) / 100
lines.push({
account_number: account,
debit_amount: 0,
credit_amount: credit,
line_description: `Försäljning faktura ${invoiceTagText}`,
})
})
const roundedVat = Math.round(toSek(group.vatAmount) * 100) / 100
+2
View File
@@ -24,6 +24,7 @@ type ErrorContext =
| 'invoice'
| 'supplier_invoice'
| 'customer'
| 'article'
| 'supplier'
| 'transaction'
| 'journal_entry'
@@ -78,6 +79,7 @@ const CONTEXT_FALLBACKS: Record<ErrorContext, Bilingual> = {
invoice: { sv: 'Kunde inte hantera fakturan. Försök igen.', en: 'Could not process the invoice. Please try again.' },
supplier_invoice: { sv: 'Kunde inte hantera leverantörsfakturan. Försök igen.', en: 'Could not process the supplier invoice. Please try again.' },
customer: { sv: 'Kunde inte hantera kunden. Försök igen.', en: 'Could not process the customer. Please try again.' },
article: { sv: 'Kunde inte hantera artikeln. Försök igen.', en: 'Could not process the article. Please try again.' },
supplier: { sv: 'Kunde inte hantera leverantören. Försök igen.', en: 'Could not process the supplier. Please try again.' },
transaction: { sv: 'Kunde inte hantera transaktionen. Försök igen.', en: 'Could not process the transaction. Please try again.' },
journal_entry: { sv: 'Kunde inte hantera verifikationen. Försök igen.', en: 'Could not process the journal entry. Please try again.' },
+34
View File
@@ -579,6 +579,11 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Momssatsen är inte tillåten för denna kundtyp.',
message_en: 'The VAT rate is not allowed for this customer type.',
},
INVOICE_CREATE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400,
message_sv: 'Ett angivet försäljningskonto finns inte eller är inte ett aktivt intäktskonto (klass 3).',
message_en: 'A supplied revenue account does not exist or is not an active class-3 income account.',
},
INVOICE_CREATE_ROT_RUT_VALIDATION: {
httpStatus: 400,
message_sv: 'ROT/RUT-avdraget kunde inte valideras. Kontrollera personnummer och fastighetsbeteckning.',
@@ -1424,6 +1429,34 @@ const CUSTOMER: Record<string, StructuredErrorEntry> = {
},
}
const ARTICLE: Record<string, StructuredErrorEntry> = {
ARTICLE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Artikeln kunde inte hittas.',
message_en: 'Article not found.',
},
ARTICLE_DUPLICATE_NUMBER: {
httpStatus: 409,
message_sv: 'En artikel med samma artikelnummer finns redan.',
message_en: 'An article with that article number already exists.',
},
ARTICLE_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Artikeln kunde inte skapas.',
message_en: 'Failed to create article.',
},
ARTICLE_UPDATE_FAILED: {
httpStatus: 500,
message_sv: 'Artikeln kunde inte uppdateras.',
message_en: 'Failed to update article.',
},
ARTICLE_REVENUE_ACCOUNT_INVALID: {
httpStatus: 400,
message_sv: 'Försäljningskontot finns inte eller är inte ett aktivt intäktskonto (klass 3).',
message_en: 'The revenue account does not exist or is not an active class-3 income account.',
},
}
const SUPPLIER: Record<string, StructuredErrorEntry> = {
SUPPLIER_NOT_FOUND: {
httpStatus: 404,
@@ -2271,6 +2304,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...PROVIDER_MIGRATION,
...DOCUMENT,
...CUSTOMER,
...ARTICLE,
...SUPPLIER,
...SUPPLIER_INVOICE_WAVE4,
...SALARY,
+4
View File
@@ -4,6 +4,7 @@ import type {
Transaction,
Customer,
Supplier,
Article,
FiscalPeriod,
DocumentAttachment,
Receipt,
@@ -78,6 +79,9 @@ export type CoreEvent =
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
// Customers
| { type: 'customer.created'; payload: { customer: Customer; userId: string; companyId: string } }
// Articles (artikelregister)
| { type: 'article.created'; payload: { article: Article; userId: string; companyId: string } }
| { type: 'article.updated'; payload: { article: Article; userId: string; companyId: string } }
// Suppliers
| { type: 'supplier.created'; payload: { supplier: Supplier; userId: string; companyId: string } }
// Receipts
@@ -230,6 +230,11 @@ export async function executeRecurringSchedule(
}
// 6. Insert items.
// NOTE (artikelregister Phase 2): recurring schedule template items have no
// article_id / revenue_account columns (see recurring_invoice_schedule_items),
// so generated invoices fall back to the VAT-treatment-derived revenue account.
// Wiring per-article overrides into recurring invoices needs a schema change
// and is deliberately out of the artikelregister MVP scope.
const itemRows = items.map((item, index) => {
const itemRate = item.vat_rate != null ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
+145
View File
@@ -70,6 +70,9 @@ import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
import { z } from 'zod'
import type {
Transaction,
@@ -80,6 +83,7 @@ import type {
Invoice,
Customer,
Supplier,
Article,
SupplierInvoice,
SupplierInvoiceItem,
PendingOperation,
@@ -427,6 +431,112 @@ async function commitCreateCustomer(
return { data: { customer_id: data.id } }
}
async function commitCreateArticle(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
// Defense in depth: re-validate the staged params at the commit boundary so a
// tampered pending_operations row cannot inject unexpected fields (ASVS V4.5).
let validated
try {
validated = CreateArticleParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
if (validated.revenue_account) {
const ok = await isValidRevenueAccount(supabase, companyId, validated.revenue_account)
if (!ok) return { error: 'Revenue account is not an active class-3 account', status: 400 }
}
const { data, error } = await supabase
.from('articles')
.insert({
user_id: userId,
company_id: companyId,
name: validated.name,
name_en: validated.name_en ?? null,
type: validated.type,
unit: validated.unit ?? 'st',
price_excl_vat: validated.price_excl_vat,
vat_rate: validated.vat_rate,
revenue_account: validated.revenue_account ?? null,
cost_price: validated.cost_price ?? null,
ean: validated.ean ?? null,
housework_type: validated.housework_type ?? null,
notes: validated.notes ?? null,
article_number: validated.article_number ?? null,
})
.select()
.single()
if (error) return { error: error.message, status: 500 }
if (!data.article_number) {
try {
data.article_number = await ensureArticleNumber(supabase, companyId, data.id)
} catch (err) {
log.warn('article number assignment failed (staged create):', err)
}
}
await eventBus.emit({ type: 'article.created', payload: { article: data as Article, userId, companyId } })
return { data: { article_id: data.id, article_number: data.article_number } }
}
async function commitUpdateArticle(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
let validated
try {
validated = UpdateArticleParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
if (validated.revenue_account) {
const ok = await isValidRevenueAccount(supabase, companyId, validated.revenue_account)
if (!ok) return { error: 'Revenue account is not an active class-3 account', status: 400 }
}
const { article_id, ...rest } = validated
const updateData: Record<string, unknown> = {}
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined) updateData[key] = value
}
const { data, error } = await supabase
.from('articles')
.update(updateData)
.eq('id', article_id)
.eq('company_id', companyId)
.select()
.single()
if (error) {
if (error.code === 'PGRST116') return { error: 'Article not found', status: 404 }
return { error: error.message, status: 500 }
}
await eventBus.emit({ type: 'article.updated', payload: { article: data as Article, userId, companyId } })
return { data: { article_id: data.id } }
}
async function commitCreateSupplier(
supabase: SupabaseClient,
userId: string,
@@ -539,6 +649,7 @@ async function commitCreateInvoice(
const customerId = params.customer_id as string
const items = params.items as Array<{
description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number
article_id?: string | null; revenue_account?: string | null
}>
const { data: customer, error: customerError } = await supabase
@@ -564,6 +675,17 @@ async function commitCreateInvoice(
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
// Validate any per-line revenue-account override (defense in depth — the field
// is frozen onto invoice_items and flows to generatePerRateLines()).
const overrideAccounts = Array.from(
new Set(items.map((i) => i.revenue_account).filter((a): a is string => !!a)),
)
for (const acct of overrideAccounts) {
if (!(await isValidRevenueAccount(supabase, companyId, acct))) {
return { error: `Försäljningskonto ${acct} är inte ett aktivt intäktskonto (klass 3)`, status: 400 }
}
}
const total = subtotal + vatAmount
const currency = ((params.currency as string) || 'SEK') as Currency
@@ -632,6 +754,10 @@ async function commitCreateInvoice(
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
// Frozen per-line override so generatePerRateLines() books to the article's
// account; null falls back to the VAT-treatment-derived account.
article_id: item.article_id ?? null,
revenue_account: item.revenue_account ?? null,
}
})
@@ -2099,6 +2225,8 @@ async function commitCreditInvoice(
line_total: number
vat_rate?: number
vat_amount?: number
revenue_account?: string | null
article_id?: string | null
}) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
@@ -2109,6 +2237,10 @@ async function commitCreditInvoice(
line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 0,
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
// Reverse to the SAME account the original credited (e.g. 3041, not the
// VAT-derived 3001) so the override account doesn't keep a dangling balance.
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
}))
const { error: itemsError } = await supabase
@@ -2257,6 +2389,13 @@ async function commitConvertInvoice(
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
// Preserve per-line VAT and any article/revenue-account override from the
// proforma so the converted invoice books exactly as the proforma showed
// (mixed rates + per-article accounts both rely on these per-line fields).
vat_rate: item.vat_rate ?? 0,
vat_amount: item.vat_amount ?? 0,
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
}))
if (items.length > 0) {
@@ -3163,6 +3302,12 @@ async function commitPendingOperationInner(
case 'create_customer':
result = await commitCreateCustomer(supabase, userId, companyId, pendingOp.params)
break
case 'create_article':
result = await commitCreateArticle(supabase, userId, companyId, pendingOp.params)
break
case 'update_article':
result = await commitUpdateArticle(supabase, userId, companyId, pendingOp.params)
break
case 'create_supplier':
result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params)
break
+6
View File
@@ -21,6 +21,12 @@ export type RiskLevel = 'low' | 'medium' | 'high'
export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// ── Low: pure data, no booking impact ─────────────────────────────
create_customer: 'low',
// Article catalog (artikelregister) is app-level master data — no journal
// impact, no external side-effect. Unlike create_supplier it carries no
// payment-routing fields, so there's no BEC/fraud surface; both create and
// update sit at the lowest tier next to create_customer.
create_article: 'low',
update_article: 'low',
// ── Medium: reversible booking ─────────────────────────────────────
categorize_transaction: 'medium',
+56
View File
@@ -0,0 +1,56 @@
import { z } from 'zod'
// Commit-boundary re-validation for staged article operations. A staged
// pending_operations row is re-parsed here before it touches the articles table
// so a tampered row cannot inject unexpected fields or malformed data
// (defense in depth, ASVS V4.5) — mirrors lib/pending-operations/schemas/create-supplier.ts.
const revenueAccount = z
.string()
.regex(/^3\d{3}$/, 'Revenue account must be a 4-digit BAS class-3 account (3xxx)')
const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
/** Empty string / null → undefined, then bounded string. */
const optString = (max: number) =>
z.preprocess((v) => (v == null || v === '' ? undefined : v), z.string().max(max).optional())
const trimmedName = z.preprocess(
(v) => (typeof v === 'string' ? v.trim() : v),
z.string().min(1, 'Article name is required').max(200),
)
export const CreateArticleParamsSchema = z.object({
name: trimmedName,
type: z.enum(['vara', 'tjanst']).default('tjanst'),
unit: optString(32),
price_excl_vat: z.number().nonnegative(),
vat_rate: vatRatePercent.default(25),
revenue_account: revenueAccount.nullable().optional(),
cost_price: z.number().nonnegative().nullable().optional(),
ean: optString(32),
housework_type: optString(64),
name_en: optString(200),
notes: optString(2000),
article_number: optString(64),
})
export const UpdateArticleParamsSchema = z.object({
article_id: z.string().uuid(),
name: trimmedName.optional(),
type: z.enum(['vara', 'tjanst']).optional(),
unit: optString(32),
price_excl_vat: z.number().nonnegative().optional(),
vat_rate: vatRatePercent.optional(),
revenue_account: revenueAccount.nullable().optional(),
cost_price: z.number().nonnegative().nullable().optional(),
ean: optString(32),
housework_type: optString(64),
name_en: optString(200),
notes: optString(2000),
article_number: optString(64),
active: z.boolean().optional(),
})
export type CreateArticleParams = z.infer<typeof CreateArticleParamsSchema>
export type UpdateArticleParams = z.infer<typeof UpdateArticleParamsSchema>