a3c6566caf
* feat(mcp): ledger-context resource with per-company booking patterns Adds Accounted://ledger/context: derived account usage, counterparty booking patterns with explicit confidence share (0.7 floor), explicit mapping rules kept separate as authoritative, observed VAT profile, and conventions. Backed by a SECURITY INVOKER get_ledger_usage_stats RPC so group-bys run SQL-side, and surfaced as a top-5 digest stanza on gnubok_get_agent_briefing so one call still bootstraps a session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mcp): fold source-quality prereqs into the ledger-context RPC Merchant-name normalization at the aggregation path (the splinter fix): new normalize_counterparty_key() SQL function mirroring normalizeCounterpartyName() so KORTKÖP/SWISH/date-suffixed labels merge into one counterparty key, which also makes the categorization_templates join exact. New supplier_patterns section (per-supplier dominant expense account + VAT treatment from supplier invoices; credit notes and reversed invoices excluded). account_usage excludes storno lines (they re-inflate the account a correction moved away from); the counterparty CTE keeps corrections because the transaction relink self-heals. Pattern confidence is now count-grounded evidence {seen_12m, agree, share, last_booked} instead of a bare ratio, and the digest frames it as historical frequency, never auto-book permission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent-context): use roundOre for the share ratio (antipattern ratchet) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent-context): defensive storno filter on counterparty CTE, fail-loud secondary reads Review follow-ups: the counterparty CTE now excludes source_type='storno' defensively (no live code path links a transaction to a storno, but legacy rows may predate reverseEntry's unlink; a linked storno would count the reversed category as precedent). Corrections stay included: they are the live booking after relink. Secondary reads (rules, templates, settings) now throw instead of silently reading as empty data: an agent must never be told 'no rules' when the truth is 'read failed'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
351 lines
14 KiB
PL/PgSQL
351 lines
14 KiB
PL/PgSQL
-- RPC: get_ledger_usage_stats — windowed booking-pattern aggregates for the
|
|
-- agent ledger-context resource (Accounted://ledger/context).
|
|
--
|
|
-- Returns one jsonb document with five sections:
|
|
-- account_usage: top 20 accounts by posted-line count in the
|
|
-- window, with account_name and last_used date
|
|
-- counterparty_patterns: top 25 booked counterparties by occurrence, with
|
|
-- dominant category (+ agree count), dominant
|
|
-- non-bank contra account, and last booked date
|
|
-- supplier_patterns: top 15 suppliers by invoice count in the window,
|
|
-- with dominant expense account (+ agree count)
|
|
-- and dominant vat_treatment
|
|
-- vat_treatments_used: distinct vat_treatment values on invoices and
|
|
-- supplier invoices in the window
|
|
-- median_booking_lag_days: median(entry_date - transaction date) across
|
|
-- booked transactions in the window (honesty
|
|
-- signal: how promptly this company books)
|
|
--
|
|
-- PostgREST cannot GROUP BY through supabase-js, and paging a year of
|
|
-- journal_entry_lines through fetchAllRows to aggregate in JS does not scale.
|
|
-- One SQL round trip keeps the resource read cheap enough to compute per
|
|
-- request (design: dev_docs/ledger_context_resource.md, phase 1 = no cache).
|
|
--
|
|
-- Only status = 'posted' entries count: the resource describes how this
|
|
-- company actually books things, and drafts are not yet bookings. (Contrast
|
|
-- get_account_usage_counts, which includes drafts because it answers a
|
|
-- deletion-safety question.)
|
|
--
|
|
-- Storno handling, deliberately asymmetric:
|
|
-- - Stornos are excluded everywhere (account_usage by their swapped lines
|
|
-- re-inflating the account a human corrected AWAY from; the counterparty
|
|
-- CTE defensively, for legacy rows linked before reverseEntry() started
|
|
-- unlinking transactions).
|
|
-- - Corrections are excluded NOWHERE: correctEntry() relinks
|
|
-- transactions.journal_entry_id to the correction entry, making it the
|
|
-- live booking. Excluding 'correction' would drop exactly the booking
|
|
-- the human fixed.
|
|
--
|
|
-- Counterparties are keyed on normalize_counterparty_key(), the SQL mirror of
|
|
-- normalizeCounterpartyName() (lib/bookkeeping/counterparty-templates.ts), so
|
|
-- "SWISH KLARNA AB", "Klarna AB 2026-06-01" and "KLARNA AB" aggregate as one
|
|
-- counterparty instead of splintering and diluting every count. The key is
|
|
-- also returned so the lib layer can join categorization_templates (whose
|
|
-- counterparty_name is stored in the same normalized form) exactly.
|
|
-- This string key is the deliberate interim identity: it re-keys to
|
|
-- counterparty_entity.id when the identity substrate lands
|
|
-- (dev_docs/bank_transaction_ai_normalization.md §14, Layer F).
|
|
--
|
|
-- Dominant contra account excludes 19xx (bank/cash): for bank-sourced
|
|
-- bookings the 19xx side is the constant, so the informative side is the
|
|
-- other one. Transaction-side vat_treatment is NOT derived here; the lib
|
|
-- layer merges it from categorization_templates, which carry it explicitly.
|
|
-- Supplier-side vat_treatment IS derived here (supplier_invoices carry it).
|
|
--
|
|
-- SECURITY INVOKER: journal_entries/journal_entry_lines/transactions RLS is
|
|
-- company-scoped via user_company_ids() (20260330130000), so the caller's own
|
|
-- membership bounds what is aggregated; a non-member calling with a foreign
|
|
-- company id gets empty sections, not an error.
|
|
--
|
|
-- pg-test: tests/pg/ledger-usage-stats-rpc.pg.test.ts
|
|
|
|
-- SQL mirror of normalizeCounterpartyName() -> normalizeMerchantName()
|
|
-- (lib/bookkeeping/counterparty-templates.ts / lib/documents/core-receipt-matcher.ts).
|
|
-- Keep the two in sync: categorization_templates.counterparty_name is written
|
|
-- through the TS pair, and the lib layer joins RPC rows to templates on this
|
|
-- key. Regex notes: \y is Postgres's word boundary (JS \b); JS \w is
|
|
-- [A-Za-z0-9_], spelled out explicitly because Postgres \w is locale-wider.
|
|
CREATE OR REPLACE FUNCTION public.normalize_counterparty_key(raw text)
|
|
RETURNS text
|
|
LANGUAGE plpgsql
|
|
IMMUTABLE
|
|
PARALLEL SAFE
|
|
AS $$
|
|
DECLARE
|
|
cleaned text;
|
|
tokens text[];
|
|
last_tok text;
|
|
months constant text[] := ARRAY[
|
|
'jan','feb','mar','apr','maj','may','jun','jul','aug','sep','sept',
|
|
'okt','oct','nov','dec',
|
|
'januari','februari','mars','april','juni','juli','augusti',
|
|
'september','oktober','november','december'
|
|
];
|
|
BEGIN
|
|
IF raw IS NULL THEN
|
|
RETURN '';
|
|
END IF;
|
|
|
|
-- normalizeCounterpartyName(): payment-rail prefixes, dates, invoice refs,
|
|
-- trailing digit runs.
|
|
cleaned := regexp_replace(raw, '^(BANKGIRO|SWISH|KORTKÖP|KORT[[:space:]]*KÖP|PG|BG|AUTOGIRO|PLUSGIRO)[[:space:]]*', '', 'i');
|
|
cleaned := regexp_replace(cleaned, '\y\d{2,4}[-/]?\d{2}[-/]?\d{2}\y', '', 'g');
|
|
cleaned := regexp_replace(cleaned, '\y[F#]?\d{4,}\S*', '', 'gi');
|
|
cleaned := regexp_replace(cleaned, '\yINV-?\d+', '', 'gi');
|
|
cleaned := regexp_replace(cleaned, '[[:space:]]+\d{4,}[[:space:]]*$', '', 'g');
|
|
cleaned := btrim(cleaned);
|
|
|
|
-- stripTrailingNoiseTokens(): pop trailing month names and 1-2 letter
|
|
-- all-caps initials (checked against ORIGINAL casing, before lowering);
|
|
-- always keep at least one token.
|
|
tokens := regexp_split_to_array(cleaned, '[[:space:]]+');
|
|
WHILE coalesce(array_length(tokens, 1), 0) > 1 LOOP
|
|
last_tok := tokens[array_length(tokens, 1)];
|
|
IF lower(last_tok) = ANY(months) OR last_tok ~ '^[A-ZÅÄÖ]{1,2}$' THEN
|
|
tokens := tokens[1:array_length(tokens, 1) - 1];
|
|
ELSE
|
|
EXIT;
|
|
END IF;
|
|
END LOOP;
|
|
cleaned := array_to_string(tokens, ' ');
|
|
|
|
-- normalizeMerchantName(): lowercase, strip special chars (keep Swedish
|
|
-- letters), drop legal-form suffixes, collapse whitespace.
|
|
cleaned := lower(cleaned);
|
|
cleaned := regexp_replace(cleaned, '[^a-z0-9_[:space:]åäöé]', '', 'g');
|
|
cleaned := regexp_replace(cleaned, '\y(ab|hb|kb|ek|för|stiftelse)\y', '', 'g');
|
|
cleaned := regexp_replace(cleaned, '[[:space:]]+', ' ', 'g');
|
|
RETURN btrim(cleaned);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.normalize_counterparty_key(text) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.normalize_counterparty_key(text) TO authenticated, service_role;
|
|
|
|
CREATE OR REPLACE FUNCTION public.get_ledger_usage_stats(
|
|
p_company_id uuid,
|
|
p_from_date date
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
SELECT jsonb_build_object(
|
|
'account_usage',
|
|
(
|
|
SELECT coalesce(
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'account_number', au.account_number,
|
|
'account_name', au.account_name,
|
|
'postings', au.postings,
|
|
'last_used', au.last_used
|
|
)
|
|
ORDER BY au.postings DESC, au.account_number
|
|
),
|
|
'[]'::jsonb
|
|
)
|
|
FROM (
|
|
SELECT
|
|
l.account_number,
|
|
max(coa.account_name) AS account_name,
|
|
count(*)::bigint AS postings,
|
|
max(je.entry_date) AS last_used
|
|
FROM public.journal_entry_lines l
|
|
JOIN public.journal_entries je ON je.id = l.journal_entry_id
|
|
LEFT JOIN public.chart_of_accounts coa
|
|
ON coa.company_id = p_company_id
|
|
AND coa.account_number = l.account_number
|
|
WHERE je.company_id = p_company_id
|
|
AND je.status = 'posted'
|
|
-- Stornos annul: counting their swapped lines re-inflates the
|
|
-- account the correction moved away from. Corrections stay.
|
|
AND je.source_type <> 'storno'
|
|
AND je.entry_date >= p_from_date
|
|
GROUP BY l.account_number
|
|
ORDER BY count(*) DESC, l.account_number
|
|
LIMIT 20
|
|
) au
|
|
),
|
|
'counterparty_patterns',
|
|
(
|
|
WITH booked AS (
|
|
SELECT
|
|
public.normalize_counterparty_key(t.merchant_name) AS counterparty_key,
|
|
t.merchant_name,
|
|
t.category,
|
|
t.journal_entry_id,
|
|
t.date
|
|
FROM public.transactions t
|
|
JOIN public.journal_entries je ON je.id = t.journal_entry_id
|
|
WHERE t.company_id = p_company_id
|
|
AND t.journal_entry_id IS NOT NULL
|
|
AND je.status = 'posted'
|
|
-- Defensive: no code path should link a transaction to a storno
|
|
-- (correctEntry relinks to the correction, reverseEntry unlinks),
|
|
-- but legacy rows may predate the unlink behavior. Corrections are
|
|
-- deliberately NOT excluded: they are the live booking.
|
|
AND je.source_type <> 'storno'
|
|
AND t.merchant_name IS NOT NULL
|
|
AND trim(t.merchant_name) <> ''
|
|
AND t.date >= p_from_date
|
|
),
|
|
keyed AS (
|
|
-- All-digit/reference-only merchant labels normalize to '': no
|
|
-- identity, no pattern.
|
|
SELECT * FROM booked WHERE counterparty_key <> ''
|
|
),
|
|
totals AS (
|
|
SELECT
|
|
counterparty_key,
|
|
mode() WITHIN GROUP (ORDER BY merchant_name) AS display_name,
|
|
count(*)::bigint AS occurrences,
|
|
max(date) AS last_booked
|
|
FROM keyed
|
|
GROUP BY counterparty_key
|
|
),
|
|
dominant_category AS (
|
|
SELECT DISTINCT ON (counterparty_key)
|
|
counterparty_key,
|
|
category,
|
|
cnt
|
|
FROM (
|
|
SELECT counterparty_key, category, count(*)::bigint AS cnt
|
|
FROM keyed
|
|
WHERE category IS NOT NULL AND category <> 'uncategorized'
|
|
GROUP BY counterparty_key, category
|
|
) c
|
|
ORDER BY counterparty_key, cnt DESC, category
|
|
),
|
|
dominant_account AS (
|
|
SELECT DISTINCT ON (counterparty_key)
|
|
counterparty_key,
|
|
account_number
|
|
FROM (
|
|
SELECT b.counterparty_key, l.account_number, count(*)::bigint AS cnt
|
|
FROM keyed b
|
|
JOIN public.journal_entry_lines l ON l.journal_entry_id = b.journal_entry_id
|
|
WHERE l.account_number NOT LIKE '19%'
|
|
GROUP BY b.counterparty_key, l.account_number
|
|
) a
|
|
ORDER BY counterparty_key, cnt DESC, account_number
|
|
)
|
|
SELECT coalesce(
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'counterparty', t.display_name,
|
|
'counterparty_key', t.counterparty_key,
|
|
'occurrences', t.occurrences,
|
|
'last_booked', t.last_booked,
|
|
'dominant_category', dc.category,
|
|
'dominant_category_count', coalesce(dc.cnt, 0),
|
|
'dominant_account_number', da.account_number
|
|
)
|
|
ORDER BY t.occurrences DESC, t.display_name
|
|
),
|
|
'[]'::jsonb
|
|
)
|
|
FROM (
|
|
SELECT * FROM totals ORDER BY occurrences DESC, display_name LIMIT 25
|
|
) t
|
|
LEFT JOIN dominant_category dc ON dc.counterparty_key = t.counterparty_key
|
|
LEFT JOIN dominant_account da ON da.counterparty_key = t.counterparty_key
|
|
),
|
|
'supplier_patterns',
|
|
(
|
|
-- AP-side booking patterns: bank-transaction patterns only see rows
|
|
-- with a merchant_name, so an invoice-heavy company would be half
|
|
-- blind without this. Supplier identity here is exact (FK), no
|
|
-- normalization needed.
|
|
WITH sinv AS (
|
|
SELECT si.id, si.supplier_id, s.name AS supplier_name,
|
|
si.invoice_date, si.vat_treatment
|
|
FROM public.supplier_invoices si
|
|
JOIN public.suppliers s ON s.id = si.supplier_id
|
|
WHERE si.company_id = p_company_id
|
|
AND si.invoice_date >= p_from_date
|
|
-- Reversed bookings and credited invoices are undone business;
|
|
-- credit notes repeat their original's accounts with flipped sign.
|
|
AND si.status NOT IN ('reversed', 'credited')
|
|
AND si.is_credit_note = false
|
|
),
|
|
totals AS (
|
|
SELECT
|
|
supplier_id,
|
|
max(supplier_name) AS supplier_name,
|
|
count(*)::bigint AS invoices,
|
|
max(invoice_date) AS last_invoice,
|
|
mode() WITHIN GROUP (ORDER BY vat_treatment) AS dominant_vat
|
|
FROM sinv
|
|
GROUP BY supplier_id
|
|
),
|
|
dominant_account AS (
|
|
-- Invoices (not lines) touching each account, so a many-line invoice
|
|
-- does not outvote ten single-line ones.
|
|
SELECT DISTINCT ON (supplier_id)
|
|
supplier_id,
|
|
account_number,
|
|
cnt
|
|
FROM (
|
|
SELECT v.supplier_id, i.account_number, count(DISTINCT v.id)::bigint AS cnt
|
|
FROM sinv v
|
|
JOIN public.supplier_invoice_items i ON i.supplier_invoice_id = v.id
|
|
GROUP BY v.supplier_id, i.account_number
|
|
) a
|
|
ORDER BY supplier_id, cnt DESC, account_number
|
|
)
|
|
SELECT coalesce(
|
|
jsonb_agg(
|
|
jsonb_build_object(
|
|
'supplier', t.supplier_name,
|
|
'invoices', t.invoices,
|
|
'last_invoice', t.last_invoice,
|
|
'vat_treatment', t.dominant_vat,
|
|
'dominant_account_number', da.account_number,
|
|
'dominant_account_count', coalesce(da.cnt, 0)
|
|
)
|
|
ORDER BY t.invoices DESC, t.supplier_name
|
|
),
|
|
'[]'::jsonb
|
|
)
|
|
FROM (
|
|
SELECT * FROM totals ORDER BY invoices DESC, supplier_name LIMIT 15
|
|
) t
|
|
LEFT JOIN dominant_account da ON da.supplier_id = t.supplier_id
|
|
),
|
|
'vat_treatments_used',
|
|
(
|
|
SELECT coalesce(jsonb_agg(DISTINCT vt), '[]'::jsonb)
|
|
FROM (
|
|
SELECT i.vat_treatment AS vt
|
|
FROM public.invoices i
|
|
WHERE i.company_id = p_company_id
|
|
AND i.invoice_date >= p_from_date
|
|
AND i.vat_treatment IS NOT NULL
|
|
UNION
|
|
SELECT si.vat_treatment AS vt
|
|
FROM public.supplier_invoices si
|
|
WHERE si.company_id = p_company_id
|
|
AND si.invoice_date >= p_from_date
|
|
AND si.vat_treatment IS NOT NULL
|
|
) treatments
|
|
),
|
|
'median_booking_lag_days',
|
|
(
|
|
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY (je.entry_date - t.date))
|
|
FROM public.transactions t
|
|
JOIN public.journal_entries je ON je.id = t.journal_entry_id
|
|
WHERE t.company_id = p_company_id
|
|
AND je.status = 'posted'
|
|
AND t.date >= p_from_date
|
|
)
|
|
);
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.get_ledger_usage_stats(uuid, date) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.get_ledger_usage_stats(uuid, date) TO authenticated, service_role;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|