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:
co-authored by
Claude Opus 4.8
parent
07ecb98389
commit
c0b006fcc1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user