Per-account bank reconciliation + overdue/inbox/privacy fixes (#619)
* feat(reconciliation): scope bank reconciliation per cash account via transactions.cash_account_id A company with two same-currency cash accounts (e.g. checking 1930 + a savings account) saw every SEK transaction on every account, and the status card summed across both — reconciliation filtered transactions by CURRENCY while filtering GL lines by ACCOUNT (issue #604). Bind each bank transaction to the cash_accounts row it settled on: - New nullable transactions.cash_account_id FK (ON DELETE SET NULL — a bank transaction is räkenskapsinformation, BFL 7 kap, and must survive cash-account deletion) + a best-effort 4-pass backfill. - All reconciliation/transaction queries scope to the selected account with a NULL->currency fallback, so legacy/un-backfilled rows never disappear mid-backfill. - ingestTransactions stamps cash_account_id from the batch's settlementAccount; categorize + manualLink resolve and use it. - Bank leg now books to the transaction's actual settlement account via applySettlementAccount (no-op for 1930), so interest/fees on a savings/EUR account reconcile instead of mis-booking to 1930. - manualLink cross-checks the transaction's account and requires a voucher line on the selected account (no silent cross-account links). - BankReconciliationView: quick-book menu for any settlement account, in-flight request abort on account/date switch, 500-row truncation notice, per-account state reset. - pg-real coverage for the FK, all backfill passes, account-scoped query isolation, and cross-company isolation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): stop marking paid invoices and credit notes as overdue update_overdue_supplier_invoices() (the daily pg_cron job) flipped every past-due 'registered'/'approved' row to 'overdue' without looking at the outstanding balance. Credit notes — created 'registered', remaining 0, due today — got flipped the next day, surfacing as "Förfallen" with "kvar att betala 0 kr"; so did any fully-paid invoice left in 'registered'/'approved'. Guard the cron on remaining_amount > 0.005 (the "fully paid" threshold used by the payment/match paths) and is_credit_note = false, and backfill the rows already mis-flagged (credit notes -> 'registered', paid -> 'paid' with paid_at stamped only when missing). pg-real coverage for the guarded function and the one-off backfill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): refresh dokumentinkorg on realtime row changes The InvoiceInboxWorkspace only refetched on mount and on explicit in-component actions. When an inbox item was resolved out of band — the in-app agent sheet committing a staged create_supplier_invoice_from_inbox / book-direct op, the /pending page approving one, or another tab booking it — none of those paths called fetchItems(), so the booked underlag stayed in "Att göra" until a manual reload (issue #600). Add invoice_inbox_items to the supabase_realtime publication (mirrors the /pending fix in 20260520120100) and subscribe in the workspace, refetching the whole list on any change so derived status/counts/ordering stay authoritative. RLS scopes the channel to the user's company. fetchItems now preserves optimistic upload placeholders so a refetch firing mid-upload can't drop an in-flight row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(privacy): disclose EU AI inference via Amazon Bedrock (eu-north-1) Update the privacy policy and DPA to state that AI inference, when AI features are enabled, runs inside the EU via Amazon Bedrock (eu-north-1, Stockholm) using Anthropic's Claude models — no transfer to a third country, prompts not retained after the call or used for model training. Add AWS as a subprocessor row and refresh the "last updated" dates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): rename invoice_inbox_realtime to avoid version collision main's #617 shipped 20260605120000_transactions_original_description.sql — the same version this branch used for the inbox-realtime publication. The Supabase migration tracker keys on the numeric version, not the filename, so the preview branch failed with a duplicate-key error on supabase_migrations.schema_migrations (version 20260605120000 already exists). Rename to the unique version 20260605120500; the body (ALTER PUBLICATION) is order-independent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reconciliation): align run guard with status; harden filter interpolation Addresses PR review (greptile + compliance swarm): - The v1 and core bank/run routes rejected an unknown account uniformly, including the default '1930', while the status routes were lenient for '1930'. A company reconciling its primary SEK account without a cash_accounts row got 200 from status but 400 from run. Make run match status: '1930' falls back to currency-only scoping (cashAccountId undefined); non-default unknown accounts are still rejected. Adds a test. - /api/transactions accepts a user-supplied `currency` query param that was interpolated raw into a PostgREST .or() filter. Reject anything that isn't a 3-letter ISO code — RLS already scopes to the company, but an unsanitized value could otherwise malform/widen the filter. Assert currency/cashAccountId shape in scopeTransactionsToAccount as well. - categorize: log (instead of silently swallowing) a cash_accounts settlement-account lookup error, so a fall-back-to-1930 mis-booking is observable in the audit log. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): correct backfill UPDATE..FROM join; idempotent realtime publication Two SQL errors that only surface on real Postgres (CI pg-real + Supabase preview) — the unit suite mocks Supabase, so neither was caught locally. - Backfill pass (a): `UPDATE transactions t ... FROM journal_entry_lines jel JOIN cash_accounts ca ON ca.company_id = t.company_id` referenced the UPDATE target `t` inside the FROM join's ON clause, which Postgres rejects ("invalid reference to FROM-clause entry for table t"). Move the company match to WHERE; the JOIN now relates jel<->ca only. Semantics unchanged. - invoice_inbox_realtime: `ALTER PUBLICATION ... ADD TABLE` is not idempotent (SQLSTATE 42710 if the table is already a member). The earlier version-collision push partially applied it on the Supabase preview branch, so the re-apply errored. Guard with a pg_publication_tables existence check. Both statements validated against a real Postgres: the single-line tx binds, the two-bank-line transfer stays NULL, and the publication add runs twice cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(migrations): backfill pass (c) uses array_agg, not min(uuid) Postgres has no min() aggregate for uuid, so pass (c)'s min(id) raised "function min(uuid) does not exist" on apply (CI pg-real + Supabase). The HAVING count(*) = 1 already guarantees one row per group, so (array_agg(id))[1] returns that single id. Validated the full backfill (all four passes) and the overdue migration against a real Postgres: every pass binds / falls through as intended, and the overdue guard + backfill produce the right statuses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(compliance): add RoPA entry for Amazon Bedrock AI inference (GDPR Art.30) The privacy policy now discloses AI inference (transaction categorization + document/receipt OCR) via Amazon Bedrock as a processing activity, but .compliance/ropa.yaml had no matching Art.30 record. Add it: opt-in consent basis, EU-region (eu-north-1) inference with no third-country transfer, prompts not retained or used for model training. Mirrors the privacy-page disclosure shipped in this PR. 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
c6c86cded4
commit
953980c875
@@ -0,0 +1,39 @@
|
||||
-- Migration: stream invoice_inbox_items changes via Supabase realtime
|
||||
--
|
||||
-- The dokumentinkorg (InvoiceInboxWorkspace) only refetched on mount and on
|
||||
-- explicit in-component actions. When an inbox item was resolved "out of
|
||||
-- band" — the in-app agent sheet committing a staged
|
||||
-- create_supplier_invoice_from_inbox / book-direct operation, the /pending
|
||||
-- approval page committing one, or another browser tab booking it — none of
|
||||
-- those paths call the component's fetchItems(). The booked underlag stayed
|
||||
-- in "Att göra" until the user manually reloaded the page (issue #600).
|
||||
-- Adding the table to supabase_realtime lets the browser subscribe via
|
||||
-- supabase.channel('postgres_changes') and refresh as the
|
||||
-- created_supplier_invoice_id / created_journal_entry_id FKs land.
|
||||
--
|
||||
-- This mirrors 20260520120100_pending_ops_realtime_publication.sql, which
|
||||
-- fixed the identical staleness on the /pending page.
|
||||
--
|
||||
-- RLS already restricts invoice_inbox_items to company members
|
||||
-- (20260223150836_invoice_inbox.sql, refreshed by the multi-tenant refactor
|
||||
-- in 20260330130000), and realtime respects the same row-level access — a
|
||||
-- member of company A only receives change events for rows where their RLS
|
||||
-- predicate evaluates true. Default replica identity (primary key) is
|
||||
-- sufficient: the client only refetches, never inspecting the old/new record.
|
||||
|
||||
-- Idempotent: ALTER PUBLICATION ... ADD TABLE errors if the table is already a
|
||||
-- member (SQLSTATE 42710). Guard so a re-apply is a no-op — e.g. a Supabase
|
||||
-- preview branch that partially applied an earlier revision of this migration
|
||||
-- (the ALTER ran, but the schema_migrations bookkeeping insert failed), leaving
|
||||
-- the table already in the publication on the next attempt.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime'
|
||||
AND schemaname = 'public'
|
||||
AND tablename = 'invoice_inbox_items'
|
||||
) THEN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.invoice_inbox_items;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Migration: transactions.cash_account_id
|
||||
--
|
||||
-- Binds each bank transaction to the specific cash account (cash_accounts row)
|
||||
-- it settled on. Until now `transactions` only carried `currency` +
|
||||
-- `bank_connection_id`, so the bank reconciliation (Rapporter → Bankavstämning)
|
||||
-- filtered transactions by CURRENCY while it filtered GL lines by ACCOUNT
|
||||
-- NUMBER. A company with two same-currency accounts (e.g. checking 1930 + a
|
||||
-- savings account on another SEK code) therefore saw every SEK transaction on
|
||||
-- every account, and the status card summed across both — the reported
|
||||
-- "shows 1930 even when you switch / sums all transactions" bug (issue #604).
|
||||
--
|
||||
-- This is the cash_account_id FK that app/api/transactions/route.ts already
|
||||
-- referred to as "the cash_account_id backfill tracked as Tier 4".
|
||||
--
|
||||
-- Nullable on purpose: legacy rows are backfilled best-effort in the paired
|
||||
-- 20260606120100_transactions_cash_account_id_backfill.sql migration, and any
|
||||
-- row that can't be resolved stays NULL. All reconciliation queries treat a
|
||||
-- NULL cash_account_id as "matches the selected account's currency" so nothing
|
||||
-- ever disappears from a report mid-backfill.
|
||||
--
|
||||
-- ON DELETE SET NULL — never CASCADE: a bank transaction is räkenskaps-
|
||||
-- information (BFL 7 kap) and must survive the deletion of a cash account.
|
||||
-- Never RESTRICT: cash accounts are user-disable-able (and occasionally
|
||||
-- deletable); the FK must not block that. In practice cash accounts are
|
||||
-- disabled, not hard-deleted, so SET NULL is an edge path that degrades into
|
||||
-- the same currency fallback as an un-backfilled row.
|
||||
|
||||
ALTER TABLE public.transactions
|
||||
ADD COLUMN IF NOT EXISTS cash_account_id UUID
|
||||
REFERENCES public.cash_accounts(id) ON DELETE SET NULL;
|
||||
|
||||
-- Partial index — in steady state most rows are bound, and the hot
|
||||
-- reconciliation query is "unmatched rows for account X". The non-NULL slice
|
||||
-- is exactly what that query needs and keeps the index small during the
|
||||
-- transition while most rows are still NULL.
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_cash_account
|
||||
ON public.transactions (company_id, cash_account_id)
|
||||
WHERE cash_account_id IS NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Migration: backfill transactions.cash_account_id
|
||||
--
|
||||
-- Best-effort population of the cash_account_id added in
|
||||
-- 20260606120000_transactions_cash_account_id.sql. Four passes, in descending
|
||||
-- order of authority. Every pass touches ONLY rows that are still NULL, so the
|
||||
-- migration is idempotent and safe to re-run / resume after an interruption.
|
||||
--
|
||||
-- Rows that no pass resolves stay NULL — reconciliation queries fall back to
|
||||
-- currency matching for those, exactly as before this feature, so nothing
|
||||
-- disappears from any report.
|
||||
|
||||
-- Pass (a) — Booked rows via the voucher's bank line. Most authoritative:
|
||||
-- reflects where the money was actually booked. Map the journal entry's single
|
||||
-- 19xx line to a cash account by (company_id, ledger_account).
|
||||
--
|
||||
-- Vouchers with MORE than one bank-class (19xx) line are own-account transfers
|
||||
-- (e.g. 1930 → 1931) and are ambiguous — which leg is "this transaction"? We
|
||||
-- deliberately skip them (leave NULL) rather than guess wrong.
|
||||
UPDATE public.transactions t
|
||||
SET cash_account_id = ca.id
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.cash_accounts ca
|
||||
ON ca.ledger_account = jel.account_number
|
||||
WHERE t.cash_account_id IS NULL
|
||||
AND t.journal_entry_id IS NOT NULL
|
||||
AND jel.journal_entry_id = t.journal_entry_id
|
||||
-- Relate the cash account to the target by company in WHERE, not in the JOIN
|
||||
-- ON above: Postgres forbids referencing the UPDATE target (t) from a
|
||||
-- FROM-clause join condition ("invalid reference to FROM-clause entry for t").
|
||||
AND ca.company_id = t.company_id
|
||||
AND jel.account_number BETWEEN '1900' AND '1999'
|
||||
AND (
|
||||
SELECT count(*)
|
||||
FROM public.journal_entry_lines x
|
||||
WHERE x.journal_entry_id = t.journal_entry_id
|
||||
AND x.account_number BETWEEN '1900' AND '1999'
|
||||
) = 1;
|
||||
|
||||
-- Pass (b) — PSD2 rows via the owned-account identity embedded in external_id.
|
||||
-- Enable Banking writes external_id = 'eb_<iban|uid>_<txid>'
|
||||
-- (extensions/general/enable-banking/lib/sync.ts). Match the prefix against the
|
||||
-- cash account's iban or external_uid. The trailing '_' in the prefix makes
|
||||
-- this an exact account match (prevents 'SE111' matching 'SE1112…'), and
|
||||
-- starts_with avoids LIKE wildcard/metacharacter ambiguity entirely.
|
||||
UPDATE public.transactions t
|
||||
SET cash_account_id = ca.id
|
||||
FROM public.cash_accounts ca
|
||||
WHERE t.cash_account_id IS NULL
|
||||
AND ca.company_id = t.company_id
|
||||
AND t.external_id IS NOT NULL
|
||||
AND (
|
||||
(ca.iban IS NOT NULL
|
||||
AND starts_with(t.external_id, 'eb_' || ca.iban || '_'))
|
||||
OR
|
||||
(ca.external_uid IS NOT NULL
|
||||
AND starts_with(t.external_id, 'eb_' || ca.external_uid || '_'))
|
||||
);
|
||||
|
||||
-- Pass (c) — Single-account-of-currency fallback. If a company has exactly one
|
||||
-- ENABLED cash account in the row's currency, the row unambiguously belongs to
|
||||
-- it. Resolves the single-account majority (incl. CSV imports, which carry no
|
||||
-- account identity). Deliberately does NOT fire for the 2-same-currency case
|
||||
-- (HAVING count(*) = 1) — those rows stay NULL and rely on passes (a)/(b) or
|
||||
-- on the currency fallback at query time. We must not guess between checking
|
||||
-- and savings.
|
||||
WITH single_ca AS (
|
||||
-- (array_agg(id))[1], not min(id): Postgres has no min() aggregate for uuid.
|
||||
-- HAVING count(*) = 1 guarantees exactly one row per group, so the array has
|
||||
-- a single element and which one we pick is moot.
|
||||
SELECT company_id, currency, (array_agg(id))[1] AS cash_account_id
|
||||
FROM public.cash_accounts
|
||||
WHERE enabled = true
|
||||
GROUP BY company_id, currency
|
||||
HAVING count(*) = 1
|
||||
)
|
||||
UPDATE public.transactions t
|
||||
SET cash_account_id = s.cash_account_id
|
||||
FROM single_ca s
|
||||
WHERE t.cash_account_id IS NULL
|
||||
AND s.company_id = t.company_id
|
||||
AND s.currency = t.currency;
|
||||
|
||||
-- Pass (d) — anything still NULL is left as-is (query-time currency fallback).
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
-- Migration: supplier_invoice_overdue_skip_paid_and_credit_notes
|
||||
--
|
||||
-- Fix: supplier invoices showing "Förfallen" (overdue) even though
|
||||
-- "kvar att betala" (remaining_amount) is 0 kr.
|
||||
--
|
||||
-- Root cause: update_overdue_supplier_invoices() (the daily pg_cron job from
|
||||
-- 20260303145744_supplier_invoice_overdue_cron.sql) flipped EVERY row past its
|
||||
-- due_date whose status was 'registered'/'approved' to 'overdue', without ever
|
||||
-- looking at the outstanding balance.
|
||||
--
|
||||
-- Credit notes (is_credit_note = true) are created with status='registered',
|
||||
-- remaining_amount=0 and due_date=today (see the supplier-invoice credit
|
||||
-- routes). A credit note is not a payable — there is nothing to pay and nothing
|
||||
-- to fall due — but because it sits in 'registered' with a due_date of today,
|
||||
-- the cron turned it 'overdue' the very next day. The same happens to any
|
||||
-- regular invoice that was fully paid but left in 'registered'/'approved'.
|
||||
--
|
||||
-- Two parts:
|
||||
-- 1. Guard the cron so a row with no outstanding balance, or a credit note,
|
||||
-- is never marked overdue.
|
||||
-- 2. Backfill the rows already mis-flagged.
|
||||
|
||||
-- 1. Guarded cron function ---------------------------------------------------
|
||||
-- CREATE OR REPLACE rewrites the whole definition, so re-declare the
|
||||
-- search_path that 20260304191528_set_search_path_on_functions.sql pinned.
|
||||
CREATE OR REPLACE FUNCTION public.update_overdue_supplier_invoices()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE supplier_invoices
|
||||
SET status = 'overdue',
|
||||
updated_at = NOW()
|
||||
WHERE due_date < CURRENT_DATE
|
||||
AND status IN ('registered', 'approved')
|
||||
-- Nothing left to pay -> cannot be overdue. 0.005 mirrors the
|
||||
-- "fully paid" threshold used by the payment/match paths.
|
||||
AND remaining_amount > 0.005
|
||||
-- Credit notes (kreditfakturor) are not payables.
|
||||
AND COALESCE(is_credit_note, false) = false;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 2. Backfill rows already mis-flagged by the old function -------------------
|
||||
-- Credit notes wrongly flipped to 'overdue' return to 'registered' (their
|
||||
-- resting state — there is no payment flow that advances a credit note).
|
||||
UPDATE public.supplier_invoices
|
||||
SET status = 'registered',
|
||||
updated_at = NOW()
|
||||
WHERE status = 'overdue'
|
||||
AND COALESCE(is_credit_note, false) = true;
|
||||
|
||||
-- Regular invoices that are fully paid but stuck on 'overdue' are 'paid'.
|
||||
-- paid_at is only stamped when it was missing, so a real payment timestamp is
|
||||
-- never overwritten.
|
||||
UPDATE public.supplier_invoices
|
||||
SET status = 'paid',
|
||||
paid_at = COALESCE(paid_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE status = 'overdue'
|
||||
AND COALESCE(is_credit_note, false) = false
|
||||
AND remaining_amount <= 0.005;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user