Files
accounted/supabase/migrations/20260708120000_ledger_stats_committed_at_lag.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

261 lines
9.9 KiB
PL/PgSQL

-- Fix: median_booking_lag_days measures real posting promptness (committed_at),
-- not the accounting date (entry_date).
--
-- The prior version computed median(entry_date - transaction.date). But the
-- bank-booking flow dates the voucher (entry_date) TO the transaction date, so
-- entry_date - date is ~0 by construction: a tautology, not a signal. Verified
-- on prod (company ed461bc1...): entry_date == transaction date for 151 of 152
-- booked transactions, so the median came out 0, while the REAL lag,
-- committed_at (when the voucher was actually posted) minus the transaction
-- date, had median 90 days. This switches the metric to committed_at, the
-- honest "how promptly does this company book" signal the field was meant to
-- be. committed_at is set by set_committed_at() on draft->posted and is
-- non-null for posted entries; the IS NOT NULL guard is belt-and-braces.
--
-- Everything else is unchanged from 20260708110000 (26xx dominant-contra
-- exclusion, storno filter, normalize_counterparty_key keying). Full
-- CREATE OR REPLACE, no signature change.
--
-- pg-test: tests/pg/ledger-usage-stats-rpc.pg.test.ts
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%'
-- Exclude VAT accounts (26xx): on a reverse-charge purchase the
-- expense, 2645 and 2614 lines tie, and the account_number
-- tiebreak would otherwise pick the low VAT number over the
-- expense. 26xx is always moms, never the informative contra.
AND l.account_number NOT LIKE '26%'
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',
(
-- committed_at (when the voucher was POSTED), not entry_date (the
-- accounting date the bank flow sets to the transaction date, making
-- entry_date - date a ~0 tautology). This is real posting promptness.
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY (je.committed_at::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'
-- A legacy transaction still linked to a storno (a reversal posted
-- long after the transaction) would inject a spurious large lag into
-- the very metric this migration exists to make honest. Exclude it,
-- matching account_usage and counterparty_patterns.
AND je.source_type <> 'storno'
AND je.committed_at IS NOT NULL
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';