Fix/fdb fr usrs (#1125)
* fix(invoices): return attachment filename in delivery history summaries The 20260723003000 hardening dropped attachment_filename from list_invoice_delivery_summaries, so the delivery history UI always fell back to the generic "faktura.pdf" label. Recreate the RPC with the filename included: it is derived from company name, customer name, invoice number, and date, all already visible to every company member, so the minimization boundary is unchanged. Addresses stay masked and message content, BCC, and checksums stay server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): surface own-account transfer legs in match-to-voucher by default The second (incoming) leg of a transfer between two of the company's own bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog because the voucher counted as 'already matched' once its outgoing leg was linked, even though the incoming account's line had no settling transaction. Users read the empty default list as 'the app won't let me link this'. get_account_gl_lines_for_matching now counts links per settlement account: a transaction provably on another cash account no longer marks the voucher as matched for the requested account, so the unsettled transfer leg surfaces by default (and auto-selects on an exact match). Same-account N:1 stays behind the 'Visa aven matchade verifikationer' opt-in, and transactions without a resolvable cash account conservatively keep counting everywhere. get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile). Companion guard: mark_entry_as_opening_balance now refuses entries with linked bank transactions, since half-settled transfer vouchers became reachable in the reconciliation view's unmatched table where 'Mark som IB' renders; re-tagging one would strand its transaction against a movement- excluded entry. getReconciliationStatus counts unmatched GL lines with the account-scoped RPC so the status card agrees with the table. Fixes #1026 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of requests over 300ms. Target: p95 under 300ms. - requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of a second network getUser per request; getUser fallback keeps HS256 self-hosted and existing test mocks working; middleware still revocation-checks every /api request - resolve_active_company RPC (20260723161000): one round trip replaces 2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall back to the legacy query path - arsredovisning build-data: ~33 sequential round trips down to ~7, output byte-identical (snapshot-proven) - currency rate route: stop bypassing the exchange_rates cache (missing supabase arg caused an external Riksbanken call on every request) - document.get: parallelize row fetch, signed URL and audit event - list_company_accounts RPC (20260723170000): accounts list in one round trip instead of paging past PostgREST's 1000-row cap - vat-declaration route: drop a dead sequential company_settings query - get_kpi_report_aggregates RPC (20260723180000): KPI report's three full-period line scans collapsed into one aggregate call; dimension- filtered path unchanged - lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to warn, zero the eslint baseline ratchet All four gates green: lint 0 errors, 9163 tests, check:guards, build. Migrations applied idempotently to staging only; prod receives them via Supabase branching on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): resolve PR review findings across auth, VAT declaration, and IB retag - requireAuth getClaims fast path: pin iss (project URL) and aud ('authenticated'), log every fallback to getUser (ASVS V9.1 finding) - remove the ignored accountingMethod parameter from calculateVatDeclaration and the dead company_settings.accounting_method reads in xlsx/pdf/eskd routes; v1 API keeps accepting the query param but documents it as a no-op - close the mark_entry_as_opening_balance TOCTOU race with a transactions trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests; applied to staging and smoke-verified both directions - re-add the 42501 tenant guard to branch-local migration 20260723160000 (function body had silently reverted to the pre-20260619130100 definition) - document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt opening balance per BFNAR 2012:1 ch.29) - add KPI VAT-liability test covering reduced-rate output accounts 2621/2631 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard The re-added tenant guard carried the pre-20260703180000 raw NOT IN (SELECT user_company_ids()) pattern, which the null-safe-tenant-guards ratchet blocks. Staging re-synced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
43f7ccab9e
commit
288915c152
@@ -0,0 +1,91 @@
|
||||
-- The delivery summary hid attachment_filename, so the UI could only show a
|
||||
-- generic "faktura.pdf" label for the archived send snapshot. The filename is
|
||||
-- derived from company name, customer name, invoice number, and date: the same
|
||||
-- information the invoice itself already exposes to every company member, so
|
||||
-- returning it does not widen the minimization boundary set in 20260723003000.
|
||||
-- Addresses stay masked; message content, BCC, and checksums stay server-side.
|
||||
|
||||
DROP FUNCTION IF EXISTS public.list_invoice_delivery_summaries(uuid, uuid);
|
||||
|
||||
CREATE FUNCTION public.list_invoice_delivery_summaries(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id uuid,
|
||||
channel text,
|
||||
status text,
|
||||
to_addresses text[],
|
||||
cc_addresses text[],
|
||||
provider text,
|
||||
error_code text,
|
||||
document_attachment_id uuid,
|
||||
attachment_filename text,
|
||||
sent_at timestamptz,
|
||||
failed_at timestamptz,
|
||||
created_at timestamptz
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL
|
||||
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid()
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION 'not authorized to list invoice delivery summaries'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
d.id,
|
||||
d.channel,
|
||||
d.status,
|
||||
ARRAY(
|
||||
SELECT CASE
|
||||
WHEN recipient.address ~ '^[^@]+@[^@]+$'
|
||||
THEN '***@' || split_part(recipient.address, '@', 2)
|
||||
ELSE '***'
|
||||
END
|
||||
FROM unnest(d.to_addresses) WITH ORDINALITY AS recipient(address, position)
|
||||
ORDER BY recipient.position
|
||||
),
|
||||
ARRAY(
|
||||
SELECT CASE
|
||||
WHEN recipient.address ~ '^[^@]+@[^@]+$'
|
||||
THEN '***@' || split_part(recipient.address, '@', 2)
|
||||
ELSE '***'
|
||||
END
|
||||
FROM unnest(d.cc_addresses) WITH ORDINALITY AS recipient(address, position)
|
||||
ORDER BY recipient.position
|
||||
),
|
||||
d.provider,
|
||||
d.error_code,
|
||||
d.document_attachment_id,
|
||||
d.attachment_filename,
|
||||
d.sent_at,
|
||||
d.failed_at,
|
||||
d.created_at
|
||||
FROM public.invoice_deliveries d
|
||||
WHERE d.company_id = p_company_id
|
||||
AND d.invoice_id = p_invoice_id
|
||||
AND d.status <> 'preparing'
|
||||
ORDER BY d.created_at DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) IS
|
||||
'Returns active-company invoice delivery status with masked To and CC addresses plus the attachment filename. Exact payload and BCC remain server-side.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,266 @@
|
||||
-- Migration: make get_account_gl_lines_for_matching count links per settlement
|
||||
-- account instead of per voucher (issue #1026).
|
||||
--
|
||||
-- An own-account transfer books ONE verifikat touching two cash accounts
|
||||
-- (e.g. credit 1940, debit 1930) while the bank feed delivers TWO transactions,
|
||||
-- one per account. Once the outgoing leg is matched, the voucher counted as
|
||||
-- "already matched" for every account, so the incoming leg's match dialog hid
|
||||
-- it behind the "Visa även matchade verifikationer" opt-in. From the incoming
|
||||
-- account's point of view the voucher is genuinely unmatched: its 1930 line has
|
||||
-- no transaction settling it. Users read the empty default list as "the app
|
||||
-- won't let me link this" (support case: two transfer legs stuck for a week).
|
||||
--
|
||||
-- Fix: linked_transaction_count now counts only transactions that are
|
||||
-- settle-relevant for p_account_number:
|
||||
-- * transactions whose cash account resolves to p_account_number, and
|
||||
-- * transactions with no resolvable cash account (legacy rows, NULL
|
||||
-- cash_account_id): they could belong to any account, so they keep
|
||||
-- counting everywhere. This preserves today's behavior for
|
||||
-- single-account companies where cash_account_id is often NULL.
|
||||
-- The p_include_matched=false default filter uses the same account-scoped
|
||||
-- check, so a transfer voucher's unsettled leg surfaces by default while a
|
||||
-- voucher already settled on THIS account (genuine N:1, e.g. a salary run paid
|
||||
-- in several transfers from one account) stays behind the opt-in toggle.
|
||||
--
|
||||
-- get_unlinked_gl_lines is deliberately untouched: it feeds auto-reconcile
|
||||
-- flows, where surfacing the transfer's second leg could auto-link ambiguous
|
||||
-- amounts. Manual matching keeps the human in the loop.
|
||||
--
|
||||
-- Signature, return shape, tenant guard (20260611140000) and grants
|
||||
-- (20260611130000) are unchanged.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_account_gl_lines_for_matching(
|
||||
p_company_id UUID,
|
||||
p_account_number TEXT DEFAULT '1930',
|
||||
p_date_from DATE DEFAULT NULL,
|
||||
p_date_to DATE DEFAULT NULL,
|
||||
p_include_matched BOOLEAN DEFAULT false
|
||||
)
|
||||
RETURNS TABLE (
|
||||
line_id UUID,
|
||||
journal_entry_id UUID,
|
||||
debit_amount NUMERIC,
|
||||
credit_amount NUMERIC,
|
||||
line_description TEXT,
|
||||
entry_date DATE,
|
||||
voucher_number INT,
|
||||
voucher_series TEXT,
|
||||
entry_description TEXT,
|
||||
source_type TEXT,
|
||||
linked_transaction_count INT
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT
|
||||
jel.id AS line_id,
|
||||
je.id AS journal_entry_id,
|
||||
jel.debit_amount,
|
||||
jel.credit_amount,
|
||||
jel.line_description,
|
||||
je.entry_date,
|
||||
je.voucher_number,
|
||||
je.voucher_series,
|
||||
je.description AS entry_description,
|
||||
je.source_type,
|
||||
-- Account-scoped: a transaction provably on ANOTHER cash account (its
|
||||
-- cash_accounts row resolves to a different ledger_account) does not make
|
||||
-- this voucher "matched" for p_account_number. A NULL / unresolvable cash
|
||||
-- account keeps counting for every account (conservative legacy behavior).
|
||||
(
|
||||
SELECT count(*)
|
||||
FROM public.transactions t
|
||||
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
|
||||
WHERE t.journal_entry_id = je.id
|
||||
AND t.company_id = p_company_id
|
||||
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
|
||||
)::int AS linked_transaction_count
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE jel.account_number = p_account_number
|
||||
AND je.company_id = p_company_id
|
||||
AND je.status = 'posted'
|
||||
AND je.source_type IS DISTINCT FROM 'opening_balance'
|
||||
AND je.source_type IS DISTINCT FROM 'storno'
|
||||
AND je.source_type IS DISTINCT FROM 'correction'
|
||||
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
|
||||
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
|
||||
AND (
|
||||
p_include_matched
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.transactions t
|
||||
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
|
||||
WHERE t.journal_entry_id = je.id
|
||||
AND t.company_id = p_company_id
|
||||
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
|
||||
)
|
||||
)
|
||||
-- Tenant guard: anon/authenticated may only read their own companies;
|
||||
-- service_role and direct/superuser access (no JWT role) bypass.
|
||||
AND (
|
||||
coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '')
|
||||
NOT IN ('anon', 'authenticated')
|
||||
OR je.company_id IN (SELECT public.user_company_ids())
|
||||
)
|
||||
ORDER BY je.entry_date, je.voucher_number;
|
||||
$$;
|
||||
|
||||
-- CREATE OR REPLACE preserves the function's ACL, but re-assert least privilege
|
||||
-- (20260611130000) so this migration stands alone if replayed on a fresh DB.
|
||||
REVOKE EXECUTE ON FUNCTION public.get_account_gl_lines_for_matching(uuid, text, date, date, boolean) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_account_gl_lines_for_matching(uuid, text, date, date, boolean) TO authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Guard mark_entry_as_opening_balance against entries with linked
|
||||
-- transactions.
|
||||
--
|
||||
-- Before the account-scoped semantics above, a voucher with ANY linked bank
|
||||
-- transaction never appeared in the reconciliation view's "Omatchade
|
||||
-- verifikationer" table, so the "Märk som IB" button was unreachable for it.
|
||||
-- A half-settled transfer voucher (manual/import source) now surfaces there,
|
||||
-- making the button clickable on an entry whose other leg is already matched.
|
||||
-- Re-tagging such an entry to 'opening_balance' would leave a live transaction
|
||||
-- pointing at an IB entry and drop the voucher out of the period movement while
|
||||
-- its transaction stays in the bank total: a permanent phantom difference.
|
||||
-- An entry with a bank-feed counterpart is by definition not an ingående
|
||||
-- balans, so refuse outright. Body otherwise verbatim from 20260619130100
|
||||
-- (NOT 20260613120000: the 130100 revision added the claims-based 42501
|
||||
-- tenant guard, which must survive this replace).
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.mark_entry_as_opening_balance(
|
||||
p_company_id uuid,
|
||||
p_entry_id uuid
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_caller_role text;
|
||||
v_entry record;
|
||||
v_is_closed boolean;
|
||||
v_locked_at timestamptz;
|
||||
v_has_bank_line boolean;
|
||||
v_old_source_type text;
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
BEGIN
|
||||
-- Tenant guard: anon/authenticated may only act on their own companies;
|
||||
-- service_role / direct access (no JWT role) bypasses BY DESIGN.
|
||||
-- NULL-safe membership predicate (20260703180000): the raw
|
||||
-- NOT IN (SELECT user_company_ids()) pattern skips the deny branch on
|
||||
-- UNKNOWN and is ratchet-blocked by null-safe-tenant-guards.pg.test.
|
||||
IF v_jwt_role IN ('anon', 'authenticated')
|
||||
AND NOT public.caller_is_company_member(p_company_id) THEN
|
||||
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- Owner/admin only (defense in depth alongside RLS; the function is SECURITY
|
||||
-- DEFINER so it must enforce tenancy + role itself).
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Only company owners and admins can re-tag opening balances';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_entry
|
||||
FROM journal_entries
|
||||
WHERE id = p_entry_id
|
||||
AND company_id = p_company_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_entry IS NULL THEN
|
||||
RAISE EXCEPTION 'Journal entry not found';
|
||||
END IF;
|
||||
|
||||
IF v_entry.status <> 'posted' THEN
|
||||
RAISE EXCEPTION 'Only posted entries can be re-tagged as opening balance (current status: %)', v_entry.status;
|
||||
END IF;
|
||||
|
||||
IF v_entry.source_type NOT IN ('manual', 'import') THEN
|
||||
RAISE EXCEPTION 'Only manual/import entries can be re-tagged as opening balance (current source_type: %)', v_entry.source_type;
|
||||
END IF;
|
||||
|
||||
-- Must touch a bank/cash account. Re-tagging excludes the WHOLE entry from the
|
||||
-- reconciliation period movement, so it must genuinely be a bank-account IB.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM journal_entry_lines l
|
||||
WHERE l.journal_entry_id = p_entry_id
|
||||
AND l.account_number IN ('1910','1920','1930','1931','1932','1940','1941','1950')
|
||||
) INTO v_has_bank_line;
|
||||
|
||||
IF NOT v_has_bank_line THEN
|
||||
RAISE EXCEPTION 'Entry does not touch a bank/cash account (19xx); refusing to tag as opening balance';
|
||||
END IF;
|
||||
|
||||
-- An entry with a linked bank transaction has a bank-feed counterpart and is
|
||||
-- therefore not an opening balance (a genuine IB predates the feed). It would
|
||||
-- also strand the linked transaction against an excluded entry, creating a
|
||||
-- permanent reconciliation difference. Unlink first if the tag is truly right.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM transactions t
|
||||
WHERE t.journal_entry_id = p_entry_id
|
||||
AND t.company_id = p_company_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Entry has linked bank transactions; unlink them before re-tagging as opening balance';
|
||||
END IF;
|
||||
|
||||
-- Respect period lock (mirror delete_last_voucher). enforce_period_lock would
|
||||
-- block the UPDATE anyway; we refuse first with a clearer message.
|
||||
SELECT is_closed, locked_at INTO v_is_closed, v_locked_at
|
||||
FROM fiscal_periods
|
||||
WHERE id = v_entry.fiscal_period_id;
|
||||
|
||||
IF v_is_closed THEN
|
||||
RAISE EXCEPTION 'Cannot re-tag an entry in a closed fiscal period';
|
||||
END IF;
|
||||
IF v_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot re-tag an entry in a locked fiscal period';
|
||||
END IF;
|
||||
|
||||
v_old_source_type := v_entry.source_type;
|
||||
|
||||
-- Transaction-local bypass consumed by the immutability carve-out
|
||||
-- (20260613120000).
|
||||
PERFORM set_config('gnubok.allow_source_type_retag', 'true', true);
|
||||
|
||||
UPDATE journal_entries
|
||||
SET source_type = 'opening_balance'
|
||||
WHERE id = p_entry_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
-- Provenance row (write_audit_log also logs old/new state via the AFTER trigger;
|
||||
-- this adds the human-readable reason, matching the delete_last_voucher pattern).
|
||||
INSERT INTO audit_log (user_id, company_id, action, table_name, record_id, actor_id, description)
|
||||
VALUES (
|
||||
v_entry.user_id,
|
||||
p_company_id,
|
||||
'UPDATE',
|
||||
'journal_entries',
|
||||
p_entry_id,
|
||||
auth.uid(),
|
||||
'Re-tagged source_type ' || v_old_source_type || ' -> opening_balance ' ||
|
||||
'(mark_entry_as_opening_balance RPC, caller: ' || auth.uid() || ')'
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'retagged', true,
|
||||
'entry_id', p_entry_id,
|
||||
'previous_source_type', v_old_source_type,
|
||||
'voucher_series', v_entry.voucher_series,
|
||||
'voucher_number', v_entry.voucher_number
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.mark_entry_as_opening_balance(uuid, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.mark_entry_as_opening_balance(uuid, uuid) TO authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Single-round-trip active-company resolution for the app layer.
|
||||
--
|
||||
-- WHY THIS MIGRATION EXISTS
|
||||
-- -------------------------
|
||||
-- lib/company/context.ts (getActiveCompanyId) and lib/supabase/middleware.ts
|
||||
-- (resolveCompanyForMiddleware) both resolve the active company with one
|
||||
-- parallel round trip (user_preferences + first membership) plus a SECOND,
|
||||
-- sequential validation round trip for every multi-company/consultant user.
|
||||
-- This function collapses the whole resolution into one RPC call.
|
||||
--
|
||||
-- INVARIANTS (do not break these when editing):
|
||||
-- 1. This function must stay SEMANTICALLY IDENTICAL to
|
||||
-- public.current_active_company_id() (20260702093000). RLS reads that
|
||||
-- function; the app reads this one. If they diverge, Next.js and RLS
|
||||
-- disagree about which company is active and tenant isolation desyncs.
|
||||
-- Resolution order, in both: validated preference (user_preferences.
|
||||
-- active_company_id backed by a live membership in a non-archived
|
||||
-- company) wins, else the earliest non-archived membership by
|
||||
-- company_members.created_at, else NULL.
|
||||
-- 2. NULL auth.uid() (service-role clients: createServiceClient /
|
||||
-- createServiceClientNoCookies are cookieless, so auth.uid() is NULL in
|
||||
-- RPCs) returns ZERO ROWS by design; the WHERE clause below produces
|
||||
-- that. JS callers treat zero rows as "use the query fallback path".
|
||||
-- 3. No table writes inside: middleware does its own conditional
|
||||
-- write-back using the used_fallback flag; read paths must stay reads.
|
||||
--
|
||||
-- used_fallback is true exactly when the validated preference is absent
|
||||
-- (no prefs row, NULL active_company_id, archived company, or membership
|
||||
-- gone), which is exactly the condition under which middleware writes the
|
||||
-- resolved company back to user_preferences today.
|
||||
--
|
||||
-- locale is NULL only when the user has no user_preferences row (the column
|
||||
-- itself is NOT NULL DEFAULT 'sv', 20260521120000), matching what the
|
||||
-- middleware's own query returns today.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.resolve_active_company()
|
||||
RETURNS TABLE(company_id uuid, locale text, used_fallback boolean)
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
WITH pref AS (
|
||||
SELECT up.active_company_id, up.locale
|
||||
FROM public.user_preferences up
|
||||
WHERE up.user_id = auth.uid()
|
||||
),
|
||||
validated AS (
|
||||
SELECT cm.company_id
|
||||
FROM pref p
|
||||
JOIN public.company_members cm
|
||||
ON cm.user_id = auth.uid() AND cm.company_id = p.active_company_id
|
||||
JOIN public.companies c
|
||||
ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
LIMIT 1
|
||||
),
|
||||
fallback AS (
|
||||
SELECT cm.company_id
|
||||
FROM public.company_members cm
|
||||
JOIN public.companies c
|
||||
ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
WHERE cm.user_id = auth.uid()
|
||||
ORDER BY cm.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
COALESCE((SELECT v.company_id FROM validated v), (SELECT f.company_id FROM fallback f)) AS company_id,
|
||||
(SELECT p.locale FROM pref p) AS locale,
|
||||
((SELECT v.company_id FROM validated v) IS NULL) AS used_fallback
|
||||
WHERE auth.uid() IS NOT NULL;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.resolve_active_company() FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.resolve_active_company() TO authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Single-round-trip chart-of-accounts listing for the app layer.
|
||||
--
|
||||
-- WHY THIS MIGRATION EXISTS
|
||||
-- -------------------------
|
||||
-- app/api/bookkeeping/accounts (GET) fetches chart_of_accounts through
|
||||
-- fetchAllRows(): sequential 1000-row PostgREST pages, one HTTP round trip
|
||||
-- each (Vercel iad1 to Supabase eu-north-1). 95/1250 prod companies have
|
||||
-- more than 1000 active accounts, so their list requests pay 2-5 sequential
|
||||
-- cross-region round trips while Postgres itself needs ~5ms per page.
|
||||
-- Returning the whole list as ONE json scalar bypasses PostgREST's
|
||||
-- db-max-rows=1000 cap, so every company size costs exactly one round trip.
|
||||
--
|
||||
-- INVARIANTS (do not break these when editing):
|
||||
-- 1. SECURITY INVOKER: the existing chart_of_accounts RLS select policy
|
||||
-- (company_id IN (SELECT user_company_ids())) must keep applying. The
|
||||
-- explicit p_company_id filter is defense in depth on top of RLS, same
|
||||
-- as the route's .eq('company_id', ...) today.
|
||||
-- 2. to_json(c) emits every column with its column name: the exact field
|
||||
-- set select('*') returns today. Do not switch to an explicit column
|
||||
-- list; the pg-real parity test locks this.
|
||||
-- 3. Ordering is (sort_order, id): sort_order is today's route order; id
|
||||
-- is only a deterministic tiebreaker (tie order was previously
|
||||
-- unspecified, so this is not a behavior change).
|
||||
-- 4. Filters mirror the route exactly: p_active_only true keeps
|
||||
-- is_active rows only; p_account_class NULL means no class filter.
|
||||
|
||||
create or replace function public.list_company_accounts(
|
||||
p_company_id uuid,
|
||||
p_active_only boolean default true,
|
||||
p_account_class integer default null
|
||||
) returns json
|
||||
language sql stable security invoker
|
||||
set search_path = public
|
||||
as $$
|
||||
select coalesce(json_agg(to_json(c) order by c.sort_order, c.id), '[]'::json)
|
||||
from public.chart_of_accounts c
|
||||
where c.company_id = p_company_id
|
||||
and (not p_active_only or c.is_active)
|
||||
and (p_account_class is null or c.account_class = p_account_class)
|
||||
$$;
|
||||
|
||||
revoke all on function public.list_company_accounts(uuid, boolean, integer) from public, anon;
|
||||
grant execute on function public.list_company_accounts(uuid, boolean, integer) to authenticated;
|
||||
grant execute on function public.list_company_accounts(uuid, boolean, integer) to service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,190 @@
|
||||
-- RPC: get_kpi_report_aggregates: one-round-trip aggregation for the KPI
|
||||
-- report (app/api/reports/kpi).
|
||||
--
|
||||
-- WHY THIS MIGRATION EXISTS
|
||||
-- -------------------------
|
||||
-- The report.kpi handler used to scan every journal line of the fiscal
|
||||
-- period THREE times through PostgREST and aggregate in JS:
|
||||
-- 1. generateTrialBalance (unfiltered) for balance-side KPIs,
|
||||
-- 2. generateIncomeStatement, which re-runs generateTrialBalance with
|
||||
-- excludeYearEndClosing,
|
||||
-- 3. generateMonthlyBreakdown with its own paginated line fetch.
|
||||
-- Each scan pays sequential cross-region round trips (Vercel iad1 to
|
||||
-- Supabase eu-north-1) per 1000-row page. One SQL pass now returns all
|
||||
-- three aggregate shapes; the JS side only merges opening balances and
|
||||
-- formats rows, so the payload is O(accounts + months), not O(lines).
|
||||
--
|
||||
-- SECTION SEMANTICS (each mirrors an existing TypeScript fetch; the
|
||||
-- pg-real test pins them):
|
||||
-- tb per-account debit/credit sums over the period's posted
|
||||
-- and reversed entries, excluding the opening-balance
|
||||
-- entry (p_ob_entry_id) so its values are not double
|
||||
-- counted against the IB. Mirrors the period-lines fetch
|
||||
-- in lib/reports/trial-balance.ts (no entry_date filter:
|
||||
-- fiscal_period_id already bounds activity).
|
||||
-- tb_ex_year_end same, additionally excluding every source_type
|
||||
-- 'year_end' entry plus stornos/corrections of REVERSED
|
||||
-- year-end entries. ye_reversed is COMPANY-WIDE (no
|
||||
-- period filter), mirroring trial-balance.ts wave-1.
|
||||
-- source_type is NOT NULL, so IS DISTINCT FROM matches
|
||||
-- the PostgREST .neq() exactly.
|
||||
-- ob per-account sums of the opening-balance entry's lines.
|
||||
-- NO status filter, mirroring getOpeningBalances
|
||||
-- (lib/reports/opening-balances.ts) which only checks
|
||||
-- id + company_id. Empty when p_ob_entry_id IS NULL.
|
||||
-- monthly income/expenses per calendar month over POSTED entries
|
||||
-- only (lib/reports/monthly-breakdown.ts): class 3 =
|
||||
-- income (credit-debit), classes 4-7 = expenses
|
||||
-- (debit-credit), class 8 split PER LINE by sign of
|
||||
-- credit-debit, and 8999 excluded entirely (the closing
|
||||
-- account would cancel the income-vs-expense signal).
|
||||
-- The OB entry and year_end entries are NOT excluded
|
||||
-- here: the JS scan never excluded them either.
|
||||
--
|
||||
-- SECURITY INVOKER: journal_entries/journal_entry_lines RLS is
|
||||
-- company-scoped via user_company_ids(), 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. Service-role callers rely on the
|
||||
-- explicit p_company_id filter.
|
||||
--
|
||||
-- pg-test: tests/pg/kpi-report-aggregates-rpc.pg.test.ts
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_kpi_report_aggregates(
|
||||
p_company_id uuid,
|
||||
p_fiscal_period_id uuid,
|
||||
p_ob_entry_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH period_entries AS (
|
||||
SELECT id, entry_date, status, source_type, reverses_id, correction_of_id
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_fiscal_period_id
|
||||
AND status IN ('posted', 'reversed')
|
||||
),
|
||||
tb_entries AS (
|
||||
SELECT * FROM period_entries
|
||||
WHERE p_ob_entry_id IS NULL OR id <> p_ob_entry_id
|
||||
),
|
||||
ye_reversed AS (
|
||||
-- Company-wide (no period filter), mirroring the wave-1 fetch in
|
||||
-- lib/reports/trial-balance.ts: a storno in this period can reverse a
|
||||
-- year-end entry from another period.
|
||||
SELECT id
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND source_type = 'year_end'
|
||||
AND status = 'reversed'
|
||||
),
|
||||
tb_ex_ye_entries AS (
|
||||
SELECT * FROM tb_entries
|
||||
WHERE source_type IS DISTINCT FROM 'year_end'
|
||||
AND (reverses_id IS NULL
|
||||
OR reverses_id NOT IN (SELECT id FROM ye_reversed))
|
||||
AND (correction_of_id IS NULL
|
||||
OR correction_of_id NOT IN (SELECT id FROM ye_reversed))
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'tb', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN tb_entries e ON e.id = l.journal_entry_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'tb_ex_year_end', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN tb_ex_ye_entries e ON e.id = l.journal_entry_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'ob', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'account_number', t.account_number,
|
||||
'debit', t.debit,
|
||||
'credit', t.credit
|
||||
) ORDER BY t.account_number)
|
||||
FROM (
|
||||
-- No status filter: getOpeningBalances only checks id + company_id.
|
||||
SELECT l.account_number,
|
||||
sum(l.debit_amount)::float8 AS debit,
|
||||
sum(l.credit_amount)::float8 AS credit
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN public.journal_entries e
|
||||
ON e.id = l.journal_entry_id
|
||||
AND e.id = p_ob_entry_id
|
||||
AND e.company_id = p_company_id
|
||||
GROUP BY l.account_number
|
||||
) t
|
||||
), '[]'::jsonb),
|
||||
'monthly', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'year', m.year,
|
||||
'month', m.month,
|
||||
'income', m.income,
|
||||
'expenses', m.expenses
|
||||
) ORDER BY m.year, m.month)
|
||||
FROM (
|
||||
SELECT EXTRACT(YEAR FROM e.entry_date)::int AS year,
|
||||
EXTRACT(MONTH FROM e.entry_date)::int AS month,
|
||||
(
|
||||
COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^3'
|
||||
THEN l.credit_amount - l.debit_amount
|
||||
END), 0)
|
||||
+ COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^8'
|
||||
AND l.account_number <> '8999'
|
||||
AND (l.credit_amount - l.debit_amount) >= 0
|
||||
THEN l.credit_amount - l.debit_amount
|
||||
END), 0)
|
||||
)::float8 AS income,
|
||||
(
|
||||
COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^[4-7]'
|
||||
THEN l.debit_amount - l.credit_amount
|
||||
END), 0)
|
||||
+ COALESCE(sum(CASE
|
||||
WHEN l.account_number ~ '^8'
|
||||
AND l.account_number <> '8999'
|
||||
AND (l.credit_amount - l.debit_amount) < 0
|
||||
THEN l.debit_amount - l.credit_amount
|
||||
END), 0)
|
||||
)::float8 AS expenses
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN period_entries e
|
||||
ON e.id = l.journal_entry_id
|
||||
AND e.status = 'posted'
|
||||
WHERE l.account_number ~ '^[3-8]'
|
||||
GROUP BY 1, 2
|
||||
) m
|
||||
), '[]'::jsonb)
|
||||
)
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Enforce "no bank transaction may point at an opening balance entry" from the
|
||||
-- transactions side as well.
|
||||
--
|
||||
-- 20260723160000 made mark_entry_as_opening_balance refuse entries with linked
|
||||
-- transactions, but that guard alone has a TOCTOU race: the RPC locks the
|
||||
-- journal_entries row FOR UPDATE, while linking paths (e.g. manualLink in
|
||||
-- lib/reconciliation/bank-reconciliation.ts) write transactions.journal_entry_id
|
||||
-- without touching journal_entries. Under READ COMMITTED a link that commits
|
||||
-- between the RPC's EXISTS check and its commit is invisible to the check, so an
|
||||
-- entry could still end up retagged to opening_balance with a live transaction
|
||||
-- pointing at it: the exact "permanent phantom difference" the guard exists to
|
||||
-- prevent.
|
||||
--
|
||||
-- This trigger closes the race without touching the linking code paths: its
|
||||
-- FOR KEY SHARE locking read on journal_entries conflicts with the RPC's
|
||||
-- FOR UPDATE (and with nothing weaker, so ordinary entry updates are not
|
||||
-- serialized against links).
|
||||
-- * Link starts first: its FOR KEY SHARE blocks the RPC's FOR UPDATE until
|
||||
-- the link commits; the RPC's EXISTS check then runs on a fresh snapshot
|
||||
-- and refuses.
|
||||
-- * Retag starts first: the trigger's FOR KEY SHARE blocks until the RPC
|
||||
-- commits; the locking read then sees source_type = 'opening_balance' and
|
||||
-- raises.
|
||||
--
|
||||
-- Fires only on INSERT with a non-NULL journal_entry_id and on UPDATEs that
|
||||
-- actually change journal_entry_id to a non-NULL value: existing rows,
|
||||
-- unlinks (SET NULL), and unrelated column updates are untouched.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.block_transaction_link_to_opening_balance()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
-- SECURITY DEFINER so RLS can never blind the invariant check (the linking
|
||||
-- paths already enforce tenancy; this only READS source_type).
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_source_type text;
|
||||
BEGIN
|
||||
IF NEW.journal_entry_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF TG_OP = 'UPDATE' AND NEW.journal_entry_id IS NOT DISTINCT FROM OLD.journal_entry_id THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT je.source_type INTO v_source_type
|
||||
FROM public.journal_entries je
|
||||
WHERE je.id = NEW.journal_entry_id
|
||||
FOR KEY SHARE;
|
||||
|
||||
IF v_source_type = 'opening_balance' THEN
|
||||
RAISE EXCEPTION 'Cannot link a bank transaction to an opening balance entry (%): an ingaende balans predates the bank feed', NEW.journal_entry_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS check_transaction_link_not_opening_balance ON public.transactions;
|
||||
CREATE TRIGGER check_transaction_link_not_opening_balance
|
||||
BEFORE INSERT OR UPDATE OF journal_entry_id ON public.transactions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.block_transaction_link_to_opening_balance();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,228 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { insertAuthUser, seedCompany } from '@/tests/pg/fixtures'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
|
||||
/**
|
||||
* Locks the behavior of public.list_company_accounts (20260723170000): the
|
||||
* single-round-trip replacement for the paged fetchAllRows chart-of-accounts
|
||||
* fetch in app/api/bookkeeping/accounts.
|
||||
*
|
||||
* The route treats the RPC result as a drop-in for select('*') ordered by
|
||||
* sort_order, so the critical properties are:
|
||||
* - filter parity: p_active_only / p_account_class mirror the route's
|
||||
* .eq('is_active', true) / .eq('account_class', n) filters
|
||||
* - ordering: (sort_order, id); id only breaks ties deterministically
|
||||
* - field-set parity: every element carries the exact column set of
|
||||
* chart_of_accounts (to_json of the whole row), so response shapes
|
||||
* do not change when the route switches paths
|
||||
* - SECURITY INVOKER: RLS still gates rows for non-members
|
||||
*/
|
||||
|
||||
const MIGRATION_SQL = readFileSync(
|
||||
join(process.cwd(), 'supabase/migrations/20260723170000_list_company_accounts_rpc.sql'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
interface AccountJson {
|
||||
id: string
|
||||
account_number: string
|
||||
account_class: number
|
||||
sort_order: number
|
||||
is_active: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
async function callRpc(
|
||||
companyId: string,
|
||||
activeOnly: boolean = true,
|
||||
accountClass: number | null = null,
|
||||
): Promise<AccountJson[]> {
|
||||
const res = await getPool().query<{ result: AccountJson[] }>(
|
||||
`SELECT public.list_company_accounts($1::uuid, $2::boolean, $3::integer) AS result`,
|
||||
[companyId, activeOnly, accountClass],
|
||||
)
|
||||
return res.rows[0]!.result
|
||||
}
|
||||
|
||||
async function seedChart(companyId: string): Promise<void> {
|
||||
await getPool().query(`SELECT public.seed_chart_of_accounts($1::uuid, 'aktiebolag')`, [
|
||||
companyId,
|
||||
])
|
||||
}
|
||||
|
||||
async function insertAccount(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
accountNumber: string
|
||||
accountClass: number
|
||||
isActive?: boolean
|
||||
sortOrder?: number
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.chart_of_accounts
|
||||
(id, user_id, company_id, account_number, account_name, account_class,
|
||||
account_group, account_type, normal_balance, is_active, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'expense', 'debit', $8, $9)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.accountNumber,
|
||||
`Testkonto ${params.accountNumber}`,
|
||||
params.accountClass,
|
||||
params.accountNumber.substring(0, 2),
|
||||
params.isActive ?? true,
|
||||
params.sortOrder ?? 0,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('list_company_accounts: filtering', () => {
|
||||
it('returns only active rows by default and includes inactive with p_active_only=false', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await seedChart(companyId)
|
||||
const inactiveId = await insertAccount({
|
||||
userId,
|
||||
companyId,
|
||||
accountNumber: '9998',
|
||||
accountClass: 8,
|
||||
isActive: false,
|
||||
})
|
||||
|
||||
const activeRows = await callRpc(companyId)
|
||||
expect(activeRows.length).toBeGreaterThan(0)
|
||||
expect(activeRows.every((r) => r.is_active)).toBe(true)
|
||||
expect(activeRows.some((r) => r.id === inactiveId)).toBe(false)
|
||||
|
||||
const allRows = await callRpc(companyId, false)
|
||||
expect(allRows.some((r) => r.id === inactiveId)).toBe(true)
|
||||
expect(allRows.length).toBe(activeRows.length + 1)
|
||||
})
|
||||
|
||||
it('filters by p_account_class', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
await seedChart(companyId)
|
||||
|
||||
const class3 = await callRpc(companyId, true, 3)
|
||||
expect(class3.length).toBeGreaterThan(0)
|
||||
expect(class3.every((r) => r.account_class === 3)).toBe(true)
|
||||
|
||||
const expected = await getPool().query<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM public.chart_of_accounts
|
||||
WHERE company_id = $1 AND is_active AND account_class = 3`,
|
||||
[companyId],
|
||||
)
|
||||
expect(class3.length).toBe(Number(expected.rows[0]!.n))
|
||||
})
|
||||
|
||||
it('returns [] (not NULL) for an unknown company id', async () => {
|
||||
const rows = await callRpc(randomUUID())
|
||||
expect(rows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('list_company_accounts: ordering', () => {
|
||||
it('orders by (sort_order, id) with id as the deterministic tiebreaker', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await seedChart(companyId)
|
||||
// Two accounts sharing a sort_order: the tie must resolve by id.
|
||||
const tieA = await insertAccount({
|
||||
userId,
|
||||
companyId,
|
||||
accountNumber: '9901',
|
||||
accountClass: 8,
|
||||
sortOrder: 5000,
|
||||
})
|
||||
const tieB = await insertAccount({
|
||||
userId,
|
||||
companyId,
|
||||
accountNumber: '9902',
|
||||
accountClass: 8,
|
||||
sortOrder: 5000,
|
||||
})
|
||||
|
||||
const rows = await callRpc(companyId)
|
||||
const expected = await getPool().query<{ id: string }>(
|
||||
`SELECT id FROM public.chart_of_accounts
|
||||
WHERE company_id = $1 AND is_active
|
||||
ORDER BY sort_order, id`,
|
||||
[companyId],
|
||||
)
|
||||
expect(rows.map((r) => r.id)).toEqual(expected.rows.map((r) => r.id))
|
||||
|
||||
// Explicit tie assertion: uuid ordering in Postgres is bytewise, which
|
||||
// matches lexicographic order of the canonical lowercase hex form.
|
||||
const [first, second] = [tieA, tieB].sort()
|
||||
expect(rows.findIndex((r) => r.id === first)).toBeLessThan(
|
||||
rows.findIndex((r) => r.id === second),
|
||||
)
|
||||
// And the tied pair sits adjacent at the end (highest sort_order).
|
||||
expect(rows.slice(-2).map((r) => r.id)).toEqual([first, second])
|
||||
})
|
||||
})
|
||||
|
||||
describe('list_company_accounts: select(*) parity', () => {
|
||||
it('each element carries the exact column set of chart_of_accounts', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
await seedChart(companyId)
|
||||
|
||||
const rows = await callRpc(companyId)
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
|
||||
const cols = await getPool().query<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'chart_of_accounts'`,
|
||||
)
|
||||
const expectedKeys = cols.rows.map((r) => r.column_name).sort()
|
||||
for (const row of rows) {
|
||||
expect(Object.keys(row).sort()).toEqual(expectedKeys)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('list_company_accounts: security', () => {
|
||||
it('is SECURITY INVOKER: a non-member sees zero rows through RLS', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
await seedChart(companyId)
|
||||
const outsiderId = await insertAuthUser()
|
||||
|
||||
const rows = await withUserContext(outsiderId, async (client) => {
|
||||
const res = await client.query<{ result: AccountJson[] }>(
|
||||
`SELECT public.list_company_accounts($1::uuid) AS result`,
|
||||
[companyId],
|
||||
)
|
||||
return res.rows[0]!.result
|
||||
})
|
||||
expect(rows).toEqual([])
|
||||
})
|
||||
|
||||
it('a member sees the company chart through the same RLS policy', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await seedChart(companyId)
|
||||
|
||||
const rows = await withUserContext(userId, async (client) => {
|
||||
const res = await client.query<{ result: AccountJson[] }>(
|
||||
`SELECT public.list_company_accounts($1::uuid) AS result`,
|
||||
[companyId],
|
||||
)
|
||||
return res.rows[0]!.result
|
||||
})
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('list_company_accounts: migration idempotency', () => {
|
||||
it('re-executing the migration SQL succeeds and the function still works', async () => {
|
||||
await getPool().query(MIGRATION_SQL)
|
||||
|
||||
const { companyId } = await seedCompany()
|
||||
await seedChart(companyId)
|
||||
const rows = await callRpc(companyId)
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user