Files
accounted/supabase/migrations/20260708130000_ledger_deep_context.sql
T
Mattsson 8dde46ad96 fix(db): reconcile prod-orphaned migrations blocking Supabase branching (#942)
* fix(db): reconcile prod-orphaned migrations blocking Supabase branching

Prod's schema_migrations carries three versions with no committed file on
main, leaving the default Supabase branch in MIGRATIONS_FAILED and stopping
preview branches from being created:

  20260707113729  add_transactions_enrichment    (adopted from #927)
  20260708120000  ledger_stats_committed_at_lag  (adopted from #935)
  20260708130000  ledger_deep_context            (adopted from #935)

Adopt the byte-identical SQL under the exact apply-time versions, plus the
matching pg-tests and fixtures for the two RPCs so pg-real stays green:
20260708120000 switches get_ledger_usage_stats' median_booking_lag_days to
committed_at, so the existing test now asserts the new behavior. Idempotent
(ADD COLUMN IF NOT EXISTS / CREATE OR REPLACE FUNCTION): no-op on prod,
clean on fresh replays, no-op on #927/#935's next rebase. The knowledge-page
UI/lib/i18n stay in #935.

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

* fix(deps): pin @anthropic-ai/bedrock-sdk to 0.29.1

0.32.0 (grouped dependabot bump #884) broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks", taking down the in-app AI assistant and invoice OCR. Local dev ran the stale 0.29.1 in node_modules, so it only failed on deploys built fresh from the lockfile. Revert to the six-week-stable 0.29.1; creds/region were never the cause (proven AKIA key + eu-west-1).

Guard against an accidental re-bump three ways: exact pin (no caret), a dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock. See DECISIONS.md.
2026-07-08 23:54:49 +02:00

212 lines
9.0 KiB
PL/PgSQL

-- RPC: get_ledger_deep_context — the deep, entity-resolved analysis behind the
-- "Vad din agent vet" page. Where get_ledger_usage_stats is the light,
-- 12-month digest the agent reads at session start, THIS is the full-history
-- deep pass: it merges counterparties across name variants, mines the booked
-- verifikat for real spend, and detects recurrence.
--
-- For each counterparty entity (bank-feed side) and supplier entity (AP side)
-- it returns:
-- name display label (modal raw variant)
-- key the normalized identity key (counterparties) / supplier id
-- variants up to 8 distinct raw labels that merged into this entity
-- variant_count true number of distinct raw labels
-- occurrences number of bookings
-- total_amount total paid (gross, sum of abs bank amount / invoice total)
-- first_seen/last_seen
-- cadence_days median gap between distinct booking dates (null if <2)
-- dominant_account_number + dominant_account_share (consistency 0..1)
-- dominant_vat (supplier side; counterparties carry it via the light RPC)
--
-- Determinism: entities merge by normalize_counterparty_key() (the SQL mirror
-- of normalizeCounterpartyName), which collapses "KLARNA AB" / "SWISH KLARNA
-- AB" / "KORTKÖP KLARNA AB 2026-07-01" into one "klarna". Cross-name merges
-- that need the counterparty bank account or fuzzy matching ("Klarna" +
-- "Klarna Bank AB") are the persistent-entity-table layer
-- (bank_transaction_ai_normalization.md, deferred); this is the deterministic
-- read-side v1.
--
-- Storno excluded (source_type <> 'storno'); corrections kept (they are the
-- live booking, the transaction is relinked to them). 26xx VAT and 19xx bank
-- are excluded from the dominant contra pick, same asymmetry as the light RPC.
--
-- p_from_date NULL = full history; a date bounds the lookback (large tenants
-- pass a bound; the cache layer is deferred, same "measure before enforce"
-- posture as the light RPC).
--
-- SECURITY INVOKER: RLS on the base tables scopes to the caller's company.
--
-- pg-test: tests/pg/ledger-deep-context-rpc.pg.test.ts
CREATE OR REPLACE FUNCTION public.get_ledger_deep_context(
p_company_id uuid,
p_from_date date DEFAULT NULL
)
RETURNS jsonb
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path TO 'public'
AS $$
SELECT jsonb_build_object(
'counterparty_entities',
(
WITH booked AS (
SELECT
public.normalize_counterparty_key(t.merchant_name) AS k,
t.merchant_name,
t.journal_entry_id,
t.date,
-- SEK: amount_sek carries the converted value on foreign-currency
-- rows and is null on SEK rows, so coalesce keeps everything in SEK
-- (mirrors the supplier side's coalesce(total_sek, total)).
abs(coalesce(t.amount_sek, t.amount)) AS amount
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'
AND je.source_type <> 'storno'
AND t.merchant_name IS NOT NULL
AND trim(t.merchant_name) <> ''
AND (p_from_date IS NULL OR t.date >= p_from_date)
),
keyed AS (SELECT * FROM booked WHERE k <> ''),
-- Median gap between distinct booking dates -> recurrence cadence.
distinct_dates AS (SELECT DISTINCT k, date FROM keyed),
gaps AS (
SELECT k, (date - lag(date) OVER (PARTITION BY k ORDER BY date)) AS gap
FROM distinct_dates
),
recur AS (
SELECT k, round(percentile_cont(0.5) WITHIN GROUP (ORDER BY gap))::int AS cadence_days
FROM gaps WHERE gap IS NOT NULL GROUP BY k
),
-- Dominant contra account + its share, over the entity's verifikat lines.
acct_counts AS (
SELECT b.k, 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%'
AND l.account_number NOT LIKE '26%'
GROUP BY b.k, l.account_number
),
acct_totals AS (SELECT k, sum(cnt) AS total FROM acct_counts GROUP BY k),
dominant_account AS (
SELECT DISTINCT ON (ac.k) ac.k, ac.account_number, ac.cnt, at.total
FROM acct_counts ac JOIN acct_totals at ON at.k = ac.k
ORDER BY ac.k, ac.cnt DESC, ac.account_number
),
agg AS (
SELECT
k,
mode() WITHIN GROUP (ORDER BY merchant_name) AS display_name,
count(*)::bigint AS occurrences,
count(DISTINCT merchant_name)::int AS variant_count,
(array_agg(DISTINCT merchant_name))[1:8] AS variants,
sum(amount) AS total_amount,
min(date) AS first_seen,
max(date) AS last_seen
FROM keyed GROUP BY k
)
SELECT coalesce(
jsonb_agg(
jsonb_build_object(
'name', a.display_name,
'key', a.k,
'variants', to_jsonb(a.variants),
'variant_count', a.variant_count,
'occurrences', a.occurrences,
'total_amount', round(a.total_amount)::bigint,
'first_seen', a.first_seen,
'last_seen', a.last_seen,
'cadence_days', r.cadence_days,
'dominant_account_number', da.account_number,
'dominant_account_share',
CASE WHEN da.total > 0 THEN round(da.cnt::numeric / da.total, 2) ELSE NULL END
)
ORDER BY a.occurrences DESC, a.total_amount DESC, a.display_name
),
'[]'::jsonb
)
FROM (SELECT * FROM agg ORDER BY occurrences DESC, total_amount DESC, display_name LIMIT 40) a
LEFT JOIN recur r ON r.k = a.k
LEFT JOIN dominant_account da ON da.k = a.k
),
'supplier_entities',
(
WITH sinv AS (
SELECT si.id, si.supplier_id, s.name AS supplier_name,
si.invoice_date, si.vat_treatment,
coalesce(si.total_sek, si.total, 0) AS amount
FROM public.supplier_invoices si
JOIN public.suppliers s ON s.id = si.supplier_id
WHERE si.company_id = p_company_id
AND si.status NOT IN ('reversed', 'credited')
AND si.is_credit_note = false
AND (p_from_date IS NULL OR si.invoice_date >= p_from_date)
),
distinct_dates AS (SELECT DISTINCT supplier_id, invoice_date FROM sinv),
gaps AS (
SELECT supplier_id,
(invoice_date - lag(invoice_date) OVER (PARTITION BY supplier_id ORDER BY invoice_date)) AS gap
FROM distinct_dates
),
recur AS (
SELECT supplier_id, round(percentile_cont(0.5) WITHIN GROUP (ORDER BY gap))::int AS cadence_days
FROM gaps WHERE gap IS NOT NULL GROUP BY supplier_id
),
acct_counts AS (
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
),
acct_totals AS (SELECT supplier_id, sum(cnt) AS total FROM acct_counts GROUP BY supplier_id),
dominant_account AS (
SELECT DISTINCT ON (ac.supplier_id) ac.supplier_id, ac.account_number, ac.cnt, at.total
FROM acct_counts ac JOIN acct_totals at ON at.supplier_id = ac.supplier_id
ORDER BY ac.supplier_id, ac.cnt DESC, ac.account_number
),
agg AS (
SELECT
supplier_id,
max(supplier_name) AS supplier_name,
count(*)::bigint AS occurrences,
sum(amount) AS total_amount,
min(invoice_date) AS first_seen,
max(invoice_date) AS last_seen,
mode() WITHIN GROUP (ORDER BY vat_treatment) AS dominant_vat
FROM sinv GROUP BY supplier_id
)
SELECT coalesce(
jsonb_agg(
jsonb_build_object(
'name', a.supplier_name,
'key', a.supplier_id::text,
'variants', to_jsonb(ARRAY[a.supplier_name]),
'variant_count', 1,
'occurrences', a.occurrences,
'total_amount', round(a.total_amount)::bigint,
'first_seen', a.first_seen,
'last_seen', a.last_seen,
'cadence_days', r.cadence_days,
'dominant_account_number', da.account_number,
'dominant_account_share',
CASE WHEN da.total > 0 THEN round(da.cnt::numeric / da.total, 2) ELSE NULL END,
'dominant_vat', a.dominant_vat
)
ORDER BY a.occurrences DESC, a.total_amount DESC, a.supplier_name
),
'[]'::jsonb
)
FROM (SELECT * FROM agg ORDER BY occurrences DESC, total_amount DESC, supplier_name LIMIT 20) a
LEFT JOIN recur r ON r.supplier_id = a.supplier_id
LEFT JOIN dominant_account da ON da.supplier_id = a.supplier_id
)
);
$$;
REVOKE ALL ON FUNCTION public.get_ledger_deep_context(uuid, date) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_ledger_deep_context(uuid, date) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';