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
@@ -0,0 +1,144 @@
|
||||
-- Artikelregister (product/article catalog) — app-level master data.
|
||||
--
|
||||
-- A lean, NON-INVENTORY article register modeled on the multi-tenant customers
|
||||
-- pattern. Articles are reusable invoice-line presets: name, unit, price excl
|
||||
-- VAT, VAT rate, and an optional revenue-account override. There is deliberately
|
||||
-- NO stock/lager column and NO inventory posting — a scope boundary matching our
|
||||
-- sole-trader + small-AB positioning (see the artikelregister plan). Articles are
|
||||
-- master data, not a journal, so they carry no BFL sequence/immutability
|
||||
-- obligation. The booking is frozen onto invoice_items at line-create time, so
|
||||
-- editing or deactivating an article never moves a posted voucher.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. articles
|
||||
-- =============================================================================
|
||||
CREATE TABLE public.articles (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
article_number text,
|
||||
name text NOT NULL,
|
||||
name_en text,
|
||||
type text NOT NULL DEFAULT 'tjanst' CHECK (type IN ('vara', 'tjanst')),
|
||||
unit text NOT NULL DEFAULT 'st',
|
||||
price_excl_vat numeric NOT NULL DEFAULT 0,
|
||||
vat_rate integer NOT NULL DEFAULT 25 CHECK (vat_rate IN (0, 6, 12, 25)),
|
||||
revenue_account text, -- optional BAS class-3 override; NULL = derive from VAT treatment
|
||||
cost_price numeric, -- margin/display only; never posted to the ledger
|
||||
ean text,
|
||||
housework_type text, -- ROT/RUT arbetstyp (tjanst only); pre-fills the invoice line
|
||||
notes text,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.articles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "view own-company articles"
|
||||
ON public.articles FOR SELECT USING (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "insert own-company articles"
|
||||
ON public.articles FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "update own-company articles"
|
||||
ON public.articles FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "delete own-company articles"
|
||||
ON public.articles FOR DELETE USING (company_id IN (SELECT user_company_ids()));
|
||||
|
||||
CREATE INDEX idx_articles_company_id ON public.articles (company_id);
|
||||
-- Active-only list/search is the hot path; keep it covered without scanning archived rows.
|
||||
CREATE INDEX idx_articles_company_active ON public.articles (company_id, name) WHERE active;
|
||||
-- Article number is the import/lookup key; unique per company when present.
|
||||
CREATE UNIQUE INDEX uq_articles_company_number
|
||||
ON public.articles (company_id, article_number) WHERE article_number IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER set_updated_at_articles
|
||||
BEFORE UPDATE ON public.articles
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER audit_articles
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.articles
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. company_settings.next_article_number — per-company auto-number counter
|
||||
-- =============================================================================
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN IF NOT EXISTS next_article_number integer NOT NULL DEFAULT 1;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. generate_article_number RPC — atomic + idempotent
|
||||
-- (mirrors generate_invoice_number: row lock, idempotent return, atomic
|
||||
-- counter via UPDATE ... RETURNING). Article numbers are master data, so a
|
||||
-- gap from a manual override colliding with the counter is harmless.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.generate_article_number(
|
||||
p_company_id uuid,
|
||||
p_article_id uuid
|
||||
)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_existing text;
|
||||
v_number integer;
|
||||
v_final text;
|
||||
BEGIN
|
||||
-- 1. Lock the article row; concurrent callers serialize here and, on retry,
|
||||
-- see the persisted number.
|
||||
SELECT article_number INTO v_existing
|
||||
FROM public.articles
|
||||
WHERE id = p_article_id AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Article % not found in company %', p_article_id, p_company_id;
|
||||
END IF;
|
||||
|
||||
-- 2. Idempotent: keep an already-assigned number; never consume a sequence twice.
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN v_existing;
|
||||
END IF;
|
||||
|
||||
-- 3. Allocate from the per-company counter atomically.
|
||||
UPDATE public.company_settings
|
||||
SET next_article_number = next_article_number + 1,
|
||||
updated_at = now()
|
||||
WHERE company_id = p_company_id
|
||||
RETURNING next_article_number - 1
|
||||
INTO v_number;
|
||||
|
||||
IF v_number IS NULL THEN
|
||||
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
|
||||
END IF;
|
||||
|
||||
-- 4. Plain sequential number as text. Master data: no year prefix, no padding.
|
||||
v_final := v_number::text;
|
||||
|
||||
-- 5. Persist on the article row in the same transaction.
|
||||
UPDATE public.articles
|
||||
SET article_number = v_final
|
||||
WHERE id = p_article_id AND company_id = p_company_id;
|
||||
|
||||
RETURN v_final;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. invoice_items: per-line revenue-account override + article back-reference
|
||||
-- =============================================================================
|
||||
-- revenue_account is COPIED from the article at line-create time (frozen), so a
|
||||
-- later article edit never moves a posted voucher. NULL preserves the existing
|
||||
-- "derive the revenue account from the VAT treatment" behaviour in
|
||||
-- generatePerRateLines(). article_id is a soft back-reference for the future
|
||||
-- "Affärshändelser" tab; ON DELETE SET NULL keeps frozen line values intact if
|
||||
-- the article is ever hard-deleted.
|
||||
ALTER TABLE public.invoice_items
|
||||
ADD COLUMN IF NOT EXISTS revenue_account text;
|
||||
ALTER TABLE public.invoice_items
|
||||
ADD COLUMN IF NOT EXISTS article_id uuid REFERENCES public.articles(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_items_article_id ON public.invoice_items (article_id);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Add 'create_article' and 'update_article' to the pending_operations
|
||||
-- operation_type CHECK constraint.
|
||||
--
|
||||
-- The artikelregister MCP tools (gnubok_create_article / gnubok_update_article)
|
||||
-- stage a pending operation that, on approval, dispatches into commitCreateArticle
|
||||
-- / commitUpdateArticle. Without this expansion the staged INSERT would be
|
||||
-- rejected by the constraint before the commit-side code ever runs, blocking the
|
||||
-- staged-operation review flow — mirrors create_customer / create_supplier.
|
||||
--
|
||||
-- pg-test: covered-by — CHECK-list expansion only (no trigger/RPC/RLS/DEFERRABLE
|
||||
-- change), so no *.pg.test.ts is required.
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
|
||||
|
||||
ALTER TABLE public.pending_operations
|
||||
ADD CONSTRAINT pending_operations_operation_type_check
|
||||
CHECK (operation_type IN (
|
||||
'categorize_transaction',
|
||||
'create_customer',
|
||||
'create_invoice',
|
||||
'mark_invoice_paid',
|
||||
'send_invoice',
|
||||
'mark_invoice_sent',
|
||||
'match_transaction_invoice',
|
||||
'close_period',
|
||||
'lock_period',
|
||||
'unlock_period',
|
||||
'set_opening_balances',
|
||||
'run_year_end',
|
||||
'run_currency_revaluation',
|
||||
'import_sie',
|
||||
'explain_voucher_gap',
|
||||
'uncategorize_transaction',
|
||||
'approve_supplier_invoice',
|
||||
'credit_supplier_invoice',
|
||||
'credit_invoice',
|
||||
'convert_invoice',
|
||||
'create_transaction',
|
||||
'attach_document_to_transaction',
|
||||
'create_voucher',
|
||||
'correct_entry',
|
||||
'reverse_entry',
|
||||
'create_supplier',
|
||||
'create_supplier_invoice_from_inbox',
|
||||
'post_annual_depreciation',
|
||||
'link_invoice_voucher',
|
||||
'undo_sie_import',
|
||||
'match_batch_allocate',
|
||||
'bulk_book_transactions',
|
||||
'create_salary_run',
|
||||
'generate_agi',
|
||||
'link_transaction_journal_entry',
|
||||
'link_supplier_invoice_voucher',
|
||||
'submit_vat_declaration',
|
||||
'submit_agi',
|
||||
'create_article', -- artikelregister: stage a new catalog article
|
||||
'update_article' -- artikelregister: stage an edit / deactivate
|
||||
));
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user