@fayz-ai/plugin-financial 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ export declare const MIGRATION_000_PLG_RENAME = "-- 000_plg_rename.sql \u2014 re
2
2
  export declare const MIGRATION_001_FINANCIAL_BASE = "-- Financial Plugin: Base Tables\n-- Pattern: uuid PK, tenant_id FK, timestamps, RLS via project_rls.sql auto-detection\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_payment_method_types (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n transaction_type text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n allowed_account_types jsonb DEFAULT '[]'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_payment_method_types ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_payment_method_types_tenant ON public.plg_financial_payment_method_types(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_payment_methods (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n payment_method_type_id uuid REFERENCES public.plg_financial_payment_method_types(id),\n is_active boolean NOT NULL DEFAULT true,\n discount_mode text,\n discount_value numeric(12,2) DEFAULT 0,\n interest_mode text,\n interest_value numeric(12,2) DEFAULT 0,\n min_installments integer DEFAULT 1,\n max_installments integer DEFAULT 1,\n service_filter_mode text DEFAULT 'all',\n service_filter_ids jsonb DEFAULT '[]'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_payment_methods ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_payment_methods_tenant ON public.plg_financial_payment_methods(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_bank_accounts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n account_type text NOT NULL DEFAULT 'bank_account',\n bank_name text,\n account_number text,\n agency_number text,\n current_balance numeric(14,2) NOT NULL DEFAULT 0,\n initial_balance numeric(14,2) NOT NULL DEFAULT 0,\n credit_limit numeric(14,2),\n due_day integer,\n closing_day integer,\n is_active boolean NOT NULL DEFAULT true,\n unit_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_bank_accounts ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_bank_accounts_tenant ON public.plg_financial_bank_accounts(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_cash_register_sessions (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n bank_account_id uuid NOT NULL REFERENCES public.plg_financial_bank_accounts(id),\n status text NOT NULL DEFAULT 'open',\n opened_at timestamptz NOT NULL DEFAULT now(),\n opened_by_user_id uuid,\n opened_by_name text,\n opening_balance numeric(14,2) NOT NULL,\n closed_at timestamptz,\n closed_by_user_id uuid,\n closed_by_name text,\n closing_balance numeric(14,2),\n expected_balance numeric(14,2),\n difference numeric(14,2),\n notes text,\n unit_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_cash_register_sessions ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_cash_register_sessions_tenant ON public.plg_financial_cash_register_sessions(tenant_id);\n\n-- Financial movements (installments, payments) \u2014 plugin-specific, not archetype\n-- Invoices themselves use public.orders (kind='invoice_payable'/'invoice_receivable')\nCREATE TABLE IF NOT EXISTS public.plg_financial_movements (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n invoice_id uuid REFERENCES public.orders(id),\n direction text NOT NULL,\n movement_kind text NOT NULL,\n amount numeric(14,2) NOT NULL,\n paid_amount numeric(14,2) DEFAULT 0,\n status text NOT NULL DEFAULT 'pending',\n due_date date NOT NULL,\n payment_date date,\n installment_number integer,\n payment_method_id uuid REFERENCES public.plg_financial_payment_methods(id),\n bank_account_id uuid REFERENCES public.plg_financial_bank_accounts(id),\n cash_session_id uuid REFERENCES public.plg_financial_cash_register_sessions(id),\n card_brand text,\n card_installments integer,\n notes text,\n metadata jsonb DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_movements ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_tenant ON public.plg_financial_movements(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_invoice ON public.plg_financial_movements(invoice_id);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_due ON public.plg_financial_movements(tenant_id, due_date);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_bank ON public.plg_financial_movements(bank_account_id);\n";
3
3
  export declare const MIGRATION_002_CHART_OF_ACCOUNTS = "-- Financial Plugin: Chart of Accounts + Cost Centers\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_chart_of_accounts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n code text NOT NULL,\n name text NOT NULL,\n node_type text NOT NULL DEFAULT 'leaf',\n parent_id uuid REFERENCES public.plg_financial_chart_of_accounts(id),\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_chart_of_accounts ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_chart_of_accounts_tenant ON public.plg_financial_chart_of_accounts(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_chart_of_accounts_parent ON public.plg_financial_chart_of_accounts(parent_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_cost_centers (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n code text NOT NULL,\n name text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_cost_centers ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_cost_centers_tenant ON public.plg_financial_cost_centers(tenant_id);\n";
4
4
  export declare const MIGRATION_003_CARD_BRANDS = "-- Financial Plugin: Card Brands\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_card_brands (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_card_brands ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_card_brands_tenant ON public.plg_financial_card_brands(tenant_id);\n";
5
- export declare const MIGRATION_004_ORDER_TO_CASH = "-- Financial plugin \u2014 order-to-cash companion SQL.\n-- The atomic \"promote an order into an invoice + installment ledger\" function\n-- that CRM (approveQuote) and the agenda financial bridge both RPC. It belongs\n-- to the financial plugin (it writes plg_financial_movements + reads orders), and it\n-- ships WITH the plugin so enabling Payments provisions it. Idempotent.\n-- Depends on: public.orders, public.plg_financial_movements (001_financial_base).\n\n-- 1. Sequence generator (REC-00001 / PAG-00001 numbering)\nCREATE TABLE IF NOT EXISTS public.sequences (\n tenant_id uuid NOT NULL REFERENCES public.tenants(id),\n kind text NOT NULL,\n current_value bigint NOT NULL DEFAULT 0,\n PRIMARY KEY (tenant_id, kind)\n);\n-- Baseline: RLS ON, deny-all (no policies), like the salon pool. The counter is\n-- an internal, non-API table written ONLY through next_sequence() below, which is\n-- SECURITY DEFINER and so bypasses RLS as the table owner. Legacy pools that\n-- shipped this table without RLS are also remediated by packages/db 013_sequences_rls.sql.\nALTER TABLE public.sequences ENABLE ROW LEVEL SECURITY;\n\n-- SECURITY DEFINER: next_sequence is called directly via PostgREST RPC by\n-- authenticated users (src/data/supabase.ts \u2192 createInvoice), so with deny-all\n-- RLS on public.sequences it MUST run as the table owner to write the counter.\n-- SET search_path pins schema resolution for the definer context.\nCREATE OR REPLACE FUNCTION public.next_sequence(p_tenant_id uuid, p_kind text)\nRETURNS bigint\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE v_next bigint;\nBEGIN\n INSERT INTO public.sequences (tenant_id, kind, current_value)\n VALUES (p_tenant_id, p_kind, 1)\n ON CONFLICT (tenant_id, kind)\n DO UPDATE SET current_value = public.sequences.current_value + 1\n RETURNING current_value INTO v_next;\n RETURN v_next;\nEND;\n$$;\n\n-- 2. Order-to-cash columns on the spine orders (plugin extends public.orders)\nALTER TABLE public.orders\n ADD COLUMN IF NOT EXISTS stage text,\n ADD COLUMN IF NOT EXISTS direction text;\n\n-- 3. RLS + grants for plg_financial_movements (table from 001_financial_base.sql)\nALTER TABLE public.plg_financial_movements ENABLE ROW LEVEL SECURITY;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_movements TO authenticated;\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_movements' AND policyname='plg_financial_movements_select') THEN\n CREATE POLICY plg_financial_movements_select ON public.plg_financial_movements FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_insert ON public.plg_financial_movements FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_update ON public.plg_financial_movements FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_delete ON public.plg_financial_movements FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n END IF;\nEND $$;\n\n-- 4. The function (v2 \u2014 backward compatible with the 3-arg CRM call via defaults)\nDROP FUNCTION IF EXISTS public.fn_invoice_from_order(uuid, date, text);\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_ref text;\n v_seq bigint;\n v_summary text;\n v_contact text;\n v_existing uuid;\n v_kind text;\n v_prefix text;\n v_n int := GREATEST(COALESCE(p_installments, 1), 1);\n v_total numeric;\n v_base numeric;\n v_amt numeric;\n v_alloc numeric := 0;\n v_due date;\n i int;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;\n IF v_order.tenant_id NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'fn_invoice_from_order: forbidden';\n END IF;\n\n SELECT id INTO v_existing FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill' LIMIT 1;\n IF v_existing IS NOT NULL THEN RETURN p_order_id; END IF;\n\n IF p_direction = 'debit' THEN v_kind := 'invoice_payable'; v_prefix := 'PAG';\n ELSE v_kind := 'invoice_receivable'; v_prefix := 'REC'; END IF;\n\n SELECT public.next_sequence(v_order.tenant_id, v_kind) INTO v_seq;\n v_ref := v_prefix || '-' || lpad(v_seq::text, 5, '0');\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.order_items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n UPDATE public.orders SET\n status = COALESCE(p_status, status),\n stage = 'invoiced',\n direction = p_direction,\n reference_number = v_ref,\n metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(\n 'itemsSummary', COALESCE(v_summary, ''),\n 'contactName', v_contact,\n 'installmentCount', v_n,\n 'quoteNumber', v_order.reference_number,\n 'direction', p_direction\n ),\n updated_at = now()\n WHERE id = p_order_id;\n\n v_total := COALESCE(v_order.total, 0);\n v_base := round(v_total / v_n, 2);\n v_due := COALESCE(p_due_date, current_date);\n FOR i IN 1..v_n LOOP\n IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);\n ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;\n INSERT INTO public.plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number)\n VALUES\n (v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',\n (v_due + ((i - 1) || ' months')::interval)::date, i);\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\nGRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated;\n\n-- 5. Derived balances view (Payments page + agenda payment badge read this)\nCREATE OR REPLACE VIEW public.v_invoice_balances AS\nWITH bill AS (\n SELECT\n invoice_id,\n SUM(amount) FILTER (WHERE status <> 'cancelled') AS billed,\n SUM(paid_amount) FILTER (WHERE status <> 'cancelled') AS paid,\n bool_or(status NOT IN ('paid','cancelled') AND due_date < current_date) AS has_overdue\n FROM public.plg_financial_movements\n WHERE movement_kind = 'bill'\n GROUP BY invoice_id\n)\nSELECT\n o.id AS invoice_id, o.tenant_id, o.kind, o.reference_number, o.party_id, o.direction,\n CASE WHEN o.direction = 'debit' OR o.kind = 'invoice_payable' THEN 'payable' ELSE 'receivable' END AS flow,\n COALESCE(b.billed, o.total, 0) AS amount,\n COALESCE(b.paid, 0) AS paid,\n CASE WHEN o.status = 'cancelled' OR o.stage = 'cancelled' THEN 0\n ELSE COALESCE(b.billed, o.total, 0) - COALESCE(b.paid, 0) END AS balance,\n CASE\n WHEN o.status = 'cancelled' OR o.stage = 'cancelled' THEN 'cancelled'\n WHEN COALESCE(b.billed, o.total, 0) = 0 THEN 'open'\n WHEN COALESCE(b.paid, 0) >= COALESCE(b.billed, o.total, 0) THEN 'paid'\n WHEN COALESCE(b.paid, 0) > 0 THEN 'partial'\n WHEN COALESCE(b.has_overdue, false) THEN 'overdue'\n ELSE 'open'\n END AS status,\n o.created_at, o.updated_at\nFROM public.orders o\nLEFT JOIN bill b ON b.invoice_id = o.id\nWHERE o.kind IN ('invoice_receivable','invoice_payable') OR b.invoice_id IS NOT NULL;\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated;\n\n-- Refresh PostgREST's schema cache so the new RPC/views are visible immediately.\nNOTIFY pgrst, 'reload schema';\n";
5
+ export declare const MIGRATION_004_ORDER_TO_CASH = "-- Financial plugin \u2014 order-to-cash companion SQL.\n-- The atomic \"promote an order into an invoice + installment ledger\" function\n-- that CRM (approveQuote) and the agenda financial bridge both RPC. It belongs\n-- to the financial plugin (it writes plg_financial_movements + reads orders), and it\n-- ships WITH the plugin so enabling Payments provisions it. Idempotent.\n-- Depends on: public.orders, public.plg_financial_movements (001_financial_base).\n\n-- 1. Sequence generator (REC-00001 / PAG-00001 numbering)\nCREATE TABLE IF NOT EXISTS public.sequences (\n tenant_id uuid NOT NULL REFERENCES public.tenants(id),\n kind text NOT NULL,\n current_value bigint NOT NULL DEFAULT 0,\n PRIMARY KEY (tenant_id, kind)\n);\n-- Baseline: RLS ON, deny-all (no policies), like the salon pool. The counter is\n-- an internal, non-API table written ONLY through next_sequence() below, which is\n-- SECURITY DEFINER and so bypasses RLS as the table owner. Legacy pools that\n-- shipped this table without RLS are also remediated by packages/db 017_sequences_rls.sql.\nALTER TABLE public.sequences ENABLE ROW LEVEL SECURITY;\n\n-- SECURITY DEFINER: next_sequence is called directly via PostgREST RPC by\n-- authenticated users (src/data/supabase.ts \u2192 createInvoice), so with deny-all\n-- RLS on public.sequences it MUST run as the table owner to write the counter.\n-- SET search_path pins schema resolution for the definer context.\nCREATE OR REPLACE FUNCTION public.next_sequence(p_tenant_id uuid, p_kind text)\nRETURNS bigint\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE v_next bigint;\nBEGIN\n INSERT INTO public.sequences (tenant_id, kind, current_value)\n VALUES (p_tenant_id, p_kind, 1)\n ON CONFLICT (tenant_id, kind)\n DO UPDATE SET current_value = public.sequences.current_value + 1\n RETURNING current_value INTO v_next;\n RETURN v_next;\nEND;\n$$;\n\n-- 2. Order-to-cash columns on the spine orders (plugin extends public.orders)\nALTER TABLE public.orders\n ADD COLUMN IF NOT EXISTS stage text,\n ADD COLUMN IF NOT EXISTS direction text;\n\n-- 3. RLS + grants for plg_financial_movements (table from 001_financial_base.sql)\nALTER TABLE public.plg_financial_movements ENABLE ROW LEVEL SECURITY;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_movements TO authenticated;\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_movements' AND policyname='plg_financial_movements_select') THEN\n CREATE POLICY plg_financial_movements_select ON public.plg_financial_movements FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_insert ON public.plg_financial_movements FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_update ON public.plg_financial_movements FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_delete ON public.plg_financial_movements FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n END IF;\nEND $$;\n\n-- 4. The function (v2 \u2014 backward compatible with the 3-arg CRM call via defaults)\nDROP FUNCTION IF EXISTS public.fn_invoice_from_order(uuid, date, text);\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_ref text;\n v_seq bigint;\n v_summary text;\n v_contact text;\n v_existing uuid;\n v_kind text;\n v_prefix text;\n v_n int := GREATEST(COALESCE(p_installments, 1), 1);\n v_total numeric;\n v_base numeric;\n v_amt numeric;\n v_alloc numeric := 0;\n v_due date;\n i int;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;\n IF v_order.tenant_id NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'fn_invoice_from_order: forbidden';\n END IF;\n\n SELECT id INTO v_existing FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill' LIMIT 1;\n IF v_existing IS NOT NULL THEN RETURN p_order_id; END IF;\n\n IF p_direction = 'debit' THEN v_kind := 'invoice_payable'; v_prefix := 'PAG';\n ELSE v_kind := 'invoice_receivable'; v_prefix := 'REC'; END IF;\n\n SELECT public.next_sequence(v_order.tenant_id, v_kind) INTO v_seq;\n v_ref := v_prefix || '-' || lpad(v_seq::text, 5, '0');\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.order_items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n UPDATE public.orders SET\n status = COALESCE(p_status, status),\n stage = 'invoiced',\n direction = p_direction,\n reference_number = v_ref,\n metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(\n 'itemsSummary', COALESCE(v_summary, ''),\n 'contactName', v_contact,\n 'installmentCount', v_n,\n 'quoteNumber', v_order.reference_number,\n 'direction', p_direction\n ),\n updated_at = now()\n WHERE id = p_order_id;\n\n v_total := COALESCE(v_order.total, 0);\n v_base := round(v_total / v_n, 2);\n v_due := COALESCE(p_due_date, current_date);\n FOR i IN 1..v_n LOOP\n IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);\n ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;\n INSERT INTO public.plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number)\n VALUES\n (v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',\n (v_due + ((i - 1) || ' months')::interval)::date, i);\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\nGRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated;\n\n-- 5. Derived balances view (Payments page + agenda payment badge read this)\nCREATE OR REPLACE VIEW public.v_invoice_balances AS\nWITH bill AS (\n SELECT\n invoice_id,\n SUM(amount) FILTER (WHERE status <> 'cancelled') AS billed,\n SUM(paid_amount) FILTER (WHERE status <> 'cancelled') AS paid,\n bool_or(status NOT IN ('paid','cancelled') AND due_date < current_date) AS has_overdue\n FROM public.plg_financial_movements\n WHERE movement_kind = 'bill'\n GROUP BY invoice_id\n)\nSELECT\n o.id AS invoice_id, o.tenant_id, o.kind, o.reference_number, o.party_id, o.direction,\n CASE WHEN o.direction = 'debit' OR o.kind = 'invoice_payable' THEN 'payable' ELSE 'receivable' END AS flow,\n COALESCE(b.billed, o.total, 0) AS amount,\n COALESCE(b.paid, 0) AS paid,\n CASE WHEN o.status = 'cancelled' OR o.stage = 'cancelled' THEN 0\n ELSE COALESCE(b.billed, o.total, 0) - COALESCE(b.paid, 0) END AS balance,\n CASE\n WHEN o.status = 'cancelled' OR o.stage = 'cancelled' THEN 'cancelled'\n WHEN COALESCE(b.billed, o.total, 0) = 0 THEN 'open'\n WHEN COALESCE(b.paid, 0) >= COALESCE(b.billed, o.total, 0) THEN 'paid'\n WHEN COALESCE(b.paid, 0) > 0 THEN 'partial'\n WHEN COALESCE(b.has_overdue, false) THEN 'overdue'\n ELSE 'open'\n END AS status,\n o.created_at, o.updated_at\nFROM public.orders o\nLEFT JOIN bill b ON b.invoice_id = o.id\nWHERE o.kind IN ('invoice_receivable','invoice_payable') OR b.invoice_id IS NOT NULL;\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated;\n\n-- Refresh PostgREST's schema cache so the new RPC/views are visible immediately.\nNOTIFY pgrst, 'reload schema';\n";
6
6
  export declare const MIGRATION_005_SEED_DEFAULTS = "-- Financial plugin \u2014 seed: payment method types + card brands.\n-- These are the read-only \"system\" registries the plugin's UI assumes exist\n-- (PaymentModal, card selection). They live as registry seedData in TS but were\n-- never written to the DB, so a fresh tenant had none. This backfills them for\n-- every tenant that has none. Idempotent. Plugin-owned so every app gets them.\n\nDO $$\nDECLARE t_id uuid;\nBEGIN\n FOR t_id IN (\n SELECT id FROM public.tenants\n WHERE id NOT IN (SELECT DISTINCT tenant_id FROM public.plg_financial_payment_method_types)\n )\n LOOP\n INSERT INTO public.plg_financial_payment_method_types (tenant_id, name, transaction_type) VALUES\n (t_id, 'Cash', 'cash'),\n (t_id, 'PIX', 'pix'),\n (t_id, 'Credit Card', 'credit_card'),\n (t_id, 'Debit Card', 'debit_card'),\n (t_id, 'Bank Transfer', 'bank_transfer'),\n (t_id, 'Check', 'check');\n END LOOP;\nEND $$;\n\nDO $$\nDECLARE t_id uuid;\nBEGIN\n FOR t_id IN (\n SELECT id FROM public.tenants\n WHERE id NOT IN (SELECT DISTINCT tenant_id FROM public.plg_financial_card_brands)\n )\n LOOP\n INSERT INTO public.plg_financial_card_brands (tenant_id, name) VALUES\n (t_id, 'Visa'), (t_id, 'Mastercard'), (t_id, 'American Express'),\n (t_id, 'Elo'), (t_id, 'Hipercard'), (t_id, 'Diners Club'),\n (t_id, 'Discover'), (t_id, 'JCB'), (t_id, 'Aura'), (t_id, 'Hiper');\n END LOOP;\nEND $$;\n";
7
7
  export declare const MIGRATION_006_RLS_POLICIES = "-- Financial Plugin: RLS policies + grants for ALL base tables.\n--\n-- The base migrations (001/002/003) ENABLE row-level security but relied on an\n-- external `project_rls.sql` auto-detection step to create the actual policies.\n-- That step does not run in the Drizzle/companion pipeline, so every table\n-- except plg_financial_movements was RLS-enabled with NO policy \u2192 all inserts denied\n-- (\"new row violates row-level security policy\"). This creates the canonical\n-- per-tenant policies idempotently. Re-running is a no-op (IF NOT EXISTS guards;\n-- plg_financial_movements policies from 004 are skipped).\n--\n-- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).\n\nDO $$\nDECLARE t text;\nBEGIN\n FOR t IN SELECT unnest(ARRAY[\n 'plg_financial_payment_method_types','plg_financial_payment_methods','plg_financial_bank_accounts',\n 'plg_financial_cash_register_sessions','plg_financial_movements',\n 'plg_financial_chart_of_accounts','plg_financial_cost_centers','plg_financial_card_brands'\n ])\n LOOP\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);\n END IF;\n END LOOP;\nEND $$;\n";
8
8
  export declare const MIGRATION_006B_EXTRACT_FEE_AMOUNT = "-- ---------------------------------------------------------------------------\n-- Financial extract (extrato): net card settlement + transfers\n-- ---------------------------------------------------------------------------\n-- Adds the processing/MDR fee column used by the bank/cash account statement so\n-- the running balance reflects the NET cash that actually lands in the account.\n-- gross = paid_amount (settles the receivable/payable)\n-- net cash = paid_amount - fee_amount (credits only; v1 has no fee on debits)\nALTER TABLE public.plg_financial_movements\n ADD COLUMN IF NOT EXISTS fee_amount numeric(14,2) NOT NULL DEFAULT 0;\n\nCOMMENT ON COLUMN public.plg_financial_movements.fee_amount IS\n 'Processing/MDR fee deducted at settlement. Net cash impact = paid_amount - fee_amount (credit). v1: same-day net, no D+N settlement-date modeling.';\n\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_statement\n ON public.plg_financial_movements (bank_account_id, payment_date)\n WHERE status IN ('paid', 'partial');\n\n-- Transfers between accounts = a debit row (source) + credit row (destination),\n-- both status='paid', movement_kind='transfer', correlated via metadata jsonb:\n-- metadata->>'transferId' | 'transferRole' ('out'|'in') | 'counterAccountId'\n";
@@ -13,9 +13,31 @@ export declare const MIGRATION_009_AGENT_RPCS = "-- ============================
13
13
  export declare const MIGRATION_010_INVOICE_INTERNAL = "-- Financial plugin \u2014 separate WHO MAY invoice from HOW an invoice is built.\n--\n-- fn_invoice_from_order (004) mixes the two: it checks that the caller is a\n-- member of the order's tenant, then does the work. That check is right for a\n-- human clicking \"Faturar\" in the admin, and impossible for anything else \u2014 a\n-- storefront checkout runs as the SHOPPER, whose user_tenant_ids() is empty by\n-- definition, so an order that should raise a receivable the moment it is placed\n-- cannot raise one at all. SECURITY DEFINER does not help: auth.uid() still\n-- belongs to the caller inside the definer context.\n--\n-- So the body moves to an internal function with no authorization of its own,\n-- and fn_invoice_from_order keeps its signature, keeps its check, and delegates.\n-- Callers that exist today (CRM approveQuote, the agenda bridge) are untouched.\n-- The internal one is granted to NOBODY: it is reachable only from other\n-- SECURITY DEFINER functions and triggers that have already decided the caller\n-- is entitled \u2014 the shop's order-to-cash bridge being the first.\n--\n-- Idempotent.\n\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order_internal(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_ref text;\n v_seq bigint;\n v_summary text;\n v_contact text;\n v_existing uuid;\n v_kind text;\n v_prefix text;\n v_n int := GREATEST(COALESCE(p_installments, 1), 1);\n v_total numeric;\n v_base numeric;\n v_amt numeric;\n v_alloc numeric := 0;\n v_due date;\n i int;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;\n\n -- Idempotent: an order already carrying a bill is already invoiced.\n SELECT id INTO v_existing FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill' LIMIT 1;\n IF v_existing IS NOT NULL THEN RETURN p_order_id; END IF;\n\n IF p_direction = 'debit' THEN v_kind := 'invoice_payable'; v_prefix := 'PAG';\n ELSE v_kind := 'invoice_receivable'; v_prefix := 'REC'; END IF;\n\n SELECT public.next_sequence(v_order.tenant_id, v_kind) INTO v_seq;\n v_ref := v_prefix || '-' || lpad(v_seq::text, 5, '0');\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.order_items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n UPDATE public.orders SET\n status = COALESCE(p_status, status),\n stage = 'invoiced',\n direction = p_direction,\n reference_number = v_ref,\n metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(\n 'itemsSummary', COALESCE(v_summary, ''),\n 'contactName', v_contact,\n 'installmentCount', v_n,\n 'quoteNumber', v_order.reference_number,\n 'direction', p_direction\n ),\n updated_at = now()\n WHERE id = p_order_id;\n\n v_total := COALESCE(v_order.total, 0);\n v_base := round(v_total / v_n, 2);\n v_due := COALESCE(p_due_date, current_date);\n FOR i IN 1..v_n LOOP\n IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);\n ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;\n INSERT INTO public.plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number)\n VALUES\n (v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',\n (v_due + ((i - 1) || ' months')::interval)::date, i);\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\n\n-- Deliberately NOT granted to authenticated/anon: entry is through\n-- fn_invoice_from_order, or from another definer function that has already\n-- established the caller's right to create this receivable.\nREVOKE ALL ON FUNCTION public.fn_invoice_from_order_internal(uuid, date, text, int, text) FROM PUBLIC;\n\n-- The public entry point: unchanged signature and behaviour, now a guard around\n-- the shared body instead of a second copy of it.\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE v_tenant uuid;\nBEGIN\n SELECT tenant_id INTO v_tenant FROM public.orders WHERE id = p_order_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id;\n END IF;\n IF v_tenant NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'fn_invoice_from_order: forbidden';\n END IF;\n\n RETURN public.fn_invoice_from_order_internal(\n p_order_id, p_due_date, p_status, p_installments, p_direction);\nEND;\n$$;\n\nGRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated;\n\nNOTIFY pgrst, 'reload schema';\n";
14
14
  export declare const MIGRATION_011_NFE = "-- 011_nfe.sql \u2014 Nota Fiscal Eletr\u00F4nica (NF-e / NFC-e / NFS-e)\n-- ----------------------------------------------------------------------------\n-- Fiscal documents the merchant issues, tracked against orders. This migration\n-- ships the STRUCTURE only: the actual authorization with SEFAZ / the prefeitura\n-- is delegated to an external emissor (Focus NFe, NFe.io, PlugNotas\u2026) wired\n-- later behind the provider's issueNota. Rows here carry the n\u00FAmero/s\u00E9rie,\n-- status, access key and the links to the authorized XML + DANFE once emitted.\n--\n-- Per-tenant RLS, canonical form (tenant_id IN user_tenant_ids()). Idempotent.\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_nfe (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n -- nfe (55, mercadoria) | nfce (65, consumidor) | nfse (servi\u00E7o)\n model text NOT NULL DEFAULT 'nfe',\n -- draft | processing | issued | cancelled | error\n status text NOT NULL DEFAULT 'draft',\n number text,\n series text,\n order_id uuid,\n contact_id uuid,\n contact_name text,\n total_amount numeric(14,2) NOT NULL DEFAULT 0,\n issued_at timestamptz,\n access_key text,\n xml_url text,\n danfe_url text,\n emissor text,\n error_message text,\n observations text,\n metadata jsonb NOT NULL DEFAULT '{}',\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS idx_plg_financial_nfe_tenant ON public.plg_financial_nfe(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_nfe_order ON public.plg_financial_nfe(order_id);\n\nALTER TABLE public.plg_financial_nfe ENABLE ROW LEVEL SECURITY;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_nfe TO authenticated;\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_nfe' AND policyname='plg_financial_nfe_select') THEN\n CREATE POLICY plg_financial_nfe_select ON public.plg_financial_nfe FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_nfe' AND policyname='plg_financial_nfe_insert') THEN\n CREATE POLICY plg_financial_nfe_insert ON public.plg_financial_nfe FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_nfe' AND policyname='plg_financial_nfe_update') THEN\n CREATE POLICY plg_financial_nfe_update ON public.plg_financial_nfe FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_nfe' AND policyname='plg_financial_nfe_delete') THEN\n CREATE POLICY plg_financial_nfe_delete ON public.plg_financial_nfe FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n END IF;\nEND $$;\n\nNOTIFY pgrst, 'reload schema';\n";
15
15
  export declare const MIGRATION_012_ANALYTICS_READ_MODELS = "-- ============================================================================\n-- @fayz-ai/plugin-financial \u2014 receivables read model for the analytics engine.\n--\n-- v_invoice_balances already answers \"what is still owed\", but it carries BOTH\n-- directions: a dashboard tile over it adds the school's electricity bill to\n-- what its students owe. Payables and receivables are not two filters of one\n-- number, they are two numbers, so the split lives in the read model.\n--\n-- The 0/1 and gated-amount columns exist because analytics_run has no\n-- conditional aggregate (024 keeps the vocabulary fixed on purpose). \"Overdue\n-- balance\" has to be a column or it cannot be a measure.\n--\n-- Idempotent.\n-- ============================================================================\n\nDO $$\nBEGIN\n IF to_regclass('public.v_invoice_balances') IS NULL THEN\n RAISE NOTICE 'financial: v_invoice_balances missing \u2014 read model skipped';\n RETURN;\n END IF;\n\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.plg_financial_rep_receivables\n WITH (security_invoker = true) AS\n SELECT\n b.invoice_id,\n b.tenant_id,\n b.created_at,\n b.reference_number,\n b.kind,\n b.status,\n b.party_id,\n p.name AS party_name,\n b.amount,\n b.paid,\n b.balance,\n -- Cancelled is not \"owed\" and not \"settled\" \u2014 it is nothing, and it has to\n -- stay out of both sums or the two never add up to the total.\n CASE WHEN b.status IN ('open', 'overdue') THEN b.balance ELSE 0 END AS open_balance,\n CASE WHEN b.status = 'overdue' THEN b.balance ELSE 0 END AS overdue_balance,\n CASE WHEN b.status = 'paid' THEN b.amount ELSE 0 END AS settled_amount,\n CASE WHEN b.status = 'overdue' THEN 1 ELSE 0 END AS is_overdue,\n CASE WHEN b.status = 'paid' THEN 1 ELSE 0 END AS is_settled\n FROM public.v_invoice_balances b\n LEFT JOIN public.people p ON p.id = b.party_id\n WHERE b.flow = 'receivable'\n $v$;\n\n GRANT SELECT ON public.plg_financial_rep_receivables TO authenticated;\nEND $$;\n\nDO $$\nBEGIN\n IF to_regclass('public.plg_analytics_read_models') IS NULL\n OR to_regclass('public.plg_financial_rep_receivables') IS NULL THEN\n RETURN;\n END IF;\n\n INSERT INTO public.plg_analytics_read_models (name, date_column, tenant_column, description) VALUES\n ('plg_financial_rep_receivables', 'created_at', 'tenant_id',\n 'One row per receivable invoice, with open/overdue/settled amounts split out')\n ON CONFLICT (name) DO UPDATE\n SET date_column = EXCLUDED.date_column,\n tenant_column = EXCLUDED.tenant_column,\n description = EXCLUDED.description;\nEND $$;\n";
16
- export declare const MIGRATION_013_CHART_ACCOUNT_TYPE = "-- ============================================================================\n-- 013_chart_account_type.sql \u2014 a chart of accounts that knows what an account is.\n--\n-- plg_financial_chart_of_accounts models the tree (code, name, parent_id,\n-- node_type) but not the nature of an account. Without account_type there is no\n-- way to tell revenue from expense from asset, which is what a DRE, a\n-- cost-analysis screen or any grouped report needs first.\n--\n-- DELIBERATELY UNCONSTRAINED. The accounting vocabulary is\n-- asset|liability|equity|revenue|expense, but a CHECK here would be a\n-- production stop the first time a pool carries a localised or extra value, and\n-- this table already exists on live pools with rows nobody has audited. The\n-- vocabulary is enforced in TypeScript, where a bad value degrades a label\n-- instead of refusing a write.\n--\n-- ON THE UNIQUE INDEX. `code` is how humans and every importer identify an\n-- account, and nothing stopped two accounts sharing one. It is also the natural\n-- idempotency key for a re-runnable import. Creating it is guarded rather than\n-- unconditional: on a pool that already contains duplicate codes, failing the\n-- whole apply would be a worse outcome than shipping without the constraint, so\n-- we warn loudly and continue. Resolve the duplicates and re-run to get it.\n-- ============================================================================\n\nALTER TABLE public.plg_financial_chart_of_accounts\n ADD COLUMN IF NOT EXISTS account_type text;\n\nCOMMENT ON COLUMN public.plg_financial_chart_of_accounts.account_type IS 'asset | liability | equity | revenue | expense. Unconstrained on purpose \u2014 see 013 header.';\n\nDO $$\nDECLARE dupes text;\nBEGIN\n SELECT string_agg(DISTINCT code, ', ')\n INTO dupes\n FROM (\n SELECT code FROM public.plg_financial_chart_of_accounts\n GROUP BY tenant_id, code HAVING count(*) > 1\n ) d;\n\n IF dupes IS NOT NULL THEN\n RAISE WARNING 'plg_financial_chart_of_accounts: duplicate code(s) per tenant (%) \u2014 unique index NOT created. Resolve them and re-apply 013 to get it.', dupes;\n ELSE\n CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_financial_chart_of_accounts_code\n ON public.plg_financial_chart_of_accounts (tenant_id, code);\n END IF;\nEND $$;\n\nNOTIFY pgrst, 'reload schema';\n";
17
- export declare const MIGRATION_014_PAYMENT_SETTLES_BILL = "-- ============================================================================\n-- 014_payment_settles_bill.sql \u2014 which bill did this payment actually settle?\n--\n-- 008 split obligations from cash events: a `bill` is what is owed, a `payment`\n-- is money moving. It did not record which bill a payment settles. Today\n-- payMovement() inserts the payment row without any reference to the bill it\n-- came from, and the reader is expected to re-derive the pairing from\n-- (invoice_id, installment_number).\n--\n-- THAT DERIVATION IS NOT SOUND, and the Artorius data we are about to import\n-- proves it. Invoice 176440 carries three bills and two payments, and BOTH\n-- payments carry installment_number = 1 while settling bills #1 and #2. The\n-- legacy system needed a real foreign key for this and had one; re-deriving\n-- silently mis-attributes the money.\n--\n-- Consequences of not having it, beyond the import: two partial payments on one\n-- installment are indistinguishable, so \"undo this payment\" cannot be built,\n-- and a per-bill settlement history cannot be shown.\n--\n-- WHY NOT REUSE matched_movement_id. That column belongs to 007's bank\n-- reconciliation, and unreconcileMovement() sets it to NULL. Storing the\n-- payment\u2192bill link there would mean an unrelated \"undo reconciliation\" click\n-- erases it \u2014 silent data loss with no error and no audit trail.\n--\n-- ON DELETE SET NULL rather than CASCADE: deleting a bill must not delete the\n-- record that money changed hands.\n--\n-- The backfill uses 008's own marker, so every payment row 008 generated gets\n-- its link for free. Payments created by the app between 008 and this file have\n-- no marker and stay NULL \u2014 correctly, since for those the pairing is exactly\n-- the ambiguous derivation this column exists to replace. Do not guess them.\n-- ============================================================================\n\nALTER TABLE public.plg_financial_movements\n ADD COLUMN IF NOT EXISTS settles_movement_id uuid\n REFERENCES public.plg_financial_movements(id) ON DELETE SET NULL;\n\nCOMMENT ON COLUMN public.plg_financial_movements.settles_movement_id IS 'The movement_kind=''bill'' row this payment settles. Not matched_movement_id \u2014 that is 007 reconciliation and unreconcileMovement() nulls it.';\n\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_settles\n ON public.plg_financial_movements (settles_movement_id)\n WHERE settles_movement_id IS NOT NULL;\n\nUPDATE public.plg_financial_movements p\n SET settles_movement_id = (p.metadata->>'backfilledFromBill')::uuid\n WHERE p.movement_kind = 'payment'\n AND p.settles_movement_id IS NULL\n AND p.metadata ? 'backfilledFromBill'\n AND EXISTS (\n SELECT 1 FROM public.plg_financial_movements b\n WHERE b.id = (p.metadata->>'backfilledFromBill')::uuid\n );\n\nNOTIFY pgrst, 'reload schema';\n";
18
- export declare const MIGRATION_015_ORDER_ITEM_ACCOUNT_INDEX = "-- ============================================================================\n-- 015_order_item_account_index.sql \u2014 make the per-item account filter cheap.\n--\n-- An invoice line's chart account and cost centre live in\n-- `public.order_items.metadata` as accountId / costCenterId. The provider\n-- already writes them, reads them back, and filters on them \u2014\n-- findOrderIdsByItemMetadata() runs `metadata->>'accountId' = ...` to answer\n-- \"which invoices touched this account\". That was a sequential scan over every\n-- line item in the pool.\n--\n-- WHY METADATA STAYS THE STORAGE. `order_items` is a spine archetype shared by\n-- every plugin. A real `account_id` column on it would have to reference\n-- plg_financial_chart_of_accounts \u2014 a spine table FK-ing a plugin prefix, which\n-- dangles the moment financial is uninstalled. The round-trip through metadata\n-- already works and is graded by data/capability.test.ts; what was missing was\n-- only the index.\n--\n-- Partial on `metadata ? key` so the index covers only the rows that carry the\n-- key \u2014 most order_items in a non-financial app carry neither.\n-- ============================================================================\n\nCREATE INDEX IF NOT EXISTS idx_order_items_account\n ON public.order_items ((metadata->>'accountId'))\n WHERE metadata ? 'accountId';\n\nCREATE INDEX IF NOT EXISTS idx_order_items_cost_center\n ON public.order_items ((metadata->>'costCenterId'))\n WHERE metadata ? 'costCenterId';\n";
16
+ export declare const MIGRATION_013_CHART_ACCOUNT_TYPE = "-- ============================================================================\n-- 017_chart_account_type.sql \u2014 a chart of accounts that knows what an account is.\n--\n-- plg_financial_chart_of_accounts models the tree (code, name, parent_id,\n-- node_type) but not the nature of an account. Without account_type there is no\n-- way to tell revenue from expense from asset, which is what a DRE, a\n-- cost-analysis screen or any grouped report needs first.\n--\n-- DELIBERATELY UNCONSTRAINED. The accounting vocabulary is\n-- asset|liability|equity|revenue|expense, but a CHECK here would be a\n-- production stop the first time a pool carries a localised or extra value, and\n-- this table already exists on live pools with rows nobody has audited. The\n-- vocabulary is enforced in TypeScript, where a bad value degrades a label\n-- instead of refusing a write.\n--\n-- ON THE UNIQUE INDEX. `code` is how humans and every importer identify an\n-- account, and nothing stopped two accounts sharing one. It is also the natural\n-- idempotency key for a re-runnable import. Creating it is guarded rather than\n-- unconditional: on a pool that already contains duplicate codes, failing the\n-- whole apply would be a worse outcome than shipping without the constraint, so\n-- we warn loudly and continue. Resolve the duplicates and re-run to get it.\n-- ============================================================================\n\nALTER TABLE public.plg_financial_chart_of_accounts\n ADD COLUMN IF NOT EXISTS account_type text;\n\nCOMMENT ON COLUMN public.plg_financial_chart_of_accounts.account_type IS 'asset | liability | equity | revenue | expense. Unconstrained on purpose \u2014 see 013 header.';\n\nDO $$\nDECLARE dupes text;\nBEGIN\n SELECT string_agg(DISTINCT code, ', ')\n INTO dupes\n FROM (\n SELECT code FROM public.plg_financial_chart_of_accounts\n GROUP BY tenant_id, code HAVING count(*) > 1\n ) d;\n\n IF dupes IS NOT NULL THEN\n RAISE WARNING 'plg_financial_chart_of_accounts: duplicate code(s) per tenant (%) \u2014 unique index NOT created. Resolve them and re-apply 013 to get it.', dupes;\n ELSE\n CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_financial_chart_of_accounts_code\n ON public.plg_financial_chart_of_accounts (tenant_id, code);\n END IF;\nEND $$;\n\nNOTIFY pgrst, 'reload schema';\n";
17
+ export declare const MIGRATION_014_PAYMENT_SETTLES_BILL = "-- ============================================================================\n-- 018_payment_settles_bill.sql \u2014 which bill did this payment actually settle?\n--\n-- 008 split obligations from cash events: a `bill` is what is owed, a `payment`\n-- is money moving. It did not record which bill a payment settles. Today\n-- payMovement() inserts the payment row without any reference to the bill it\n-- came from, and the reader is expected to re-derive the pairing from\n-- (invoice_id, installment_number).\n--\n-- THAT DERIVATION IS NOT SOUND, and the Artorius data we are about to import\n-- proves it. Invoice 176440 carries three bills and two payments, and BOTH\n-- payments carry installment_number = 1 while settling bills #1 and #2. The\n-- legacy system needed a real foreign key for this and had one; re-deriving\n-- silently mis-attributes the money.\n--\n-- Consequences of not having it, beyond the import: two partial payments on one\n-- installment are indistinguishable, so \"undo this payment\" cannot be built,\n-- and a per-bill settlement history cannot be shown.\n--\n-- WHY NOT REUSE matched_movement_id. That column belongs to 007's bank\n-- reconciliation, and unreconcileMovement() sets it to NULL. Storing the\n-- payment\u2192bill link there would mean an unrelated \"undo reconciliation\" click\n-- erases it \u2014 silent data loss with no error and no audit trail.\n--\n-- ON DELETE SET NULL rather than CASCADE: deleting a bill must not delete the\n-- record that money changed hands.\n--\n-- The backfill uses 008's own marker, so every payment row 008 generated gets\n-- its link for free. Payments created by the app between 008 and this file have\n-- no marker and stay NULL \u2014 correctly, since for those the pairing is exactly\n-- the ambiguous derivation this column exists to replace. Do not guess them.\n-- ============================================================================\n\nALTER TABLE public.plg_financial_movements\n ADD COLUMN IF NOT EXISTS settles_movement_id uuid\n REFERENCES public.plg_financial_movements(id) ON DELETE SET NULL;\n\nCOMMENT ON COLUMN public.plg_financial_movements.settles_movement_id IS 'The movement_kind=''bill'' row this payment settles. Not matched_movement_id \u2014 that is 007 reconciliation and unreconcileMovement() nulls it.';\n\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_settles\n ON public.plg_financial_movements (settles_movement_id)\n WHERE settles_movement_id IS NOT NULL;\n\nUPDATE public.plg_financial_movements p\n SET settles_movement_id = (p.metadata->>'backfilledFromBill')::uuid\n WHERE p.movement_kind = 'payment'\n AND p.settles_movement_id IS NULL\n AND p.metadata ? 'backfilledFromBill'\n AND EXISTS (\n SELECT 1 FROM public.plg_financial_movements b\n WHERE b.id = (p.metadata->>'backfilledFromBill')::uuid\n );\n\nNOTIFY pgrst, 'reload schema';\n";
18
+ export declare const MIGRATION_015_ORDER_ITEM_ACCOUNT_INDEX = "-- ============================================================================\n-- 019_order_item_account_index.sql \u2014 make the per-item account filter cheap.\n--\n-- An invoice line's chart account and cost centre live in\n-- `public.order_items.metadata` as accountId / costCenterId. The provider\n-- already writes them, reads them back, and filters on them \u2014\n-- findOrderIdsByItemMetadata() runs `metadata->>'accountId' = ...` to answer\n-- \"which invoices touched this account\". That was a sequential scan over every\n-- line item in the pool.\n--\n-- WHY METADATA STAYS THE STORAGE. `order_items` is a spine archetype shared by\n-- every plugin. A real `account_id` column on it would have to reference\n-- plg_financial_chart_of_accounts \u2014 a spine table FK-ing a plugin prefix, which\n-- dangles the moment financial is uninstalled. The round-trip through metadata\n-- already works and is graded by data/capability.test.ts; what was missing was\n-- only the index.\n--\n-- Partial on `metadata ? key` so the index covers only the rows that carry the\n-- key \u2014 most order_items in a non-financial app carry neither.\n-- ============================================================================\n\nCREATE INDEX IF NOT EXISTS idx_order_items_account\n ON public.order_items ((metadata->>'accountId'))\n WHERE metadata ? 'accountId';\n\nCREATE INDEX IF NOT EXISTS idx_order_items_cost_center\n ON public.order_items ((metadata->>'costCenterId'))\n WHERE metadata ? 'costCenterId';\n";
19
+ export declare const MIGRATION_016_SCAFFOLD = "-- 016_scaffold.sql \u2014 the financial tables under the ONE RLS template (043\n-- app.scaffold_table), in SHADOW mode (PRD 01 R6 / #135).\n--\n-- Shadow keeps the canonical tenant policies of 004/006 and ADDS the\n-- <t>_authz_* template policies; app.shadow_report() compares both per member;\n-- the O2C slice (or an operator, app.scaffold_set_mode) flips to 'enforce' on\n-- evidence \u2014 both calls are idempotent and converge. Every table is guarded\n-- with to_regclass so the file is safe on a pool at any earlier version.\n--\n-- Owner scoping: cash register sessions are \"caixa pr\u00F3prio\" \u2014 owner_id is\n-- backfilled from opened_by_user_id when that user is a member of the tenant\n-- (financial.read_all sees the others'). Everything else is unit/tenant scoped.\n-- A pre-existing unit_id that does not resolve to an app.units id of the same\n-- tenant is parked in app.legacy_unit_quarantine and nulled (055) so the\n-- composite FK can be added without losing the value.\nDO $$\nDECLARE\n t record;\n v_n integer;\nBEGIN\n IF to_regprocedure('app.scaffold_table(regclass, text, text, boolean, text)') IS NULL THEN\n RAISE NOTICE 'plugin-financial 012: app.scaffold_table missing (spine < 043) \u2014 skipped';\n RETURN;\n END IF;\n FOR t IN SELECT * FROM (VALUES\n ('plg_financial_payment_method_types', 'financial.payment_method_type', false),\n ('plg_financial_payment_methods', 'financial.payment_method', false),\n ('plg_financial_bank_accounts', 'financial.bank_account', false),\n ('plg_financial_cash_register_sessions','financial.cash_register_session', true),\n ('plg_financial_movements', 'financial.movement', false),\n ('plg_financial_chart_of_accounts', 'financial.chart_of_account', false),\n ('plg_financial_cost_centers', 'financial.cost_center', false),\n ('plg_financial_card_brands', 'financial.card_brand', false),\n ('plg_financial_nfe', 'financial.nfe', false)\n ) AS v(table_name, resource_type, owner_scoping)\n LOOP\n IF to_regclass('public.' || t.table_name) IS NULL THEN CONTINUE; END IF;\n IF to_regprocedure('app.quarantine_legacy_unit_ids(regclass)') IS NOT NULL THEN\n v_n := app.quarantine_legacy_unit_ids(('public.' || t.table_name)::regclass);\n IF v_n > 0 THEN RAISE NOTICE 'plugin-financial 012: % legacy unit_id value(s) of % parked in app.legacy_unit_quarantine', v_n, t.table_name; END IF;\n END IF;\n PERFORM app.scaffold_table(('public.' || t.table_name)::regclass, t.resource_type, 'financial', t.owner_scoping, 'shadow');\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t.table_name);\n EXECUTE format('GRANT ALL ON public.%I TO service_role', t.table_name);\n END LOOP;\n\n -- owner_id of a cash session = who opened it, when that user is a member\n IF to_regclass('public.plg_financial_cash_register_sessions') IS NOT NULL\n AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'plg_financial_cash_register_sessions' AND column_name = 'opened_by_user_id') THEN\n UPDATE public.plg_financial_cash_register_sessions s SET owner_id = s.opened_by_user_id\n WHERE s.owner_id IS NULL AND s.opened_by_user_id IS NOT NULL\n AND EXISTS (SELECT 1 FROM app.memberships m WHERE m.tenant_id = s.tenant_id AND m.user_id = s.opened_by_user_id);\n END IF;\nEND $$;\n";
20
+ export declare const MIGRATION_017_INVOICES = "-- ============================================================================\n-- plugin-financial 013 \u2014 the invoice is a first-class financial document (#141).\n--\n-- ADR 0004 (amended): the SDK's order-to-cash spine no longer models a\n-- receivable/payable as `orders kind='invoice_*'`. An invoice (receivable or\n-- payable) is a row of plg_financial_invoices with its parcelas in\n-- plg_financial_invoice_installments; it may point at a Venda (order_id) \u2014 one\n-- sale can raise N invoices \u2014 or stand alone (a bill to pay). Its status is never\n-- stored: it derives from the ledger (installments \u2212 allocations, 018) through\n-- the canonical views v_financial_invoice_balances / v_financial_installment_balances.\n--\n-- Numbering: `number` comes from the tenant's own sequence (kinds\n-- 'invoice_receivable' \u2192 REC-000001, 'invoice_payable' \u2192 PAG-000001) through\n-- next_sequence_internal, so an invoice is never numbered by the client.\n--\n-- Both tables are on the authz template (app.scaffold_table, plugin\n-- 'financial', owner scoping off, enforce): reading needs financial.read,\n-- writing financial.create/edit/delete, and the row's unit must be one of the\n-- caller's units. Idempotent and replay-safe.\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_invoices (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n direction text NOT NULL CHECK (direction IN ('receivable', 'payable')),\n counterparty_person_id uuid REFERENCES public.people(id) ON DELETE SET NULL,\n order_id uuid REFERENCES public.orders(id) ON DELETE SET NULL,\n number text,\n competence_date date NOT NULL DEFAULT current_date,\n issue_date date NOT NULL DEFAULT current_date,\n total numeric(14,2) NOT NULL CHECK (total >= 0),\n currency text NOT NULL DEFAULT 'BRL',\n description text,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n idempotency_key text,\n cancelled_at timestamptz,\n cancel_reason text,\n created_by uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- the scaffold's own name for it, so app.scaffold_table finds it in place\n CONSTRAINT plg_financial_invoices_tenant_id_id_key UNIQUE (tenant_id, id)\n);\nCREATE UNIQUE INDEX IF NOT EXISTS plg_financial_invoices_idempotency_uq\n ON public.plg_financial_invoices (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL;\nCREATE UNIQUE INDEX IF NOT EXISTS plg_financial_invoices_number_uq\n ON public.plg_financial_invoices (tenant_id, number) WHERE number IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_financial_invoices_order_idx ON public.plg_financial_invoices (tenant_id, order_id) WHERE order_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_financial_invoices_person_idx ON public.plg_financial_invoices (tenant_id, counterparty_person_id) WHERE counterparty_person_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_financial_invoices_competence_idx ON public.plg_financial_invoices (tenant_id, direction, competence_date);\nDO $$\nBEGIN\n -- The Venda of an invoice lives in the same tenant (orders is scaffolded by spine 081).\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_invoices'::regclass AND conname = 'plg_financial_invoices_tenant_order_fk') THEN\n ALTER TABLE public.plg_financial_invoices ADD CONSTRAINT plg_financial_invoices_tenant_order_fk\n FOREIGN KEY (tenant_id, order_id) REFERENCES public.orders(tenant_id, id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_invoice_installments (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n invoice_id uuid NOT NULL,\n number integer NOT NULL CHECK (number >= 1),\n due_date date NOT NULL,\n amount numeric(14,2) NOT NULL CHECK (amount >= 0),\n cancelled_at timestamptz,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (invoice_id, number),\n FOREIGN KEY (tenant_id, invoice_id) REFERENCES public.plg_financial_invoices(tenant_id, id) ON DELETE CASCADE\n);\nCREATE INDEX IF NOT EXISTS plg_financial_invoice_installments_due_idx ON public.plg_financial_invoice_installments (tenant_id, due_date);\nCREATE INDEX IF NOT EXISTS plg_financial_invoice_installments_invoice_idx ON public.plg_financial_invoice_installments (invoice_id, number);\n\n-- An installment belongs to its invoice's tenant and unit \u2014 inherited, never\n-- claimed by the client.\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_installments_inherit()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE v_tenant uuid; v_unit uuid;\nBEGIN\n SELECT t.tenant_id, t.unit_id INTO v_tenant, v_unit FROM public.plg_financial_invoices t WHERE t.id = NEW.invoice_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'plg_financial_invoice_installments: invoice % not found', NEW.invoice_id USING ERRCODE = '23503';\n END IF;\n IF NEW.tenant_id IS NOT NULL AND NEW.tenant_id <> v_tenant THEN\n RAISE EXCEPTION 'plg_financial_invoice_installments: tenant_id must be the invoice''s tenant' USING ERRCODE = '23514';\n END IF;\n NEW.tenant_id := v_tenant;\n IF NEW.unit_id IS NULL THEN NEW.unit_id := v_unit; END IF;\n RETURN NEW;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_installments_inherit() FROM public, anon, authenticated;\nDROP TRIGGER IF EXISTS plg_financial_invoice_installments_inherit ON public.plg_financial_invoice_installments;\nCREATE TRIGGER plg_financial_invoice_installments_inherit BEFORE INSERT OR UPDATE OF invoice_id, tenant_id ON public.plg_financial_invoice_installments\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_installments_inherit();\n\n-- \u2500\u2500 authz template \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nSELECT app.scaffold_table('public.plg_financial_invoices', 'financial.invoice', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_invoice_installments', 'financial.invoice_installment', 'financial', false, 'enforce');\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_invoices, public.plg_financial_invoice_installments TO authenticated;\nGRANT ALL ON public.plg_financial_invoices, public.plg_financial_invoice_installments TO service_role;\n\n-- \u2500\u2500 numbering: the sequence is tenant-authorized \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- next_sequence(tenant, kind) (004) is SECURITY DEFINER because public.sequences\n-- is deny-all; it took the tenant from the caller and trusted it. Two entry\n-- points now: the internal one (no grant \u2014 reachable only from definer bodies\n-- that already established the tenant: fn_invoice_from_order_internal, the\n-- invoice RPCs, the shop's checkout chain) and the public one, which only serves\n-- the caller's own tenant.\nCREATE OR REPLACE FUNCTION public.next_sequence_internal(p_tenant_id uuid, p_kind text)\nRETURNS bigint\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE v_next bigint;\nBEGIN\n INSERT INTO public.sequences (tenant_id, kind, current_value)\n VALUES (p_tenant_id, p_kind, 1)\n ON CONFLICT (tenant_id, kind)\n DO UPDATE SET current_value = public.sequences.current_value + 1\n RETURNING current_value INTO v_next;\n RETURN v_next;\nEND $$;\nREVOKE ALL ON FUNCTION public.next_sequence_internal(uuid, text) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION public.next_sequence(p_tenant_id uuid, p_kind text)\nRETURNS bigint\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nBEGIN\n IF p_tenant_id IS NULL THEN\n RAISE EXCEPTION 'next_sequence: tenant required' USING ERRCODE = '22023';\n END IF;\n IF NOT (p_tenant_id = app.current_tenant_id()\n OR p_tenant_id IN (SELECT public.user_tenant_ids())\n OR coalesce(current_setting('request.jwt.claim.role', true), '') = 'service_role'\n OR coalesce(auth.jwt() ->> 'role', '') = 'service_role') THEN\n RAISE EXCEPTION 'next_sequence: forbidden for tenant %', p_tenant_id USING ERRCODE = '42501';\n END IF;\n RETURN public.next_sequence_internal(p_tenant_id, p_kind);\nEND $$;\nREVOKE ALL ON FUNCTION public.next_sequence(uuid, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.next_sequence(uuid, text) TO authenticated, service_role;\n\n-- Human-readable invoice number for a tenant/direction: REC-000001 / PAG-000001.\nCREATE OR REPLACE FUNCTION public.plg_financial_next_invoice_number(p_tenant_id uuid, p_direction text)\nRETURNS text\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE v_seq bigint;\nBEGIN\n IF p_direction = 'payable' THEN\n v_seq := public.next_sequence_internal(p_tenant_id, 'invoice_payable');\n RETURN 'PAG-' || lpad(v_seq::text, 6, '0');\n END IF;\n v_seq := public.next_sequence_internal(p_tenant_id, 'invoice_receivable');\n RETURN 'REC-' || lpad(v_seq::text, 6, '0');\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_next_invoice_number(uuid, text) FROM public, anon, authenticated;\n\nNOTIFY pgrst, 'reload schema';\n";
21
+ export declare const MIGRATION_018_SPINE_INVOICE_FROM_ORDER = "-- ============================================================================\n-- plugin-financial 014 \u2014 the order-to-cash spine now raises an invoice (#141).\n--\n-- fn_invoice_from_order / fn_invoice_from_order_internal keep their signatures\n-- (uuid in, uuid out; the CRM's approveQuote, the agenda's financial bridge,\n-- the agent RPC 009 and the shop's checkout trigger 0023 call them unchanged)\n-- but their body changes: promoting a Venda creates a receivable/payable\n-- invoice with N installments in plg_financial_invoices / _invoice_installments\n-- instead of 'bill' rows in the movements ledger.\n--\n-- Legacy compat, on this entry point ONLY: every installment raised here is\n-- mirrored as a movement_kind='bill' row (metadata.legacy_mirror = true,\n-- financial_invoice_installment_id set) because the shop's shop_confirm_payment\n-- (0023), the agent RPC (009), the agenda's payment badge and the CRM read and settle\n-- per-order 'bill' rows in plg_financial_movements. The mirror is derived \u2014 its\n-- paid_amount/status are kept in step from allocations (018) \u2014 never a second\n-- source of truth. Invoices raised through the new RPCs (015) have no mirror.\n--\n-- v_invoice_balances keeps its column set (invoice_id = order id) as a compat\n-- view over invoices; the canonical read models are v_financial_invoice_balances\n-- and v_financial_installment_balances (redefined over allocations in 018).\n-- Idempotent and replay-safe.\n-- ============================================================================\n\n-- \u2500\u2500 movements learn where their invoice is \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS financial_invoice_id uuid;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS financial_invoice_installment_id uuid;\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_invoice_fk') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_invoice_fk\n FOREIGN KEY (financial_invoice_id) REFERENCES public.plg_financial_invoices(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_installment_fk') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_installment_fk\n FOREIGN KEY (financial_invoice_installment_id) REFERENCES public.plg_financial_invoice_installments(id) ON DELETE SET NULL;\n END IF;\nEND $$;\nCREATE INDEX IF NOT EXISTS plg_financial_movements_installment_idx ON public.plg_financial_movements (financial_invoice_installment_id) WHERE financial_invoice_installment_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_financial_movements_invoice_idx ON public.plg_financial_movements (financial_invoice_id) WHERE financial_invoice_id IS NOT NULL;\n\n-- \u2500\u2500 the body: Venda \u2192 invoice (+ legacy mirror) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order_internal(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_invoice_id uuid;\n v_inst_id uuid;\n v_dir text;\n v_number text;\n v_summary text;\n v_contact text;\n v_n int := GREATEST(COALESCE(p_installments, 1), 1);\n v_total numeric;\n v_base numeric;\n v_amt numeric;\n v_alloc numeric := 0;\n v_due date;\n i int;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;\n\n -- Idempotent: an order that already carries a live invoice is already invoiced;\n -- so is one that carries legacy 'bill' rows (pools promoted before 016).\n SELECT id INTO v_invoice_id FROM public.plg_financial_invoices\n WHERE order_id = p_order_id AND cancelled_at IS NULL LIMIT 1;\n IF v_invoice_id IS NOT NULL THEN RETURN p_order_id; END IF;\n IF EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE invoice_id = p_order_id AND movement_kind = 'bill') THEN\n RETURN p_order_id;\n END IF;\n\n v_dir := CASE WHEN p_direction = 'debit' THEN 'payable' ELSE 'receivable' END;\n v_number := public.plg_financial_next_invoice_number(v_order.tenant_id, v_dir);\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.order_items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n -- The order keeps recording that it was invoiced (stage/direction) and which\n -- number it received \u2014 the same keys the CRM, the agenda and the shop read.\n UPDATE public.orders SET\n status = COALESCE(p_status, status),\n stage = 'invoiced',\n direction = p_direction,\n reference_number = v_number,\n metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(\n 'itemsSummary', COALESCE(v_summary, ''),\n 'contactName', v_contact,\n 'installmentCount', v_n,\n 'quoteNumber', v_order.reference_number,\n 'direction', p_direction,\n 'invoiceNumber', v_number\n ),\n updated_at = now()\n WHERE id = p_order_id;\n\n v_total := round(COALESCE(v_order.total, 0), 2);\n v_due := COALESCE(p_due_date, current_date);\n\n INSERT INTO public.plg_financial_invoices\n (tenant_id, unit_id, direction, counterparty_person_id, order_id, number, competence_date, issue_date,\n total, description, metadata, created_by)\n VALUES\n (v_order.tenant_id, v_order.unit_id, v_dir, v_order.party_id, p_order_id, v_number,\n COALESCE(v_order.created_at::date, current_date), current_date,\n v_total, NULLIF(v_summary, ''),\n jsonb_build_object('source', 'fn_invoice_from_order', 'legacy_mirror', true, 'orderKind', v_order.kind),\n auth.uid())\n RETURNING id INTO v_invoice_id;\n\n v_base := round(v_total / v_n, 2);\n FOR i IN 1..v_n LOOP\n IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);\n ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;\n\n INSERT INTO public.plg_financial_invoice_installments (invoice_id, number, due_date, amount)\n VALUES (v_invoice_id, i, (v_due + ((i - 1) || ' months')::interval)::date, v_amt)\n RETURNING id INTO v_inst_id;\n\n -- legacy mirror (see header): the per-order 'bill' row legacy readers expect\n INSERT INTO public.plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number,\n financial_invoice_id, financial_invoice_installment_id, metadata)\n VALUES\n (v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',\n (v_due + ((i - 1) || ' months')::interval)::date, i,\n v_invoice_id, v_inst_id, jsonb_build_object('legacy_mirror', true));\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\nREVOKE ALL ON FUNCTION public.fn_invoice_from_order_internal(uuid, date, text, int, text) FROM PUBLIC, anon, authenticated;\n\n-- The public entry point: unchanged signature; authorization is the authz core\n-- only \u2014 financial.create in the order's unit. (An earlier draft kept the legacy\n-- tenant_members seam as a fallback; once #134 makes every member resolve\n-- through user_tenant_ids() that fallback is a bypass of the permission model,\n-- so it is gone: a role without financial.create cannot invoice.)\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $$\nDECLARE v_tenant uuid; v_unit uuid;\nBEGIN\n SELECT tenant_id, unit_id INTO v_tenant, v_unit FROM public.orders WHERE id = p_order_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id;\n END IF;\n IF NOT (v_tenant = app.current_tenant_id() AND app.has_permission('financial.create', v_unit)) THEN\n RAISE EXCEPTION 'fn_invoice_from_order: forbidden' USING ERRCODE = '42501';\n END IF;\n\n RETURN public.fn_invoice_from_order_internal(\n p_order_id, p_due_date, p_status, p_installments, p_direction);\nEND;\n$$;\nREVOKE ALL ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated, service_role;\n\n-- \u2500\u2500 canonical read models (paid basis: linked payment movements; 018 moves it\n-- to allocations with the same columns) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE VIEW public.v_financial_installment_balances AS\nWITH paid AS (\n SELECT m.financial_invoice_installment_id, sum(m.amount) AS paid\n FROM public.plg_financial_movements m\n WHERE m.movement_kind = 'payment' AND m.status <> 'cancelled' AND m.financial_invoice_installment_id IS NOT NULL\n GROUP BY m.financial_invoice_installment_id\n)\nSELECT\n i.id AS installment_id, i.tenant_id, i.unit_id, i.invoice_id, t.number AS invoice_number, t.direction,\n t.counterparty_person_id, t.order_id, i.number, i.due_date, i.amount,\n COALESCE(p.paid, 0)::numeric(14,2) AS paid,\n CASE WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 0\n ELSE GREATEST(i.amount - COALESCE(p.paid, 0), 0) END::numeric(14,2) AS balance,\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(p.paid, 0) >= i.amount THEN 'paid'\n WHEN COALESCE(p.paid, 0) > 0 THEN 'partial'\n WHEN i.due_date < current_date THEN 'overdue'\n ELSE 'pending'\n END AS status,\n i.cancelled_at, i.metadata, i.created_at, i.updated_at\nFROM public.plg_financial_invoice_installments i\nJOIN public.plg_financial_invoices t ON t.id = i.invoice_id\nLEFT JOIN paid p ON p.financial_invoice_installment_id = i.id;\nALTER VIEW public.v_financial_installment_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_installment_balances TO authenticated, service_role;\n\nCREATE OR REPLACE VIEW public.v_financial_invoice_balances AS\nWITH ib AS (\n SELECT invoice_id,\n count(*) FILTER (WHERE status <> 'cancelled') AS installment_count,\n sum(amount) FILTER (WHERE status <> 'cancelled') AS billed,\n sum(paid) AS paid,\n bool_or(status = 'overdue') AS has_overdue,\n min(due_date) FILTER (WHERE status IN ('pending', 'partial', 'overdue')) AS next_due_date,\n max(due_date) FILTER (WHERE status <> 'cancelled') AS last_due_date\n FROM public.v_financial_installment_balances\n GROUP BY invoice_id\n)\nSELECT\n t.id, t.tenant_id, t.unit_id, t.direction, t.number, t.counterparty_person_id,\n p.name AS counterparty_name, t.order_id, t.competence_date, t.issue_date, t.total,\n COALESCE(ib.billed, 0)::numeric(14,2) AS billed,\n COALESCE(ib.paid, 0)::numeric(14,2) AS paid,\n CASE WHEN t.cancelled_at IS NOT NULL THEN 0\n ELSE GREATEST(COALESCE(ib.billed, t.total, 0) - COALESCE(ib.paid, 0), 0) END::numeric(14,2) AS balance,\n CASE\n WHEN t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(ib.billed, t.total, 0) = 0 THEN 'paid'\n WHEN COALESCE(ib.paid, 0) >= COALESCE(ib.billed, t.total, 0) THEN 'paid'\n WHEN COALESCE(ib.paid, 0) > 0 THEN 'partial'\n WHEN COALESCE(ib.has_overdue, false) THEN 'overdue'\n ELSE 'open'\n END AS status,\n COALESCE(ib.installment_count, 0)::int AS installment_count,\n ib.next_due_date, ib.last_due_date,\n t.description, t.notes, t.metadata, t.idempotency_key, t.cancelled_at, t.cancel_reason,\n t.created_by, t.created_at, t.updated_at\nFROM public.plg_financial_invoices t\nLEFT JOIN ib ON ib.invoice_id = t.id\nLEFT JOIN public.people p ON p.id = t.counterparty_person_id;\nALTER VIEW public.v_financial_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_invoice_balances TO authenticated, service_role;\n\n-- \u2500\u2500 v_invoice_balances: the compat view, same columns as 004 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- invoice_id is the ORDER id (a Venda may carry N invoices: aggregated per order);\n-- kind is the order's kind, reference_number the (first) invoice number.\nCREATE OR REPLACE VIEW public.v_invoice_balances AS\nWITH tb AS (\n SELECT t.order_id,\n min(t.number) AS reference_number,\n min(t.counterparty_person_id::text)::uuid AS party_id,\n min(t.direction) AS flow,\n sum(t.billed) AS amount,\n sum(t.paid) AS paid,\n sum(t.balance) AS balance,\n bool_and(t.status = 'cancelled') AS all_cancelled,\n bool_or(t.status = 'overdue') AS has_overdue,\n min(t.created_at) AS created_at,\n max(t.updated_at) AS updated_at\n FROM public.v_financial_invoice_balances t\n WHERE t.order_id IS NOT NULL\n GROUP BY t.order_id\n)\nSELECT\n o.id AS invoice_id, o.tenant_id, o.kind, tb.reference_number, tb.party_id,\n CASE WHEN tb.flow = 'payable' THEN 'debit' ELSE 'credit' END AS direction,\n tb.flow,\n COALESCE(tb.amount, 0)::numeric AS amount,\n COALESCE(tb.paid, 0)::numeric AS paid,\n CASE WHEN o.status = 'cancelled' OR o.stage = 'cancelled' OR tb.all_cancelled THEN 0\n ELSE COALESCE(tb.balance, 0) END::numeric AS balance,\n CASE\n WHEN o.status = 'cancelled' OR o.stage = 'cancelled' OR tb.all_cancelled THEN 'cancelled'\n WHEN COALESCE(tb.amount, 0) = 0 THEN 'open'\n WHEN COALESCE(tb.paid, 0) >= COALESCE(tb.amount, 0) THEN 'paid'\n WHEN COALESCE(tb.paid, 0) > 0 THEN 'partial'\n WHEN COALESCE(tb.has_overdue, false) THEN 'overdue'\n ELSE 'open'\n END AS status,\n tb.created_at, tb.updated_at\nFROM tb\nJOIN public.orders o ON o.id = tb.order_id;\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated, service_role;\n\nNOTIFY pgrst, 'reload schema';\n";
22
+ export declare const MIGRATION_019_INVOICE_RPCS = "-- ============================================================================\n-- plugin-financial 015 \u2014 the invoice's transactional RPCs (#141).\n--\n-- Every mutating RPC of the financial plugin follows the same contract:\n-- SECURITY DEFINER, search_path = '', actor = auth.uid(), tenant =\n-- app.current_tenant_id() (never a parameter), permission checked with\n-- app.has_permission(...) \u2192 ERRCODE 42501, an idempotency key that makes a\n-- replay return the same result without a second effect, and one audit row in\n-- public.audit_logs (action / entity / metadata; the audit hardening itself is\n-- PRD 07's slice).\n--\n-- financial_create_invoice(direction, total, \u2026installments jsonb\u2026) \u2192 jsonb\n-- financial_cancel_invoice(invoice_id, reason, idempotency_key) \u2192 jsonb\n--\n-- An invoice's items are the Venda's order_items when it has an order; one\n-- without an order (a bill to pay) keeps a free-form `items` array in metadata.\n-- Idempotent and replay-safe.\n-- ============================================================================\n\n-- \u2500\u2500 shared helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.plg_financial_audit(\n p_tenant uuid, p_action text, p_entity_type text, p_entity_id text, p_metadata jsonb DEFAULT '{}'::jsonb\n) RETURNS void\nLANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$\n INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant, auth.uid(), p_action, p_entity_type, p_entity_id, coalesce(p_metadata, '{}'::jsonb));\n$$;\nREVOKE ALL ON FUNCTION public.plg_financial_audit(uuid, text, text, text, jsonb) FROM public, anon, authenticated;\n\n-- The tenant of the session, or 42501. Every RPC opens with it.\nCREATE OR REPLACE FUNCTION public.plg_financial_require_tenant()\nRETURNS uuid\nLANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nDECLARE v_tenant uuid := app.current_tenant_id();\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial: no active tenant for this session' USING ERRCODE = '42501';\n END IF;\n RETURN v_tenant;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_require_tenant() FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION public.plg_financial_require_permission(p_perm text, p_unit uuid DEFAULT NULL)\nRETURNS void\nLANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF NOT app.has_permission(p_perm, p_unit) THEN\n RAISE EXCEPTION 'financial: permission % required', p_perm USING ERRCODE = '42501';\n END IF;\n IF p_unit IS NOT NULL AND NOT app.has_unit(p_unit) THEN\n RAISE EXCEPTION 'financial: unit % is outside your scope', p_unit USING ERRCODE = '42501';\n END IF;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_require_permission(text, uuid) FROM public, anon, authenticated;\n\n-- \u2500\u2500 financial_create_invoice \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- p_installments: [{\"number\": 1, \"due_date\": \"2026-08-30\", \"amount\": 1000.00}, \u2026]\n-- (NULL \u2192 one installment for the total, due on p_competence_date or today).\n-- The installments must sum to p_total to the cent. Returns\n-- {invoice_id, number, installments: [{id, number, due_date, amount}], replayed}.\nCREATE OR REPLACE FUNCTION public.financial_create_invoice(\n p_direction text,\n p_total numeric,\n p_counterparty_person_id uuid DEFAULT NULL,\n p_order_id uuid DEFAULT NULL,\n p_unit_id uuid DEFAULT NULL,\n p_installments jsonb DEFAULT NULL,\n p_competence_date date DEFAULT NULL,\n p_description text DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_metadata jsonb DEFAULT '{}'::jsonb,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_unit uuid := p_unit_id;\n v_order public.orders%ROWTYPE;\n v_invoice_id uuid;\n v_number text;\n v_total numeric := round(coalesce(p_total, 0), 2);\n v_insts jsonb;\n v_sum numeric := 0;\n v_i jsonb;\n v_n int := 0;\n v_out jsonb := '[]'::jsonb;\n v_inst_id uuid;\nBEGIN\n IF p_direction NOT IN ('receivable', 'payable') THEN\n RAISE EXCEPTION 'financial_create_invoice: direction must be receivable|payable' USING ERRCODE = '22023';\n END IF;\n IF v_total < 0 THEN\n RAISE EXCEPTION 'financial_create_invoice: total must be >= 0' USING ERRCODE = '22023';\n END IF;\n\n IF p_order_id IS NOT NULL THEN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_create_invoice: order % not found in this tenant', p_order_id USING ERRCODE = 'P0002';\n END IF;\n IF v_order.kind IN ('invoice_receivable', 'invoice_payable') THEN\n RAISE EXCEPTION 'financial_create_invoice: order % is a retired invoice kind', p_order_id USING ERRCODE = '23514';\n END IF;\n v_unit := coalesce(v_unit, v_order.unit_id);\n END IF;\n\n PERFORM public.plg_financial_require_permission('financial.create', v_unit);\n\n -- Idempotency: same key in the tenant \u2192 the same invoice, no second effect.\n IF p_idempotency_key IS NOT NULL THEN\n SELECT id, number INTO v_invoice_id, v_number FROM public.plg_financial_invoices\n WHERE tenant_id = v_tenant AND idempotency_key = p_idempotency_key;\n IF v_invoice_id IS NOT NULL THEN\n SELECT coalesce(jsonb_agg(jsonb_build_object('id', i.id, 'number', i.number, 'due_date', i.due_date, 'amount', i.amount) ORDER BY i.number), '[]'::jsonb)\n INTO v_out FROM public.plg_financial_invoice_installments i WHERE i.invoice_id = v_invoice_id;\n RETURN jsonb_build_object('invoice_id', v_invoice_id, 'number', v_number, 'installments', v_out, 'replayed', true);\n END IF;\n END IF;\n\n -- installments: given, or one for the total\n v_insts := coalesce(p_installments, jsonb_build_array(jsonb_build_object(\n 'number', 1, 'due_date', coalesce(p_competence_date, current_date), 'amount', v_total)));\n IF jsonb_typeof(v_insts) <> 'array' OR jsonb_array_length(v_insts) = 0 THEN\n RAISE EXCEPTION 'financial_create_invoice: installments must be a non-empty array' USING ERRCODE = '22023';\n END IF;\n FOR v_i IN SELECT * FROM jsonb_array_elements(v_insts) LOOP\n v_sum := v_sum + round(coalesce((v_i ->> 'amount')::numeric, 0), 2);\n END LOOP;\n IF v_sum <> v_total THEN\n RAISE EXCEPTION 'financial_create_invoice: installments sum to % but the invoice total is %', v_sum, v_total USING ERRCODE = '23514';\n END IF;\n\n v_number := public.plg_financial_next_invoice_number(v_tenant, p_direction);\n\n INSERT INTO public.plg_financial_invoices\n (tenant_id, unit_id, direction, counterparty_person_id, order_id, number, competence_date, issue_date,\n total, description, notes, metadata, idempotency_key, created_by)\n VALUES\n (v_tenant, v_unit, p_direction, coalesce(p_counterparty_person_id, v_order.party_id), p_order_id, v_number,\n coalesce(p_competence_date, current_date), current_date, v_total, p_description, p_notes,\n coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'financial_create_invoice'),\n p_idempotency_key, auth.uid())\n RETURNING id INTO v_invoice_id;\n\n FOR v_i IN SELECT * FROM jsonb_array_elements(v_insts) LOOP\n v_n := v_n + 1;\n INSERT INTO public.plg_financial_invoice_installments (invoice_id, number, due_date, amount)\n VALUES (v_invoice_id, coalesce((v_i ->> 'number')::int, v_n),\n coalesce((v_i ->> 'due_date')::date, coalesce(p_competence_date, current_date)),\n round(coalesce((v_i ->> 'amount')::numeric, 0), 2))\n RETURNING id INTO v_inst_id;\n v_out := v_out || jsonb_build_object('id', v_inst_id, 'number', coalesce((v_i ->> 'number')::int, v_n),\n 'due_date', coalesce((v_i ->> 'due_date')::date, coalesce(p_competence_date, current_date)),\n 'amount', round(coalesce((v_i ->> 'amount')::numeric, 0), 2));\n END LOOP;\n\n -- The Venda records that it was invoiced (same keys the legacy readers use).\n IF p_order_id IS NOT NULL THEN\n UPDATE public.orders SET\n stage = 'invoiced',\n direction = CASE WHEN p_direction = 'payable' THEN 'debit' ELSE 'credit' END,\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('invoiceNumber', v_number, 'installmentCount', v_n),\n updated_at = now()\n WHERE id = p_order_id;\n END IF;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.invoice.create', 'plg_financial_invoices', v_invoice_id::text,\n jsonb_build_object('number', v_number, 'direction', p_direction, 'total', v_total, 'order_id', p_order_id,\n 'installments', v_n, 'idempotency_key', p_idempotency_key));\n\n RETURN jsonb_build_object('invoice_id', v_invoice_id, 'number', v_number, 'installments', v_out, 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_create_invoice(text, numeric, uuid, uuid, uuid, jsonb, date, text, text, jsonb, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_create_invoice(text, numeric, uuid, uuid, uuid, jsonb, date, text, text, jsonb, text) TO authenticated, service_role;\n\n-- \u2500\u2500 financial_cancel_invoice \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- An invoice with money already allocated to it cannot simply be cancelled: the\n-- money must be reversed first (financial_reverse_movement, 019) or the paid\n-- item cancelled into customer credit (financial_cancel_paid_item, 019).\nCREATE OR REPLACE FUNCTION public.financial_cancel_invoice(\n p_invoice_id uuid,\n p_reason text,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_invoice public.plg_financial_invoices%ROWTYPE;\n v_paid numeric;\nBEGIN\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'financial_cancel_invoice: a reason is required' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_invoice FROM public.plg_financial_invoices WHERE id = p_invoice_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_cancel_invoice: invoice % not found', p_invoice_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.edit', v_invoice.unit_id);\n\n IF v_invoice.cancelled_at IS NOT NULL THEN\n RETURN jsonb_build_object('invoice_id', v_invoice.id, 'status', 'cancelled', 'replayed', true);\n END IF;\n\n SELECT coalesce(sum(paid), 0) INTO v_paid FROM public.v_financial_installment_balances WHERE invoice_id = p_invoice_id;\n IF v_paid > 0 THEN\n RAISE EXCEPTION 'financial_cancel_invoice: invoice % has % already received; reverse the payments or cancel the paid items into credit first', v_invoice.number, v_paid\n USING ERRCODE = '55000';\n END IF;\n\n UPDATE public.plg_financial_invoices\n SET cancelled_at = now(), cancel_reason = btrim(p_reason),\n metadata = metadata || jsonb_build_object('cancel_idempotency_key', p_idempotency_key)\n WHERE id = p_invoice_id;\n UPDATE public.plg_financial_invoice_installments SET cancelled_at = now() WHERE invoice_id = p_invoice_id AND cancelled_at IS NULL;\n -- legacy mirror rows follow the installment\n UPDATE public.plg_financial_movements SET status = 'cancelled', updated_at = now()\n WHERE financial_invoice_id = p_invoice_id AND movement_kind = 'bill' AND status <> 'cancelled';\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.invoice.cancel', 'plg_financial_invoices', p_invoice_id::text,\n jsonb_build_object('reason', btrim(p_reason), 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('invoice_id', v_invoice.id, 'status', 'cancelled', 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_cancel_invoice(uuid, text, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_cancel_invoice(uuid, text, text) TO authenticated, service_role;\n\nNOTIFY pgrst, 'reload schema';\n";
23
+ export declare const MIGRATION_020_MIGRATE_LEGACY_INVOICES = "-- ============================================================================\n-- plugin-financial 016 \u2014 data migration: the legacy invoice orders become\n-- invoice documents (#141).\n--\n-- Two shapes exist in pools provisioned before this slice:\n-- (a) orders kind IN ('invoice_receivable','invoice_payable') \u2014 the financial\n-- plugin's own \"invoice = order\" rows, with 'bill' movements as their\n-- installments and 'payment' movements as their receipts;\n-- (b) orders of any other kind (appointment, quote, shop, service_order\u2026) that\n-- were promoted through fn_invoice_from_order and therefore carry the same\n-- per-order 'bill' / 'payment' rows.\n--\n-- Both become one invoice per order (+ one installment per bill, or a single\n-- installment for the total when the order has no bill), the bills are linked\n-- as legacy mirrors of their installment, the payments are linked to the\n-- installment they settled (018 turns those links into allocations and adds\n-- the \"Cr\u00E9dito Legado\" synthetic receipt of R14 where a bill says paid but no\n-- receipt exists). Kind (a) rows are re-kinded \u2014 receivable \u2192 'sale' (a\n-- confirmed Venda: it has items and a party), payable \u2192 'purchase' \u2014 so the\n-- kind guard of spine 080 holds for every row.\n--\n-- Packaged as a function so a suite can exercise it against a synthetic\n-- legacy dataset and so a pool can re-run it: idempotent (an order that already\n-- has an invoice is skipped), replay-safe.\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION public.plg_financial_migrate_legacy_invoices()\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n r record;\n b record;\n v_invoice_id uuid;\n v_inst_id uuid;\n v_dir text;\n v_number text;\n v_total numeric;\n v_n int;\n v_invoices int := 0;\n v_insts int := 0;\n v_links int := 0;\n v_rekinded int := 0;\nBEGIN\n FOR r IN\n SELECT o.*\n FROM public.orders o\n WHERE (o.kind IN ('invoice_receivable', 'invoice_payable')\n OR EXISTS (SELECT 1 FROM public.plg_financial_movements m\n WHERE m.invoice_id = o.id AND m.movement_kind = 'bill'))\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_invoices t WHERE t.order_id = o.id)\n ORDER BY o.created_at, o.id\n LOOP\n -- direction: the bills know it; else the order's kind/direction\n SELECT CASE WHEN min(m.direction) = 'debit' THEN 'payable' ELSE 'receivable' END INTO v_dir\n FROM public.plg_financial_movements m WHERE m.invoice_id = r.id AND m.movement_kind = 'bill';\n IF v_dir IS NULL THEN\n v_dir := CASE WHEN r.kind = 'invoice_payable' OR r.direction = 'debit' THEN 'payable' ELSE 'receivable' END;\n END IF;\n\n -- number: keep the legacy REC-/PAG- number when it is free in the tenant\n v_number := NULL;\n IF r.reference_number ~ '^(REC|PAG)-[0-9]+$'\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_invoices t WHERE t.tenant_id = r.tenant_id AND t.number = r.reference_number) THEN\n v_number := r.reference_number;\n END IF;\n IF v_number IS NULL THEN\n v_number := public.plg_financial_next_invoice_number(r.tenant_id, v_dir);\n END IF;\n\n SELECT coalesce(sum(m.amount), 0) INTO v_total\n FROM public.plg_financial_movements m WHERE m.invoice_id = r.id AND m.movement_kind = 'bill' AND m.status <> 'cancelled';\n IF v_total = 0 THEN v_total := round(coalesce(r.total, 0), 2); END IF;\n\n INSERT INTO public.plg_financial_invoices\n (tenant_id, unit_id, direction, counterparty_person_id, order_id, number, competence_date, issue_date,\n total, description, notes, metadata, cancelled_at, cancel_reason, created_by, created_at)\n VALUES\n (r.tenant_id, r.unit_id, v_dir, r.party_id, r.id, v_number,\n coalesce(r.created_at::date, current_date), coalesce(r.created_at::date, current_date),\n v_total, nullif(r.metadata ->> 'itemsSummary', ''), r.notes,\n jsonb_build_object('source', 'migrate_legacy_invoices', 'legacy_mirror', true,\n 'legacy_kind', r.kind, 'legacy_status', r.status, 'legacy_stage', r.stage,\n 'contactName', r.metadata ->> 'contactName'),\n CASE WHEN r.status = 'cancelled' OR r.stage = 'cancelled' THEN coalesce(r.updated_at, now()) END,\n CASE WHEN r.status = 'cancelled' OR r.stage = 'cancelled' THEN 'legacy: order was cancelled' END,\n r.created_by, r.created_at)\n RETURNING id INTO v_invoice_id;\n v_invoices := v_invoices + 1;\n\n -- installments: one per bill; the bill becomes that installment's mirror\n v_n := 0;\n FOR b IN\n SELECT m.* FROM public.plg_financial_movements m\n WHERE m.invoice_id = r.id AND m.movement_kind = 'bill'\n ORDER BY m.installment_number NULLS LAST, m.due_date, m.created_at\n LOOP\n v_n := v_n + 1;\n INSERT INTO public.plg_financial_invoice_installments (invoice_id, number, due_date, amount, cancelled_at, metadata, created_at)\n VALUES (v_invoice_id, v_n, b.due_date, round(b.amount, 2),\n CASE WHEN b.status = 'cancelled' THEN coalesce(b.updated_at, now()) END,\n jsonb_build_object('legacy_movement_id', b.id, 'legacy_installment_number', b.installment_number),\n b.created_at)\n RETURNING id INTO v_inst_id;\n v_insts := v_insts + 1;\n\n UPDATE public.plg_financial_movements\n SET financial_invoice_id = v_invoice_id, financial_invoice_installment_id = v_inst_id,\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('legacy_mirror', true)\n WHERE id = b.id;\n\n -- receipts of this installment (same installment number)\n UPDATE public.plg_financial_movements p\n SET financial_invoice_id = v_invoice_id, financial_invoice_installment_id = v_inst_id\n WHERE p.invoice_id = r.id AND p.movement_kind = 'payment' AND p.financial_invoice_installment_id IS NULL\n AND p.installment_number IS NOT DISTINCT FROM b.installment_number;\n GET DIAGNOSTICS v_links = ROW_COUNT;\n END LOOP;\n\n IF v_n = 0 THEN\n INSERT INTO public.plg_financial_invoice_installments (invoice_id, number, due_date, amount, created_at)\n VALUES (v_invoice_id, 1, coalesce(r.due_at::date, r.created_at::date, current_date), v_total, r.created_at)\n RETURNING id INTO v_inst_id;\n v_insts := v_insts + 1;\n END IF;\n\n -- receipts without an installment number: the first live installment\n UPDATE public.plg_financial_movements p\n SET financial_invoice_id = v_invoice_id,\n financial_invoice_installment_id = (SELECT i.id FROM public.plg_financial_invoice_installments i\n WHERE i.invoice_id = v_invoice_id AND i.cancelled_at IS NULL\n ORDER BY i.due_date, i.number LIMIT 1)\n WHERE p.invoice_id = r.id AND p.movement_kind = 'payment' AND p.financial_invoice_installment_id IS NULL;\n -- anything else per-order (commission accruals) at least knows its invoice\n UPDATE public.plg_financial_movements p\n SET financial_invoice_id = v_invoice_id\n WHERE p.invoice_id = r.id AND p.financial_invoice_id IS NULL;\n\n -- the invoice kinds are retired: the receivable was a confirmed sale, the\n -- payable a purchase document behind its payable invoice\n IF r.kind = 'invoice_receivable' THEN\n UPDATE public.orders SET kind = 'sale',\n status = CASE WHEN r.status = 'cancelled' THEN 'cancelled' ELSE 'confirmed' END,\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('legacy_kind', r.kind, 'legacy_status', r.status, 'invoiceNumber', v_number)\n WHERE id = r.id;\n v_rekinded := v_rekinded + 1;\n ELSIF r.kind = 'invoice_payable' THEN\n UPDATE public.orders SET kind = 'purchase',\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('legacy_kind', r.kind, 'legacy_status', r.status, 'invoiceNumber', v_number)\n WHERE id = r.id;\n v_rekinded := v_rekinded + 1;\n END IF;\n END LOOP;\n\n RETURN jsonb_build_object('invoices', v_invoices, 'installments', v_insts, 'orders_rekinded', v_rekinded);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_migrate_legacy_invoices() FROM public, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_financial_migrate_legacy_invoices() TO service_role;\n\nDO $$\nDECLARE v jsonb;\nBEGIN\n v := public.plg_financial_migrate_legacy_invoices();\n RAISE NOTICE 'plg_financial_migrate_legacy_invoices: %', v;\nEND $$;\n";
24
+ export declare const MIGRATION_021_ACCOUNTS = "-- ============================================================================\n-- plugin-financial 017 \u2014 Conta: every place money can be, or come from (#142).\n--\n-- ADR 0005: the two-leg ledger needs one account table for every kind of\n-- counterpart \u2014 plg_financial_accounts generalizes plg_financial_bank_accounts:\n--\n-- bank a bank / digital account of the unit\n-- cash_physical the unit's cofre (RN-FIN-01) \u2014 physical cash\n-- cash_session one drawer per open caixinha (RN-FIN-02); created by\n-- financial_cash_session_open, closed to zero on close\n-- card_receivable_credit the acquirer's CREDIT receiving account (RN-FIN-05)\n-- card_receivable_debit \u2026and its DEBIT one \u2014 separate accounts, separate fees\n-- card_payable a credit card the unit pays with (RN-FIN-07)\n-- person a Person as counterparty (customer credit, supplier,\n-- professional) \u2014 one per person, created lazily by\n-- plg_financial_person_account(tenant, person)\n-- system the plugin's own clearing accounts, addressed by\n-- `code`, created lazily by plg_financial_system_account:\n-- receivables \u00B7 payables \u00B7 legacy \u00B7 legacy_credit \u00B7\n-- cancellations \u00B7 cash_shortage \u00B7 cash_overage \u00B7\n-- opening_balances\n--\n-- Balances are NEVER stored here: v_financial_account_balances (018) sums the\n-- ledger. plg_financial_bank_accounts is NOT dropped and NOT turned into a view\n-- (001/006 replay `ALTER TABLE \u2026 ENABLE ROW LEVEL SECURITY` on it, which a view\n-- cannot take): its rows are copied into accounts under the same ids and a\n-- forward trigger keeps writing through, so a legacy writer still lands in the\n-- ledger's account table; readers should use plg_financial_accounts.\n-- Idempotent and replay-safe.\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_accounts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n kind text NOT NULL DEFAULT 'bank'\n CHECK (kind IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit',\n 'card_receivable_debit', 'card_payable', 'person', 'system')),\n code text,\n name text NOT NULL,\n person_id uuid REFERENCES public.people(id) ON DELETE SET NULL,\n account_type text, -- legacy label of plg_financial_bank_accounts.account_type\n bank_name text,\n agency_number text,\n account_number text,\n credit_limit numeric(14,2),\n due_day integer,\n closing_day integer,\n best_purchase_day integer,\n settlement_days integer, -- card receivable: default D+N of the acquirer\n is_active boolean NOT NULL DEFAULT true,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_by uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CHECK ((kind = 'person') = (person_id IS NOT NULL)),\n CHECK (kind <> 'system' OR code IS NOT NULL),\n CONSTRAINT plg_financial_accounts_tenant_id_id_key UNIQUE (tenant_id, id)\n);\nCREATE UNIQUE INDEX IF NOT EXISTS plg_financial_accounts_person_uq ON public.plg_financial_accounts (tenant_id, person_id) WHERE person_id IS NOT NULL;\nCREATE UNIQUE INDEX IF NOT EXISTS plg_financial_accounts_code_uq ON public.plg_financial_accounts (tenant_id, code) WHERE code IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_financial_accounts_kind_idx ON public.plg_financial_accounts (tenant_id, kind) WHERE is_active;\n\nSELECT app.scaffold_table('public.plg_financial_accounts', 'financial.account', 'financial', false, 'enforce');\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_accounts TO authenticated;\nGRANT ALL ON public.plg_financial_accounts TO service_role;\n\n-- \u2500\u2500 legacy account_type \u2192 kind \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.plg_financial_account_kind_of(p_account_type text)\nRETURNS text LANGUAGE sql IMMUTABLE AS $$\n SELECT CASE lower(coalesce(p_account_type, ''))\n WHEN 'cash_register' THEN 'cash_physical'\n WHEN 'caixa_fisico' THEN 'cash_physical'\n WHEN 'cash' THEN 'cash_physical'\n WHEN 'credit_card' THEN 'card_payable'\n WHEN 'cartao_credito_pagamento' THEN 'card_payable'\n WHEN 'cartao_credito_recebimento' THEN 'card_receivable_credit'\n WHEN 'card_receivable_credit' THEN 'card_receivable_credit'\n WHEN 'cartao_debito_recebimento' THEN 'card_receivable_debit'\n WHEN 'card_receivable_debit' THEN 'card_receivable_debit'\n ELSE 'bank'\n END\n$$;\n\n-- \u2500\u2500 system accounts (per tenant, lazily) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.plg_financial_system_account(p_tenant uuid, p_code text)\nRETURNS uuid\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE v_id uuid; v_name text;\nBEGIN\n IF p_code NOT IN ('receivables', 'payables', 'legacy', 'legacy_holder', 'legacy_credit', 'cancellations',\n 'cash_shortage', 'cash_overage', 'opening_balances') THEN\n RAISE EXCEPTION 'plg_financial_system_account: unknown code %', p_code USING ERRCODE = '22023';\n END IF;\n SELECT id INTO v_id FROM public.plg_financial_accounts WHERE tenant_id = p_tenant AND code = p_code;\n IF v_id IS NOT NULL THEN RETURN v_id; END IF;\n v_name := CASE p_code\n WHEN 'receivables' THEN 'Contas a receber (compensa\u00E7\u00E3o)'\n WHEN 'payables' THEN 'Contas a pagar (compensa\u00E7\u00E3o)'\n WHEN 'legacy' THEN 'Legado (contrapartida desconhecida)'\n -- The OTHER unknown. `legacy` is the counterparty we cannot name; this is the\n -- drawer we cannot name. A legacy payment with no bank account, no cash\n -- session and no invoice used to resolve BOTH legs to `legacy`, and\n -- plg_financial_movements_legs_check (debit <> credit) then rejected the row\n -- the backfill had just built (#199).\n WHEN 'legacy_holder' THEN 'Legado (portador desconhecido)'\n WHEN 'legacy_credit' THEN 'Cr\u00E9dito Legado'\n WHEN 'cancellations' THEN 'Cancelamentos (cr\u00E9dito ao cliente)'\n WHEN 'cash_shortage' THEN 'Quebra de caixa (falta)'\n WHEN 'cash_overage' THEN 'Quebra de caixa (sobra)'\n WHEN 'opening_balances' THEN 'Saldos de abertura'\n END;\n INSERT INTO public.plg_financial_accounts (tenant_id, kind, code, name, metadata)\n VALUES (p_tenant, 'system', p_code, v_name, jsonb_build_object('system', true))\n ON CONFLICT DO NOTHING;\n SELECT id INTO v_id FROM public.plg_financial_accounts WHERE tenant_id = p_tenant AND code = p_code;\n RETURN v_id;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_system_account(uuid, text) FROM public, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_financial_system_account(uuid, text) TO service_role;\n\n-- \u2500\u2500 the Person as an account (per tenant, lazily) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.plg_financial_person_account(p_tenant uuid, p_person uuid)\nRETURNS uuid\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE v_id uuid; v_name text;\nBEGIN\n IF p_person IS NULL THEN\n RAISE EXCEPTION 'plg_financial_person_account: person required' USING ERRCODE = '22023';\n END IF;\n SELECT id INTO v_id FROM public.plg_financial_accounts WHERE tenant_id = p_tenant AND person_id = p_person;\n IF v_id IS NOT NULL THEN RETURN v_id; END IF;\n SELECT name INTO v_name FROM public.people WHERE id = p_person AND tenant_id = p_tenant;\n IF v_name IS NULL THEN\n RAISE EXCEPTION 'plg_financial_person_account: person % not found in tenant', p_person USING ERRCODE = 'P0002';\n END IF;\n INSERT INTO public.plg_financial_accounts (tenant_id, kind, name, person_id, metadata)\n VALUES (p_tenant, 'person', v_name, p_person, jsonb_build_object('person', true))\n ON CONFLICT DO NOTHING;\n SELECT id INTO v_id FROM public.plg_financial_accounts WHERE tenant_id = p_tenant AND person_id = p_person;\n RETURN v_id;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_person_account(uuid, uuid) FROM public, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_financial_person_account(uuid, uuid) TO service_role;\n\n-- \u2500\u2500 plg_financial_bank_accounts \u2192 accounts (same ids), then write-through \u2500\u2500\nINSERT INTO public.plg_financial_accounts\n (id, tenant_id, unit_id, kind, name, account_type, bank_name, agency_number, account_number,\n credit_limit, due_day, closing_day, is_active, metadata, created_at, updated_at)\nSELECT b.id, b.tenant_id,\n CASE WHEN EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = b.tenant_id AND u.id = b.unit_id) THEN b.unit_id END,\n public.plg_financial_account_kind_of(b.account_type), b.name, b.account_type, b.bank_name, b.agency_number, b.account_number,\n b.credit_limit, b.due_day, b.closing_day, b.is_active,\n jsonb_build_object('migrated_from', 'plg_financial_bank_accounts', 'legacy_initial_balance', b.initial_balance),\n b.created_at, b.updated_at\n FROM public.plg_financial_bank_accounts b\n WHERE NOT EXISTS (SELECT 1 FROM public.plg_financial_accounts a WHERE a.id = b.id);\n\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_bank_accounts_forward()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n INSERT INTO public.plg_financial_accounts\n (id, tenant_id, unit_id, kind, name, account_type, bank_name, agency_number, account_number,\n credit_limit, due_day, closing_day, is_active, metadata)\n VALUES\n (NEW.id, NEW.tenant_id,\n CASE WHEN EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = NEW.tenant_id AND u.id = NEW.unit_id) THEN NEW.unit_id END,\n public.plg_financial_account_kind_of(NEW.account_type), NEW.name, NEW.account_type, NEW.bank_name, NEW.agency_number,\n NEW.account_number, NEW.credit_limit, NEW.due_day, NEW.closing_day, NEW.is_active,\n jsonb_build_object('migrated_from', 'plg_financial_bank_accounts', 'legacy_initial_balance', NEW.initial_balance))\n ON CONFLICT (id) DO UPDATE\n SET name = EXCLUDED.name, account_type = EXCLUDED.account_type, kind = EXCLUDED.kind,\n bank_name = EXCLUDED.bank_name, agency_number = EXCLUDED.agency_number, account_number = EXCLUDED.account_number,\n credit_limit = EXCLUDED.credit_limit, due_day = EXCLUDED.due_day, closing_day = EXCLUDED.closing_day,\n is_active = EXCLUDED.is_active, unit_id = coalesce(EXCLUDED.unit_id, public.plg_financial_accounts.unit_id),\n updated_at = now();\n RETURN NEW;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_bank_accounts_forward() FROM public, anon, authenticated;\nDROP TRIGGER IF EXISTS plg_financial_bank_accounts_forward ON public.plg_financial_bank_accounts;\nCREATE TRIGGER plg_financial_bank_accounts_forward AFTER INSERT OR UPDATE ON public.plg_financial_bank_accounts\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_bank_accounts_forward();\n\nNOTIFY pgrst, 'reload schema';\n";
25
+ export declare const MIGRATION_022_LEDGER_TWO_LEG = "-- ============================================================================\n-- plugin-financial 018 \u2014 the two-leg ledger, allocations, balances as views (#142).\n--\n-- ADR 0005. plg_financial_movements becomes a double-entry ledger of MONEY\n-- events: every payment/transfer moves an amount from one Conta to another \u2014\n-- `credit_account_id` is credited, `debit_account_id` is debited (the accounting\n-- convention: an asset-kind account \u2014 bank, cofre, caixinha, card receivable \u2014\n-- is debited when money enters it; a counterparty/liability-kind account \u2014 a\n-- Person, a card payable, a clearing account \u2014 is credited when we owe it more).\n-- Balances are views only: v_financial_account_balances exposes both raw totals\n-- and the balance signed by the account's normal side; v_financial_customer_credit\n-- is the credit-side balance of a Person's account (RN-FIN-10/14).\n--\n-- What a movement_kind means now:\n-- payment a receipt or a payout \u2014 the ONLY thing that settles an installment,\n-- through plg_financial_allocations (payment \u00D7 installment, partial\n-- allowed; \u03A3 allocations \u2264 payment; \u03A3 allocations \u2264 installment).\n-- The obligation side of an invoice payment is the invoice itself; the\n-- ledger's counter-leg is the tenant's `receivables` / `payables`\n-- clearing account. A payment \"with customer credit\" debits the\n-- Person account instead of a money holder.\n-- transfer money between two accounts with no invoice: cofre \u2192 caixinha,\n-- bank \u2192 bank, a customer's Pix deposit (bank \u2190 Person: credit),\n-- a cancelled paid item (Person \u2190 cancellations: credit).\n-- bill LEGACY ONLY. The obligation is a plg_financial_invoice_installments\n-- row; a 'bill' movement is the per-order mirror legacy readers use\n-- (014) or a pre-016 row. It carries no legs and never sums into a\n-- balance. Its paid_amount/status are DERIVED from allocations.\n-- commission LEGACY ONLY (plugin-agenda's accrual): same rule as bill.\n--\n-- Append-only: a payment/transfer row can only gain reconciliation marks,\n-- notes/metadata, its invoice links (null \u2192 value) and `reversed_by_id`; a\n-- reversal is a NEW movement with the legs swapped (financial_reverse_movement,\n-- 019) \u2014 both rows stay, the pair nets to zero in every balance, and the\n-- reversed payment's allocations no longer count. DELETE is refused except\n-- inside a cascade. Bill/commission mirrors may update their\n-- derived columns (the shop's shop_confirm_payment and the agent RPC still do).\n--\n-- Legacy shape: a writer that inserts a payment the old way (invoice_id +\n-- installment_number, bank_account_id / cash_session_id, no legs) is completed\n-- by a BEFORE INSERT trigger \u2014 installment resolved through the order's invoice,\n-- legs derived \u2014 and auto-allocated by an AFTER INSERT trigger. The old\n-- one-leg rows are backfilled the same way; where the money holder is unknown\n-- the synthetic system account `legacy` takes the leg. Invoices whose legacy bill\n-- said \"paid\" without a receipt get the synthetic \"Cr\u00E9dito Legado\" receipt (R14)\n-- so \u03A3 payments = \u03A3 paid installments holds on migrated data too.\n--\n-- Idempotent and replay-safe.\n-- ============================================================================\n\n-- \u2500\u2500 movements: the columns of the two-leg ledger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS debit_account_id uuid;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS credit_account_id uuid;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS method text;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS nsu text;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS reversed_by_id uuid;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS reverses_id uuid;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS reason text;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS idempotency_key text;\nALTER TABLE public.plg_financial_movements ADD COLUMN IF NOT EXISTS created_by uuid;\n-- the caixinha's drawer account and owner (020 finishes the sessions table)\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS account_id uuid;\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS user_id uuid;\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_debit_fk') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_debit_fk\n FOREIGN KEY (debit_account_id) REFERENCES public.plg_financial_accounts(id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_credit_fk') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_credit_fk\n FOREIGN KEY (credit_account_id) REFERENCES public.plg_financial_accounts(id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_reversed_by_fk') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_reversed_by_fk\n FOREIGN KEY (reversed_by_id) REFERENCES public.plg_financial_movements(id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_reverses_fk') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_reverses_fk\n FOREIGN KEY (reverses_id) REFERENCES public.plg_financial_movements(id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_kind_check') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_kind_check\n CHECK (movement_kind IN ('bill', 'payment', 'transfer', 'commission')) NOT VALID;\n END IF;\n -- bank_account_id now points at the generalized account table (same ids)\n IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_bank_account_id_fkey') THEN\n ALTER TABLE public.plg_financial_movements DROP CONSTRAINT plg_financial_movements_bank_account_id_fkey;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_bank_account_fk') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_bank_account_fk\n FOREIGN KEY (bank_account_id) REFERENCES public.plg_financial_accounts(id) NOT VALID;\n END IF;\nEND $$;\nDO $$\nBEGIN\n ALTER TABLE public.plg_financial_movements VALIDATE CONSTRAINT plg_financial_movements_kind_check;\nEXCEPTION WHEN check_violation THEN\n RAISE WARNING 'plg_financial_movements_kind_check left NOT VALID: legacy rows with an unknown movement_kind exist';\nEND $$;\nDO $$\nBEGIN\n ALTER TABLE public.plg_financial_movements VALIDATE CONSTRAINT plg_financial_movements_bank_account_fk;\nEXCEPTION WHEN foreign_key_violation THEN\n RAISE WARNING 'plg_financial_movements_bank_account_fk left NOT VALID: legacy rows point at a bank account that never reached plg_financial_accounts';\nEND $$;\nCREATE UNIQUE INDEX IF NOT EXISTS plg_financial_movements_idempotency_uq\n ON public.plg_financial_movements (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_financial_movements_debit_idx ON public.plg_financial_movements (debit_account_id) WHERE debit_account_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_financial_movements_credit_idx ON public.plg_financial_movements (credit_account_id) WHERE credit_account_id IS NOT NULL;\n\n-- The authz template on the ledger (financial.read to see, .create to post).\nSELECT app.scaffold_table('public.plg_financial_movements', 'financial.movement', 'financial', false, 'enforce');\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_movements TO authenticated;\nGRANT ALL ON public.plg_financial_movements TO service_role;\n\n-- \u2500\u2500 allocations: payment \u00D7 installment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE TABLE IF NOT EXISTS public.plg_financial_allocations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n payment_movement_id uuid NOT NULL REFERENCES public.plg_financial_movements(id) ON DELETE CASCADE,\n installment_id uuid NOT NULL REFERENCES public.plg_financial_invoice_installments(id) ON DELETE CASCADE,\n amount numeric(14,2) NOT NULL CHECK (amount > 0),\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_by uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (payment_movement_id, installment_id),\n CONSTRAINT plg_financial_allocations_tenant_id_id_key UNIQUE (tenant_id, id)\n);\nCREATE INDEX IF NOT EXISTS plg_financial_allocations_installment_idx ON public.plg_financial_allocations (installment_id);\nCREATE INDEX IF NOT EXISTS plg_financial_allocations_payment_idx ON public.plg_financial_allocations (payment_movement_id);\nSELECT app.scaffold_table('public.plg_financial_allocations', 'financial.allocation', 'financial', false, 'enforce');\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_allocations TO authenticated;\nGRANT ALL ON public.plg_financial_allocations TO service_role;\n\n-- \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Legs of a legacy-shaped payment/transfer: (debit, credit, is_mirror). The\n-- holder is the bank/cofre/session account when known, else `legacy`; the\n-- counter-leg of an invoice payment is the clearing account of the invoice's\n-- direction, else `legacy`.\nCREATE OR REPLACE FUNCTION public.plg_financial_legacy_legs(\n p_tenant uuid, p_kind text, p_direction text, p_bank_account_id uuid, p_cash_session_id uuid,\n p_invoice_id uuid, p_metadata jsonb,\n OUT o_debit uuid, OUT o_credit uuid, OUT o_mirror boolean\n) LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE v_holder uuid; v_counter uuid; v_invoice_dir text; v_other uuid;\nBEGIN\n o_mirror := false;\n IF p_kind IN ('bill', 'commission') THEN\n o_mirror := true; RETURN;\n END IF;\n\n -- money holder\n IF p_bank_account_id IS NOT NULL AND EXISTS (SELECT 1 FROM public.plg_financial_accounts a WHERE a.id = p_bank_account_id AND a.tenant_id = p_tenant) THEN\n v_holder := p_bank_account_id;\n ELSIF p_cash_session_id IS NOT NULL THEN\n SELECT s.account_id INTO v_holder FROM public.plg_financial_cash_register_sessions s WHERE s.id = p_cash_session_id AND s.tenant_id = p_tenant;\n END IF;\n -- `legacy_holder`, not `legacy`: the counter-leg below falls back to `legacy`,\n -- and a payment with no holder AND no invoice would otherwise post legacy \u2192\n -- legacy, which legs_check (debit <> credit) rejects (#199).\n IF v_holder IS NULL THEN v_holder := public.plg_financial_system_account(p_tenant, 'legacy_holder'); END IF;\n\n IF p_kind = 'transfer' THEN\n -- the old two-row transfer: 'out' carries the whole transfer, 'in' is a mirror\n IF coalesce(p_metadata ->> 'transferRole', '') = 'in' THEN\n o_mirror := true; RETURN;\n END IF;\n v_other := NULL;\n BEGIN\n v_other := (p_metadata ->> 'counterAccountId')::uuid;\n EXCEPTION WHEN OTHERS THEN v_other := NULL; END;\n IF v_other IS NULL OR NOT EXISTS (SELECT 1 FROM public.plg_financial_accounts a WHERE a.id = v_other AND a.tenant_id = p_tenant) THEN\n v_other := public.plg_financial_system_account(p_tenant, 'legacy');\n END IF;\n IF p_direction = 'debit' THEN -- money leaves the holder\n o_debit := v_other; o_credit := v_holder;\n ELSE\n o_debit := v_holder; o_credit := v_other;\n END IF;\n RETURN;\n END IF;\n\n -- payment: counter-leg is the clearing account of the invoice's direction\n IF p_invoice_id IS NOT NULL THEN\n SELECT t.direction INTO v_invoice_dir FROM public.plg_financial_invoices t WHERE t.id = p_invoice_id;\n END IF;\n v_counter := CASE\n WHEN v_invoice_dir = 'receivable' THEN public.plg_financial_system_account(p_tenant, 'receivables')\n WHEN v_invoice_dir = 'payable' THEN public.plg_financial_system_account(p_tenant, 'payables')\n ELSE public.plg_financial_system_account(p_tenant, 'legacy')\n END;\n IF p_direction = 'credit' THEN -- money in\n o_debit := v_holder; o_credit := v_counter;\n ELSE -- money out\n o_debit := v_counter; o_credit := v_holder;\n END IF;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_legacy_legs(uuid, text, text, uuid, uuid, uuid, jsonb) FROM public, anon, authenticated;\n\n-- \u03A3 allocations of an installment (live payments only)\nCREATE OR REPLACE FUNCTION public.plg_financial_installment_paid(p_installment uuid)\nRETURNS numeric LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT coalesce(sum(a.amount), 0)\n FROM public.plg_financial_allocations a\n JOIN public.plg_financial_movements m ON m.id = a.payment_movement_id\n WHERE a.installment_id = p_installment AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL;\n$$;\nREVOKE ALL ON FUNCTION public.plg_financial_installment_paid(uuid) FROM public, anon, authenticated;\n\n-- The per-order 'bill' mirror follows its installment: paid_amount / status.\nCREATE OR REPLACE FUNCTION public.plg_financial_sync_mirror(p_installment uuid)\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE v_paid numeric; v_amount numeric; v_cancelled boolean; v_last date;\nBEGIN\n SELECT i.amount, i.cancelled_at IS NOT NULL INTO v_amount, v_cancelled\n FROM public.plg_financial_invoice_installments i WHERE i.id = p_installment;\n IF NOT FOUND THEN RETURN; END IF;\n v_paid := public.plg_financial_installment_paid(p_installment);\n SELECT max(m.payment_date) INTO v_last\n FROM public.plg_financial_allocations a JOIN public.plg_financial_movements m ON m.id = a.payment_movement_id\n WHERE a.installment_id = p_installment AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL;\n UPDATE public.plg_financial_movements b\n SET paid_amount = v_paid,\n status = CASE WHEN v_cancelled THEN 'cancelled'\n WHEN v_paid >= v_amount AND v_amount > 0 THEN 'paid'\n WHEN v_paid > 0 THEN 'partial'\n WHEN b.status = 'overdue' THEN 'overdue'\n ELSE 'pending' END,\n payment_date = CASE WHEN v_paid >= v_amount AND v_amount > 0 THEN coalesce(v_last, b.payment_date) ELSE b.payment_date END,\n updated_at = now()\n WHERE b.financial_invoice_installment_id = p_installment AND b.movement_kind = 'bill'\n AND (b.paid_amount IS DISTINCT FROM v_paid\n OR b.status IS DISTINCT FROM CASE WHEN v_cancelled THEN 'cancelled'\n WHEN v_paid >= v_amount AND v_amount > 0 THEN 'paid'\n WHEN v_paid > 0 THEN 'partial'\n WHEN b.status = 'overdue' THEN 'overdue'\n ELSE 'pending' END);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_sync_mirror(uuid) FROM public, anon, authenticated;\n\n-- \u2500\u2500 triggers on movements \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_movements_before_insert()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE\n v_invoice uuid; v_inst uuid; v_legs record; v_dir text;\nBEGIN\n NEW.created_by := coalesce(NEW.created_by, auth.uid());\n NEW.metadata := coalesce(NEW.metadata, '{}'::jsonb);\n IF NEW.amount IS NULL OR NEW.amount < 0 THEN\n RAISE EXCEPTION 'plg_financial_movements: amount must be >= 0' USING ERRCODE = '23514';\n END IF;\n\n IF NEW.movement_kind IN ('bill', 'commission') THEN\n -- obligation mirrors carry no legs\n NEW.debit_account_id := NULL; NEW.credit_account_id := NULL;\n IF NEW.financial_invoice_installment_id IS NOT NULL THEN\n NEW.metadata := NEW.metadata || jsonb_build_object('legacy_mirror', true);\n IF NEW.financial_invoice_id IS NULL THEN\n SELECT i.invoice_id INTO NEW.financial_invoice_id FROM public.plg_financial_invoice_installments i WHERE i.id = NEW.financial_invoice_installment_id;\n END IF;\n ELSE\n NEW.metadata := NEW.metadata || jsonb_build_object('legacy_orphan', true);\n END IF;\n RETURN NEW;\n END IF;\n\n -- payment / transfer: resolve the installment for legacy per-order writers\n IF NEW.movement_kind = 'payment' AND NEW.financial_invoice_installment_id IS NULL AND NEW.invoice_id IS NOT NULL THEN\n SELECT t.id INTO v_invoice FROM public.plg_financial_invoices t\n WHERE t.order_id = NEW.invoice_id AND t.tenant_id = NEW.tenant_id AND t.cancelled_at IS NULL\n ORDER BY t.created_at DESC LIMIT 1;\n IF v_invoice IS NOT NULL THEN\n SELECT i.id INTO v_inst FROM public.plg_financial_invoice_installments i\n WHERE i.invoice_id = v_invoice AND i.cancelled_at IS NULL\n AND (NEW.installment_number IS NULL OR i.number = NEW.installment_number)\n ORDER BY CASE WHEN public.plg_financial_installment_paid(i.id) < i.amount THEN 0 ELSE 1 END, i.due_date, i.number\n LIMIT 1;\n NEW.financial_invoice_id := coalesce(NEW.financial_invoice_id, v_invoice);\n NEW.financial_invoice_installment_id := v_inst;\n END IF;\n END IF;\n IF NEW.financial_invoice_installment_id IS NOT NULL AND NEW.financial_invoice_id IS NULL THEN\n SELECT i.invoice_id INTO NEW.financial_invoice_id FROM public.plg_financial_invoice_installments i WHERE i.id = NEW.financial_invoice_installment_id;\n END IF;\n\n IF NEW.debit_account_id IS NULL OR NEW.credit_account_id IS NULL THEN\n -- legacy shape: derive the legs\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n NEW.tenant_id, NEW.movement_kind, NEW.direction, NEW.bank_account_id, NEW.cash_session_id, NEW.financial_invoice_id, NEW.metadata);\n IF v_legs.o_mirror THEN\n NEW.debit_account_id := NULL; NEW.credit_account_id := NULL;\n NEW.metadata := NEW.metadata || jsonb_build_object('legacy_mirror', true);\n ELSE\n NEW.debit_account_id := v_legs.o_debit; NEW.credit_account_id := v_legs.o_credit;\n NEW.metadata := NEW.metadata || jsonb_build_object('legacy_shape', true);\n END IF;\n ELSE\n IF NEW.debit_account_id = NEW.credit_account_id THEN\n RAISE EXCEPTION 'plg_financial_movements: debit and credit accounts must differ' USING ERRCODE = '23514';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_accounts a WHERE a.id = NEW.debit_account_id AND a.tenant_id = NEW.tenant_id)\n OR NOT EXISTS (SELECT 1 FROM public.plg_financial_accounts a WHERE a.id = NEW.credit_account_id AND a.tenant_id = NEW.tenant_id) THEN\n RAISE EXCEPTION 'plg_financial_movements: both accounts must belong to the tenant' USING ERRCODE = '23514';\n END IF;\n END IF;\n\n IF NEW.direction IS NULL THEN\n -- money entering an asset-kind debit account is 'credit' (money in) for the tenant\n SELECT CASE WHEN a.kind IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit', 'card_receivable_debit') THEN 'credit' ELSE 'debit' END\n INTO v_dir FROM public.plg_financial_accounts a WHERE a.id = NEW.debit_account_id;\n NEW.direction := coalesce(v_dir, 'credit');\n END IF;\n IF NEW.status IS NULL THEN NEW.status := 'paid'; END IF;\n IF NEW.paid_amount IS NULL AND NEW.status = 'paid' THEN NEW.paid_amount := NEW.amount; END IF;\n RETURN NEW;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_movements_before_insert() FROM public, anon, authenticated;\nDROP TRIGGER IF EXISTS plg_financial_movements_before_insert ON public.plg_financial_movements;\nCREATE TRIGGER plg_financial_movements_before_insert BEFORE INSERT ON public.plg_financial_movements\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_movements_before_insert();\n\n-- legacy-shaped payments settle their installment automatically\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_movements_after_insert()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE v_open numeric; v_alloc numeric;\nBEGIN\n IF NEW.movement_kind = 'payment' AND NEW.status <> 'cancelled'\n AND NEW.financial_invoice_installment_id IS NOT NULL\n AND coalesce(NEW.metadata ->> 'legacy_shape', '') = 'true' THEN\n SELECT i.amount - public.plg_financial_installment_paid(i.id) INTO v_open\n FROM public.plg_financial_invoice_installments i WHERE i.id = NEW.financial_invoice_installment_id AND i.cancelled_at IS NULL;\n v_alloc := LEAST(coalesce(v_open, 0), NEW.amount);\n IF v_alloc > 0 THEN\n INSERT INTO public.plg_financial_allocations (tenant_id, unit_id, payment_movement_id, installment_id, amount, created_by, metadata)\n VALUES (NEW.tenant_id, NEW.unit_id, NEW.id, NEW.financial_invoice_installment_id, v_alloc, NEW.created_by, jsonb_build_object('auto', true))\n ON CONFLICT (payment_movement_id, installment_id) DO NOTHING;\n END IF;\n END IF;\n RETURN NULL;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_movements_after_insert() FROM public, anon, authenticated;\nDROP TRIGGER IF EXISTS plg_financial_movements_after_insert ON public.plg_financial_movements;\nCREATE TRIGGER plg_financial_movements_after_insert AFTER INSERT ON public.plg_financial_movements\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_movements_after_insert();\n\n-- append-only\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_movements_append_only()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF TG_OP = 'DELETE' THEN\n IF pg_trigger_depth() > 1 THEN RETURN OLD; END IF; -- a cascade (tenant removal)\n RAISE EXCEPTION 'plg_financial_movements is append-only: a money event is never deleted \u2014 reverse it (financial_reverse_movement)'\n USING ERRCODE = '55000';\n END IF;\n\n -- immutable on every row\n IF NEW.id <> OLD.id OR NEW.tenant_id <> OLD.tenant_id OR NEW.movement_kind <> OLD.movement_kind\n OR NEW.amount <> OLD.amount OR NEW.direction IS DISTINCT FROM OLD.direction\n OR NEW.created_at <> OLD.created_at\n OR (OLD.invoice_id IS NOT NULL AND NEW.invoice_id IS DISTINCT FROM OLD.invoice_id)\n OR (OLD.financial_invoice_id IS NOT NULL AND NEW.financial_invoice_id IS DISTINCT FROM OLD.financial_invoice_id)\n OR (OLD.financial_invoice_installment_id IS NOT NULL AND NEW.financial_invoice_installment_id IS DISTINCT FROM OLD.financial_invoice_installment_id)\n OR (OLD.reversed_by_id IS NOT NULL AND NEW.reversed_by_id IS DISTINCT FROM OLD.reversed_by_id)\n OR NEW.reverses_id IS DISTINCT FROM OLD.reverses_id\n -- A leg may be FILLED exactly once and never rewritten \u2014 same shape as the\n -- link columns three lines up. Rows that predate the two-leg model carry\n -- NULL legs, and giving them their posting is what the backfill below\n -- exists to do; refusing that made this migration abort on every pool that\n -- actually had legacy movements, which is every real V1 cutover (#199).\n OR (OLD.debit_account_id IS NOT NULL AND NEW.debit_account_id IS DISTINCT FROM OLD.debit_account_id)\n OR (OLD.credit_account_id IS NOT NULL AND NEW.credit_account_id IS DISTINCT FROM OLD.credit_account_id) THEN\n RAISE EXCEPTION 'plg_financial_movements is append-only: amount, kind, accounts and links of movement % cannot change \u2014 post a reversal instead', OLD.id\n USING ERRCODE = '55000';\n END IF;\n\n IF OLD.movement_kind IN ('bill', 'commission') THEN\n RETURN NEW; -- obligation mirrors: derived columns may move\n END IF;\n\n -- money events: only reconciliation marks, notes/metadata, reversal stamp\n IF NEW.paid_amount IS DISTINCT FROM OLD.paid_amount\n OR NEW.due_date IS DISTINCT FROM OLD.due_date\n OR NEW.payment_date IS DISTINCT FROM OLD.payment_date\n OR NEW.installment_number IS DISTINCT FROM OLD.installment_number\n OR NEW.payment_method_id IS DISTINCT FROM OLD.payment_method_id\n OR NEW.payment_method_type_id IS DISTINCT FROM OLD.payment_method_type_id\n OR NEW.bank_account_id IS DISTINCT FROM OLD.bank_account_id\n OR NEW.cash_session_id IS DISTINCT FROM OLD.cash_session_id\n OR NEW.card_brand IS DISTINCT FROM OLD.card_brand\n OR NEW.card_installments IS DISTINCT FROM OLD.card_installments\n OR NEW.fee_amount IS DISTINCT FROM OLD.fee_amount\n OR NEW.method IS DISTINCT FROM OLD.method\n OR NEW.nsu IS DISTINCT FROM OLD.nsu\n OR NEW.external_id IS DISTINCT FROM OLD.external_id\n OR NEW.external_source IS DISTINCT FROM OLD.external_source\n OR NEW.idempotency_key IS DISTINCT FROM OLD.idempotency_key THEN\n RAISE EXCEPTION 'plg_financial_movements is append-only: money event % cannot be edited \u2014 post a reversal (financial_reverse_movement) or a new movement', OLD.id\n USING ERRCODE = '55000';\n END IF;\n IF NEW.status IS DISTINCT FROM OLD.status THEN\n RAISE EXCEPTION 'plg_financial_movements is append-only: the status of money event % never changes \u2014 a reversal is a new movement (financial_reverse_movement)', OLD.id\n USING ERRCODE = '55000';\n END IF;\n RETURN NEW;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_movements_append_only() FROM public, anon, authenticated;\nDROP TRIGGER IF EXISTS plg_financial_movements_append_only ON public.plg_financial_movements;\nCREATE TRIGGER plg_financial_movements_append_only BEFORE UPDATE OR DELETE ON public.plg_financial_movements\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_movements_append_only();\n\n-- \u2500\u2500 triggers on allocations: the two \u03A3 invariants + mirror sync \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_allocations_check()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE v_pay record; v_inst record; v_sum_pay numeric; v_sum_inst numeric;\nBEGIN\n SELECT m.tenant_id, m.unit_id, m.movement_kind, m.amount, m.status, m.reversed_by_id INTO v_pay\n FROM public.plg_financial_movements m WHERE m.id = NEW.payment_movement_id;\n IF NOT FOUND OR v_pay.movement_kind <> 'payment' THEN\n RAISE EXCEPTION 'plg_financial_allocations: % is not a payment movement', NEW.payment_movement_id USING ERRCODE = '23514';\n END IF;\n IF v_pay.status = 'cancelled' OR v_pay.reversed_by_id IS NOT NULL THEN\n RAISE EXCEPTION 'plg_financial_allocations: payment % is cancelled or reversed', NEW.payment_movement_id USING ERRCODE = '23514';\n END IF;\n SELECT i.tenant_id, i.unit_id, i.amount, i.cancelled_at INTO v_inst\n FROM public.plg_financial_invoice_installments i WHERE i.id = NEW.installment_id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'plg_financial_allocations: installment % not found', NEW.installment_id USING ERRCODE = '23503';\n END IF;\n IF v_inst.tenant_id <> v_pay.tenant_id THEN\n RAISE EXCEPTION 'plg_financial_allocations: payment and installment belong to different tenants' USING ERRCODE = '23514';\n END IF;\n IF v_inst.cancelled_at IS NOT NULL THEN\n RAISE EXCEPTION 'plg_financial_allocations: installment % is cancelled', NEW.installment_id USING ERRCODE = '23514';\n END IF;\n NEW.tenant_id := v_pay.tenant_id;\n NEW.unit_id := coalesce(NEW.unit_id, v_inst.unit_id, v_pay.unit_id);\n NEW.created_by := coalesce(NEW.created_by, auth.uid());\n\n SELECT coalesce(sum(a.amount), 0) INTO v_sum_pay FROM public.plg_financial_allocations a\n WHERE a.payment_movement_id = NEW.payment_movement_id AND (TG_OP = 'INSERT' OR a.id <> NEW.id);\n IF v_sum_pay + NEW.amount > v_pay.amount + 0.000001 THEN\n RAISE EXCEPTION 'plg_financial_allocations: allocations (%) exceed the payment amount (%)', v_sum_pay + NEW.amount, v_pay.amount\n USING ERRCODE = '23514';\n END IF;\n SELECT coalesce(sum(a.amount), 0) INTO v_sum_inst FROM public.plg_financial_allocations a\n JOIN public.plg_financial_movements m ON m.id = a.payment_movement_id\n WHERE a.installment_id = NEW.installment_id AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL AND (TG_OP = 'INSERT' OR a.id <> NEW.id);\n IF v_sum_inst + NEW.amount > v_inst.amount + 0.000001 THEN\n RAISE EXCEPTION 'plg_financial_allocations: allocations (%) exceed the installment amount (%)', v_sum_inst + NEW.amount, v_inst.amount\n USING ERRCODE = '23514';\n END IF;\n RETURN NEW;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_allocations_check() FROM public, anon, authenticated;\nDROP TRIGGER IF EXISTS plg_financial_allocations_check ON public.plg_financial_allocations;\nCREATE TRIGGER plg_financial_allocations_check BEFORE INSERT OR UPDATE ON public.plg_financial_allocations\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_allocations_check();\n\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_allocations_sync()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF TG_OP IN ('INSERT', 'UPDATE') THEN PERFORM public.plg_financial_sync_mirror(NEW.installment_id); END IF;\n IF TG_OP IN ('UPDATE', 'DELETE') AND (TG_OP = 'DELETE' OR OLD.installment_id <> NEW.installment_id) THEN\n PERFORM public.plg_financial_sync_mirror(OLD.installment_id);\n END IF;\n RETURN NULL;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_allocations_sync() FROM public, anon, authenticated;\nDROP TRIGGER IF EXISTS plg_financial_allocations_sync ON public.plg_financial_allocations;\nCREATE TRIGGER plg_financial_allocations_sync AFTER INSERT OR UPDATE OR DELETE ON public.plg_financial_allocations\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_allocations_sync();\n\n-- \u2500\u2500 backfill: legacy rows get their legs, their allocations, their receipts \u2500\nCREATE OR REPLACE FUNCTION public.plg_financial_backfill_ledger()\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n r record; v_legs record; v_open numeric; v_alloc numeric; v_paid numeric; v_id uuid;\n v_legs_n int := 0; v_alloc_n int := 0; v_synth_n int := 0; v_open_n int := 0;\nBEGIN\n -- 1. legs on every legless payment/transfer; mirrors marked\n FOR r IN\n SELECT m.* FROM public.plg_financial_movements m\n WHERE m.debit_account_id IS NULL AND m.credit_account_id IS NULL\n AND NOT (m.metadata ? 'legacy_mirror')\n ORDER BY m.created_at, m.id\n LOOP\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n r.tenant_id, r.movement_kind, r.direction, r.bank_account_id, r.cash_session_id, r.financial_invoice_id, r.metadata);\n IF v_legs.o_mirror THEN\n UPDATE public.plg_financial_movements SET metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('legacy_mirror', true) WHERE id = r.id;\n ELSE\n UPDATE public.plg_financial_movements\n SET debit_account_id = v_legs.o_debit, credit_account_id = v_legs.o_credit,\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('legacy_shape', true, 'legs_backfilled', true)\n WHERE id = r.id;\n v_legs_n := v_legs_n + 1;\n END IF;\n END LOOP;\n\n -- 2. allocations from the links 016 made\n FOR r IN\n SELECT m.* FROM public.plg_financial_movements m\n WHERE m.movement_kind = 'payment' AND m.status <> 'cancelled' AND m.financial_invoice_installment_id IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_allocations a WHERE a.payment_movement_id = m.id)\n ORDER BY m.payment_date NULLS LAST, m.created_at, m.id\n LOOP\n SELECT i.amount - public.plg_financial_installment_paid(i.id) INTO v_open\n FROM public.plg_financial_invoice_installments i WHERE i.id = r.financial_invoice_installment_id AND i.cancelled_at IS NULL;\n v_alloc := LEAST(coalesce(v_open, 0), r.amount);\n IF v_alloc > 0 THEN\n INSERT INTO public.plg_financial_allocations (tenant_id, unit_id, payment_movement_id, installment_id, amount, metadata)\n VALUES (r.tenant_id, r.unit_id, r.id, r.financial_invoice_installment_id, v_alloc, jsonb_build_object('backfilled', true))\n ON CONFLICT (payment_movement_id, installment_id) DO NOTHING;\n v_alloc_n := v_alloc_n + 1;\n END IF;\n END LOOP;\n\n -- 3. R14 \u2014 a bill that says paid without the receipt: the synthetic \"Cr\u00E9dito Legado\"\n FOR r IN\n SELECT b.*, i.amount AS inst_amount, t.direction AS invoice_dir\n FROM public.plg_financial_movements b\n JOIN public.plg_financial_invoice_installments i ON i.id = b.financial_invoice_installment_id\n JOIN public.plg_financial_invoices t ON t.id = i.invoice_id\n WHERE b.movement_kind = 'bill' AND b.status <> 'cancelled' AND i.cancelled_at IS NULL\n AND coalesce(b.paid_amount, 0) > public.plg_financial_installment_paid(i.id) + 0.005\n AND (b.metadata ? 'legacy_mirror')\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_movements s WHERE s.metadata ->> 'legacy_credit_for_bill' = b.id::text)\n ORDER BY b.created_at, b.id\n LOOP\n v_paid := public.plg_financial_installment_paid(r.financial_invoice_installment_id);\n v_alloc := LEAST(round(coalesce(r.paid_amount, 0) - v_paid, 2), round(r.inst_amount - v_paid, 2));\n CONTINUE WHEN v_alloc <= 0;\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, invoice_id, financial_invoice_id, financial_invoice_installment_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, notes, metadata,\n debit_account_id, credit_account_id)\n VALUES\n (r.tenant_id, r.unit_id, r.invoice_id, r.financial_invoice_id, r.financial_invoice_installment_id,\n CASE WHEN r.invoice_dir = 'payable' THEN 'debit' ELSE 'credit' END, 'payment', v_alloc, v_alloc, 'paid',\n r.due_date, coalesce(r.payment_date, r.updated_at::date, current_date), r.installment_number,\n 'Cr\u00E9dito Legado', jsonb_build_object('synthetic', 'legacy_credit', 'legacy_credit_for_bill', r.id, 'source', 'plg_financial_backfill_ledger'),\n CASE WHEN r.invoice_dir = 'payable' THEN public.plg_financial_system_account(r.tenant_id, 'payables')\n ELSE public.plg_financial_system_account(r.tenant_id, 'legacy_credit') END,\n CASE WHEN r.invoice_dir = 'payable' THEN public.plg_financial_system_account(r.tenant_id, 'legacy_credit')\n ELSE public.plg_financial_system_account(r.tenant_id, 'receivables') END)\n RETURNING id INTO v_id;\n INSERT INTO public.plg_financial_allocations (tenant_id, unit_id, payment_movement_id, installment_id, amount, metadata)\n VALUES (r.tenant_id, r.unit_id, v_id, r.financial_invoice_installment_id, v_alloc, jsonb_build_object('synthetic', 'legacy_credit'))\n ON CONFLICT DO NOTHING;\n v_synth_n := v_synth_n + 1;\n END LOOP;\n\n -- 4. legacy initial balances become opening movements\n FOR r IN\n SELECT a.* FROM public.plg_financial_accounts a\n WHERE coalesce((a.metadata ->> 'legacy_initial_balance')::numeric, 0) <> 0\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_movements m WHERE m.metadata ->> 'opening_balance_for' = a.id::text)\n LOOP\n v_alloc := (r.metadata ->> 'legacy_initial_balance')::numeric;\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, notes, metadata,\n debit_account_id, credit_account_id)\n VALUES\n (r.tenant_id, r.unit_id, CASE WHEN v_alloc > 0 THEN 'credit' ELSE 'debit' END, 'transfer', abs(round(v_alloc, 2)), abs(round(v_alloc, 2)), 'paid',\n r.created_at::date, r.created_at::date, 'Saldo de abertura (migrado)',\n jsonb_build_object('opening_balance_for', r.id, 'source', 'plg_financial_backfill_ledger'),\n CASE WHEN v_alloc > 0 THEN r.id ELSE public.plg_financial_system_account(r.tenant_id, 'opening_balances') END,\n CASE WHEN v_alloc > 0 THEN public.plg_financial_system_account(r.tenant_id, 'opening_balances') ELSE r.id END);\n v_open_n := v_open_n + 1;\n END LOOP;\n\n RETURN jsonb_build_object('legs', v_legs_n, 'allocations', v_alloc_n, 'legacy_credit_receipts', v_synth_n, 'opening_balances', v_open_n);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_backfill_ledger() FROM public, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_financial_backfill_ledger() TO service_role;\n\nDO $$\nDECLARE v jsonb;\nBEGIN\n v := public.plg_financial_backfill_ledger();\n RAISE NOTICE 'plg_financial_backfill_ledger: %', v;\nEND $$;\n\n-- Only now \u2014 every legacy row carries its legs or its mirror mark.\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_movements'::regclass AND conname = 'plg_financial_movements_legs_check') THEN\n ALTER TABLE public.plg_financial_movements ADD CONSTRAINT plg_financial_movements_legs_check\n CHECK (movement_kind IN ('bill', 'commission')\n OR (metadata ? 'legacy_mirror')\n OR (debit_account_id IS NOT NULL AND credit_account_id IS NOT NULL AND debit_account_id <> credit_account_id)) NOT VALID;\n END IF;\nEND $$;\nDO $$\nBEGIN\n ALTER TABLE public.plg_financial_movements VALIDATE CONSTRAINT plg_financial_movements_legs_check;\nEXCEPTION WHEN check_violation THEN\n RAISE WARNING 'plg_financial_movements_legs_check left NOT VALID: legacy rows without legs remain';\nEND $$;\n\n-- \u2500\u2500 balances: views only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE VIEW public.v_financial_account_balances AS\nWITH d AS (\n SELECT m.debit_account_id AS account_id, sum(m.amount) AS total, count(*) AS n\n FROM public.plg_financial_movements m\n WHERE m.status <> 'cancelled' AND m.debit_account_id IS NOT NULL AND m.movement_kind IN ('payment', 'transfer')\n GROUP BY m.debit_account_id\n), c AS (\n SELECT m.credit_account_id AS account_id, sum(m.amount) AS total, count(*) AS n\n FROM public.plg_financial_movements m\n WHERE m.status <> 'cancelled' AND m.credit_account_id IS NOT NULL AND m.movement_kind IN ('payment', 'transfer')\n GROUP BY m.credit_account_id\n)\nSELECT\n a.id AS account_id, a.tenant_id, a.unit_id, a.kind, a.code, a.name, a.person_id, a.is_active,\n a.account_type, a.bank_name, a.agency_number, a.account_number, a.metadata,\n COALESCE(d.total, 0)::numeric(14,2) AS debit_total,\n COALESCE(c.total, 0)::numeric(14,2) AS credit_total,\n (COALESCE(c.total, 0) - COALESCE(d.total, 0))::numeric(14,2) AS net_credit,\n -- signed by the account's normal side: what an asset holds, what a counterparty is owed\n CASE WHEN a.kind IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit', 'card_receivable_debit')\n THEN COALESCE(d.total, 0) - COALESCE(c.total, 0)\n ELSE COALESCE(c.total, 0) - COALESCE(d.total, 0) END::numeric(14,2) AS balance,\n (COALESCE(d.n, 0) + COALESCE(c.n, 0))::int AS movement_count,\n a.created_at, a.updated_at\nFROM public.plg_financial_accounts a\nLEFT JOIN d ON d.account_id = a.id\nLEFT JOIN c ON c.account_id = a.id;\nALTER VIEW public.v_financial_account_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_account_balances TO authenticated, service_role;\n\n-- Customer credit = the credit-side balance of the Person's account.\nCREATE OR REPLACE VIEW public.v_financial_customer_credit AS\nSELECT b.tenant_id, b.person_id, b.account_id, p.name AS person_name,\n b.credit_total AS credited, b.debit_total AS consumed,\n b.balance AS credit, b.movement_count, b.updated_at\n FROM public.v_financial_account_balances b\n LEFT JOIN public.people p ON p.id = b.person_id\n WHERE b.kind = 'person';\nALTER VIEW public.v_financial_customer_credit SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_customer_credit TO authenticated, service_role;\n\n-- Installment / invoice balances now read allocations (same columns as 014).\nCREATE OR REPLACE VIEW public.v_financial_installment_balances AS\nWITH paid AS (\n SELECT a.installment_id, sum(a.amount) AS paid\n FROM public.plg_financial_allocations a\n JOIN public.plg_financial_movements m ON m.id = a.payment_movement_id\n WHERE m.status <> 'cancelled' AND m.reversed_by_id IS NULL\n GROUP BY a.installment_id\n)\nSELECT\n i.id AS installment_id, i.tenant_id, i.unit_id, i.invoice_id, t.number AS invoice_number, t.direction,\n t.counterparty_person_id, t.order_id, i.number, i.due_date, i.amount,\n COALESCE(p.paid, 0)::numeric(14,2) AS paid,\n CASE WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 0\n ELSE GREATEST(i.amount - COALESCE(p.paid, 0), 0) END::numeric(14,2) AS balance,\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(p.paid, 0) >= i.amount THEN 'paid'\n WHEN COALESCE(p.paid, 0) > 0 THEN 'partial'\n WHEN i.due_date < current_date THEN 'overdue'\n ELSE 'pending'\n END AS status,\n i.cancelled_at, i.metadata, i.created_at, i.updated_at\nFROM public.plg_financial_invoice_installments i\nJOIN public.plg_financial_invoices t ON t.id = i.invoice_id\nLEFT JOIN paid p ON p.installment_id = i.id;\nALTER VIEW public.v_financial_installment_balances SET (security_invoker = true);\n\n-- The ledger as the UI reads it: installments as the obligation lines\n-- (movement_kind='bill' shape) plus every money event; per-order mirror rows\n-- are represented by their installment, orphan legacy rows as themselves.\nCREATE OR REPLACE VIEW public.v_financial_ledger AS\nSELECT\n i.installment_id AS id, i.tenant_id, i.unit_id,\n i.invoice_id, i.installment_id AS invoice_installment_id, i.order_id,\n CASE WHEN i.direction = 'payable' THEN 'debit' ELSE 'credit' END AS direction,\n 'bill'::text AS movement_kind, i.amount, i.paid AS paid_amount, i.status, i.due_date,\n NULL::date AS payment_date, i.number AS installment_number,\n NULL::uuid AS payment_method_id, NULL::uuid AS payment_method_type_id, NULL::uuid AS bank_account_id,\n NULL::uuid AS cash_session_id, NULL::text AS card_brand, NULL::int AS card_installments,\n 0::numeric(14,2) AS fee_amount, NULL::uuid AS debit_account_id, NULL::uuid AS credit_account_id,\n NULL::text AS method, NULL::text AS nsu, NULL::text AS notes, i.metadata,\n NULL::text AS external_source, NULL::text AS external_id, NULL::timestamptz AS reconciled_at, NULL::uuid AS matched_movement_id,\n NULL::uuid AS reversed_by_id, NULL::uuid AS reverses_id, NULL::text AS reason,\n 'installment'::text AS entry_kind, i.created_at, i.updated_at\nFROM public.v_financial_installment_balances i\nUNION ALL\nSELECT\n m.id, m.tenant_id, m.unit_id,\n m.financial_invoice_id AS invoice_id, m.financial_invoice_installment_id AS invoice_installment_id, m.invoice_id AS order_id,\n m.direction, m.movement_kind, m.amount, m.paid_amount, m.status, m.due_date,\n m.payment_date, m.installment_number,\n m.payment_method_id, m.payment_method_type_id,\n COALESCE(m.bank_account_id,\n CASE WHEN m.direction = 'credit' THEN m.debit_account_id ELSE m.credit_account_id END) AS bank_account_id,\n m.cash_session_id, m.card_brand, m.card_installments,\n COALESCE(m.fee_amount, 0)::numeric(14,2) AS fee_amount, m.debit_account_id, m.credit_account_id,\n m.method, m.nsu, m.notes, m.metadata,\n m.external_source, m.external_id, m.reconciled_at, m.matched_movement_id,\n m.reversed_by_id, m.reverses_id, m.reason,\n 'movement'::text AS entry_kind, m.created_at, m.updated_at\nFROM public.plg_financial_movements m\nWHERE NOT (m.movement_kind IN ('bill', 'commission') AND m.financial_invoice_installment_id IS NOT NULL)\n AND NOT (m.movement_kind = 'transfer' AND (m.metadata ? 'legacy_mirror'));\nALTER VIEW public.v_financial_ledger SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_ledger TO authenticated, service_role;\n\nNOTIFY pgrst, 'reload schema';\n";
26
+ export declare const MIGRATION_023_MONEY_RPCS = "-- ============================================================================\n-- plugin-financial 019 \u2014 the money RPCs (#142).\n--\n-- financial_receive_payment(installment, amount, account, method, \u2026) \u2192 jsonb\n-- financial_transfer(from_account, to_account, amount, \u2026) \u2192 jsonb\n-- financial_credit_customer(person, amount, to_account, method, \u2026) \u2192 jsonb (RN-FIN-10: sinal)\n-- financial_cancel_paid_item(order_item, reason, \u2026) \u2192 jsonb (RN-FIN-14: cr\u00E9dito)\n-- financial_reverse_movement(movement, reason, \u2026) \u2192 jsonb\n--\n-- Same contract as 015: SECURITY DEFINER, search_path = '', actor auth.uid(),\n-- tenant app.current_tenant_id(), permission via app.has_permission (42501),\n-- idempotency key (a replay returns the first result, no second effect), one\n-- audit row per effect. Every RPC posts explicit legs, so the legacy completion\n-- triggers of 018 never touch a row it writes.\n--\n-- Money in for a receivable: debit the holder (bank / cofre / caixinha drawer /\n-- card receivable / Person when paying with credit), credit `receivables`.\n-- Money out for a payable: debit `payables`, credit the holder. Cash goes to\n-- the caller's OPEN caixinha in the installment's unit \u2014 no open session, no\n-- cash receipt (RN-FIN-03). Card methods go to the matching card receivable\n-- account (RN-FIN-05); NSU optional (RN-FIN-06).\n-- Idempotent and replay-safe.\n-- ============================================================================\n\n-- the caller's open cash session in a unit (NULL when none)\nCREATE OR REPLACE FUNCTION public.plg_financial_open_session_of(p_tenant uuid, p_user uuid, p_unit uuid)\nRETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$\n SELECT s.id FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = p_tenant AND s.user_id = p_user AND s.status = 'open'\n AND (p_unit IS NULL OR s.unit_id = p_unit)\n ORDER BY s.opened_at DESC LIMIT 1;\n$$;\nREVOKE ALL ON FUNCTION public.plg_financial_open_session_of(uuid, uuid, uuid) FROM public, anon, authenticated;\n\n-- \u2500\u2500 financial_receive_payment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.financial_receive_payment(\n p_installment_id uuid,\n p_amount numeric,\n p_account_id uuid DEFAULT NULL,\n p_method text DEFAULT NULL,\n p_cash_session_id uuid DEFAULT NULL,\n p_card_brand text DEFAULT NULL,\n p_card_installments integer DEFAULT NULL,\n p_nsu text DEFAULT NULL,\n p_payment_date date DEFAULT NULL,\n p_fee_amount numeric DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_idempotency_key text DEFAULT NULL,\n p_metadata jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_actor uuid := auth.uid();\n v_inst_id uuid := p_installment_id;\n v_inst record;\n v_invoice public.plg_financial_invoices%ROWTYPE;\n v_amount numeric := round(coalesce(p_amount, 0), 2);\n v_open numeric;\n v_method text := lower(coalesce(nullif(btrim(p_method), ''), 'other'));\n v_holder uuid;\n v_holder_k text;\n v_session uuid := p_cash_session_id;\n v_counter uuid;\n v_credit numeric;\n v_mov_id uuid;\n v_alloc_id uuid;\n v_dir text;\nBEGIN\n IF v_amount <= 0 THEN\n RAISE EXCEPTION 'financial_receive_payment: amount must be > 0' USING ERRCODE = '22023';\n END IF;\n IF v_method NOT IN ('cash', 'pix', 'credit_card', 'debit_card', 'transfer', 'boleto', 'check', 'credit', 'other') THEN\n RAISE EXCEPTION 'financial_receive_payment: unknown method %', v_method USING ERRCODE = '22023';\n END IF;\n\n -- accept a legacy per-order 'bill' movement id in place of the installment\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_invoice_installments i WHERE i.id = v_inst_id) THEN\n SELECT m.financial_invoice_installment_id INTO v_inst_id FROM public.plg_financial_movements m\n WHERE m.id = p_installment_id AND m.movement_kind = 'bill' AND m.tenant_id = v_tenant;\n END IF;\n SELECT i.* INTO v_inst FROM public.plg_financial_invoice_installments i WHERE i.id = v_inst_id AND i.tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_receive_payment: installment % not found', p_installment_id USING ERRCODE = 'P0002';\n END IF;\n SELECT * INTO v_invoice FROM public.plg_financial_invoices WHERE id = v_inst.invoice_id;\n PERFORM public.plg_financial_require_permission('financial.create', v_inst.unit_id);\n\n -- idempotency\n IF p_idempotency_key IS NOT NULL THEN\n SELECT m.id INTO v_mov_id FROM public.plg_financial_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = p_idempotency_key;\n IF v_mov_id IS NOT NULL THEN\n SELECT a.id INTO v_alloc_id FROM public.plg_financial_allocations a WHERE a.payment_movement_id = v_mov_id LIMIT 1;\n RETURN jsonb_build_object('movement_id', v_mov_id, 'allocation_id', v_alloc_id, 'installment_id', v_inst.id,\n 'invoice_id', v_inst.invoice_id, 'amount', v_amount, 'replayed', true);\n END IF;\n END IF;\n\n IF v_inst.cancelled_at IS NOT NULL OR v_invoice.cancelled_at IS NOT NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: installment is cancelled' USING ERRCODE = '55000';\n END IF;\n v_open := round(v_inst.amount - public.plg_financial_installment_paid(v_inst.id), 2);\n IF v_amount > v_open + 0.000001 THEN\n RAISE EXCEPTION 'financial_receive_payment: % exceeds the open balance % of installment %/% of %', v_amount, v_open, v_inst.number, v_invoice.number, v_invoice.number\n USING ERRCODE = '23514';\n END IF;\n\n -- the money holder\n IF v_method = 'credit' THEN\n IF v_invoice.counterparty_person_id IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: the invoice has no counterparty to consume credit from' USING ERRCODE = '55000';\n END IF;\n v_holder := public.plg_financial_person_account(v_tenant, v_invoice.counterparty_person_id);\n SELECT balance INTO v_credit FROM public.v_financial_account_balances WHERE account_id = v_holder;\n IF coalesce(v_credit, 0) + 0.000001 < v_amount THEN\n RAISE EXCEPTION 'financial_receive_payment: customer credit % is below %', coalesce(v_credit, 0), v_amount USING ERRCODE = '23514';\n END IF;\n ELSIF v_method = 'cash' THEN\n IF v_session IS NULL THEN\n v_session := public.plg_financial_open_session_of(v_tenant, v_actor, v_inst.unit_id);\n END IF;\n IF v_session IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: no open cash session (caixinha) for cash in this unit' USING ERRCODE = '55000',\n HINT = 'Open your cash session first (financial_cash_session_open).';\n END IF;\n SELECT s.account_id INTO v_holder FROM public.plg_financial_cash_register_sessions s\n WHERE s.id = v_session AND s.tenant_id = v_tenant AND s.status = 'open';\n IF v_holder IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: cash session % is not open', v_session USING ERRCODE = '55000';\n END IF;\n ELSE\n v_holder := p_account_id;\n IF v_holder IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: an account is required for method %', v_method USING ERRCODE = '22023';\n END IF;\n END IF;\n SELECT a.kind INTO v_holder_k FROM public.plg_financial_accounts a WHERE a.id = v_holder AND a.tenant_id = v_tenant AND a.is_active;\n IF v_holder_k IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: account % not found or inactive', v_holder USING ERRCODE = 'P0002';\n END IF;\n IF (v_method = 'credit_card' AND v_holder_k <> 'card_receivable_credit')\n OR (v_method = 'debit_card' AND v_holder_k <> 'card_receivable_debit') THEN\n RAISE EXCEPTION 'financial_receive_payment: method % must settle in a % account', v_method,\n CASE WHEN v_method = 'credit_card' THEN 'card_receivable_credit' ELSE 'card_receivable_debit' END USING ERRCODE = '23514';\n END IF;\n IF v_method NOT IN ('credit_card', 'debit_card') AND v_holder_k IN ('card_receivable_credit', 'card_receivable_debit') THEN\n RAISE EXCEPTION 'financial_receive_payment: a card receivable account only takes card methods' USING ERRCODE = '23514';\n END IF;\n\n IF v_invoice.direction = 'payable' THEN\n v_dir := 'debit';\n v_counter := public.plg_financial_system_account(v_tenant, 'payables');\n ELSE\n v_dir := 'credit';\n v_counter := public.plg_financial_system_account(v_tenant, 'receivables');\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, invoice_id, financial_invoice_id, financial_invoice_installment_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, method, card_brand, card_installments, nsu, cash_session_id,\n bank_account_id, fee_amount, notes, metadata, idempotency_key, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_inst.unit_id, v_invoice.order_id, v_invoice.id, v_inst.id, v_dir, 'payment', v_amount, v_amount, 'paid',\n v_inst.due_date, coalesce(p_payment_date, current_date), v_inst.number, v_method, p_card_brand, p_card_installments, p_nsu, v_session,\n CASE WHEN v_holder_k IN ('person', 'system') THEN NULL ELSE v_holder END,\n round(coalesce(p_fee_amount, 0), 2), p_notes,\n coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'financial_receive_payment'),\n p_idempotency_key, v_actor,\n CASE WHEN v_dir = 'credit' THEN v_holder ELSE v_counter END,\n CASE WHEN v_dir = 'credit' THEN v_counter ELSE v_holder END)\n RETURNING id INTO v_mov_id;\n\n INSERT INTO public.plg_financial_allocations (tenant_id, unit_id, payment_movement_id, installment_id, amount, created_by)\n VALUES (v_tenant, v_inst.unit_id, v_mov_id, v_inst.id, v_amount, v_actor)\n RETURNING id INTO v_alloc_id;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.payment.receive', 'plg_financial_movements', v_mov_id::text,\n jsonb_build_object('installment_id', v_inst.id, 'invoice_id', v_invoice.id, 'invoice_number', v_invoice.number, 'amount', v_amount,\n 'method', v_method, 'account_id', v_holder, 'cash_session_id', v_session, 'idempotency_key', p_idempotency_key));\n\n RETURN jsonb_build_object('movement_id', v_mov_id, 'allocation_id', v_alloc_id, 'installment_id', v_inst.id,\n 'invoice_id', v_inst.invoice_id, 'amount', v_amount, 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_receive_payment(uuid, numeric, uuid, text, uuid, text, integer, text, date, numeric, text, text, jsonb) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_receive_payment(uuid, numeric, uuid, text, uuid, text, integer, text, date, numeric, text, text, jsonb) TO authenticated, service_role;\n\n-- \u2500\u2500 financial_transfer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Money from one Conta to another, no invoice. A Person account may be either\n-- side: from = deposit (customer credit up), to = refund of credit.\nCREATE OR REPLACE FUNCTION public.financial_transfer(\n p_from_account_id uuid,\n p_to_account_id uuid,\n p_amount numeric,\n p_date date DEFAULT NULL,\n p_method text DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_idempotency_key text DEFAULT NULL,\n p_metadata jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_from record; v_to record;\n v_amount numeric := round(coalesce(p_amount, 0), 2);\n v_mov_id uuid;\nBEGIN\n IF v_amount <= 0 THEN\n RAISE EXCEPTION 'financial_transfer: amount must be > 0' USING ERRCODE = '22023';\n END IF;\n IF p_from_account_id IS NULL OR p_to_account_id IS NULL OR p_from_account_id = p_to_account_id THEN\n RAISE EXCEPTION 'financial_transfer: source and destination must be two different accounts' USING ERRCODE = '22023';\n END IF;\n SELECT a.id, a.unit_id, a.kind INTO v_from FROM public.plg_financial_accounts a WHERE a.id = p_from_account_id AND a.tenant_id = v_tenant AND a.is_active;\n SELECT a.id, a.unit_id, a.kind INTO v_to FROM public.plg_financial_accounts a WHERE a.id = p_to_account_id AND a.tenant_id = v_tenant AND a.is_active;\n IF v_from.id IS NULL OR v_to.id IS NULL THEN\n RAISE EXCEPTION 'financial_transfer: account not found or inactive' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.create', coalesce(v_to.unit_id, v_from.unit_id));\n IF v_from.unit_id IS NOT NULL AND v_to.unit_id IS NOT NULL AND v_from.unit_id <> v_to.unit_id THEN\n PERFORM public.plg_financial_require_permission('financial.create', v_from.unit_id);\n END IF;\n\n IF p_idempotency_key IS NOT NULL THEN\n SELECT m.id INTO v_mov_id FROM public.plg_financial_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = p_idempotency_key;\n IF v_mov_id IS NOT NULL THEN\n RETURN jsonb_build_object('movement_id', v_mov_id, 'amount', v_amount, 'replayed', true);\n END IF;\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method,\n bank_account_id, notes, metadata, idempotency_key, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, coalesce(v_to.unit_id, v_from.unit_id),\n CASE WHEN v_to.kind IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit', 'card_receivable_debit') THEN 'credit' ELSE 'debit' END,\n 'transfer', v_amount, v_amount, 'paid', coalesce(p_date, current_date), coalesce(p_date, current_date), lower(nullif(btrim(p_method), '')),\n CASE WHEN v_to.kind IN ('person', 'system') THEN CASE WHEN v_from.kind IN ('person', 'system') THEN NULL ELSE v_from.id END ELSE v_to.id END,\n p_notes, coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'financial_transfer', 'from_account_id', v_from.id, 'to_account_id', v_to.id),\n p_idempotency_key, auth.uid(), v_to.id, v_from.id)\n RETURNING id INTO v_mov_id;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.transfer', 'plg_financial_movements', v_mov_id::text,\n jsonb_build_object('from_account_id', v_from.id, 'to_account_id', v_to.id, 'amount', v_amount, 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('movement_id', v_mov_id, 'amount', v_amount, 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_transfer(uuid, uuid, numeric, date, text, text, text, jsonb) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_transfer(uuid, uuid, numeric, date, text, text, text, jsonb) TO authenticated, service_role;\n\n-- \u2500\u2500 financial_credit_customer: a deposit without a sale (RN-FIN-10) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.financial_credit_customer(\n p_person_id uuid,\n p_amount numeric,\n p_to_account_id uuid,\n p_method text DEFAULT 'pix',\n p_reason text DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_person_acc uuid;\n v_res jsonb; v_credit numeric;\nBEGIN\n IF p_person_id IS NULL THEN\n RAISE EXCEPTION 'financial_credit_customer: person required' USING ERRCODE = '22023';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = p_person_id AND p.tenant_id = v_tenant) THEN\n RAISE EXCEPTION 'financial_credit_customer: person % not found in this tenant', p_person_id USING ERRCODE = 'P0002';\n END IF;\n v_person_acc := public.plg_financial_person_account(v_tenant, p_person_id);\n v_res := public.financial_transfer(v_person_acc, p_to_account_id, p_amount, current_date, p_method, p_notes, p_idempotency_key,\n jsonb_build_object('credit_source', 'deposit', 'reason', p_reason, 'person_id', p_person_id));\n SELECT credit INTO v_credit FROM public.v_financial_customer_credit WHERE account_id = v_person_acc;\n IF NOT coalesce((v_res ->> 'replayed')::boolean, false) THEN\n PERFORM public.plg_financial_audit(v_tenant, 'financial.credit.deposit', 'plg_financial_accounts', v_person_acc::text,\n jsonb_build_object('person_id', p_person_id, 'amount', round(p_amount, 2), 'movement_id', v_res ->> 'movement_id', 'reason', p_reason));\n END IF;\n RETURN v_res || jsonb_build_object('person_account_id', v_person_acc, 'credit', coalesce(v_credit, 0));\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_credit_customer(uuid, numeric, uuid, text, text, text, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_credit_customer(uuid, numeric, uuid, text, text, text, text) TO authenticated, service_role;\n\n-- \u2500\u2500 financial_cancel_paid_item: a paid item cancelled becomes credit (RN-FIN-14) \u2500\n-- The money stays where it landed; the customer's Person account is credited\n-- from the `cancellations` account for the item's value, with a mandatory\n-- reason. The line is marked execution_status='cancelled'.\nCREATE OR REPLACE FUNCTION public.financial_cancel_paid_item(\n p_order_item_id uuid,\n p_reason text,\n p_idempotency_key text DEFAULT NULL,\n p_amount numeric DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_item public.order_items%ROWTYPE;\n v_order public.orders%ROWTYPE;\n v_amount numeric;\n v_paid numeric; v_credited numeric;\n v_person_acc uuid; v_cancel_acc uuid; v_mov_id uuid; v_credit numeric;\nBEGIN\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: a reason is required' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_item FROM public.order_items WHERE id = p_order_item_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: order item % not found', p_order_item_id USING ERRCODE = 'P0002';\n END IF;\n SELECT * INTO v_order FROM public.orders WHERE id = v_item.order_id;\n PERFORM public.plg_financial_require_permission('financial.edit', v_order.unit_id);\n IF v_order.party_id IS NULL THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: the sale has no customer to credit' USING ERRCODE = '55000';\n END IF;\n v_amount := round(coalesce(p_amount, v_item.total, 0), 2);\n IF v_amount <= 0 THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: nothing to credit' USING ERRCODE = '22023';\n END IF;\n\n IF p_idempotency_key IS NOT NULL THEN\n SELECT m.id INTO v_mov_id FROM public.plg_financial_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = p_idempotency_key;\n IF v_mov_id IS NOT NULL THEN\n v_person_acc := public.plg_financial_person_account(v_tenant, v_order.party_id);\n SELECT credit INTO v_credit FROM public.v_financial_customer_credit WHERE account_id = v_person_acc;\n RETURN jsonb_build_object('movement_id', v_mov_id, 'person_account_id', v_person_acc, 'credit_amount', v_amount, 'credit', coalesce(v_credit, 0), 'replayed', true);\n END IF;\n END IF;\n IF v_item.execution_status = 'cancelled' THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: item is already cancelled' USING ERRCODE = '55000';\n END IF;\n\n -- \"paid\" = the sale's invoices received at least this much beyond what was already credited back\n SELECT coalesce(sum(t.paid), 0) INTO v_paid FROM public.v_financial_invoice_balances t WHERE t.order_id = v_order.id;\n SELECT coalesce(sum(m.amount), 0) INTO v_credited FROM public.plg_financial_movements m\n WHERE m.tenant_id = v_tenant AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL\n AND m.metadata ->> 'cancelled_order_id' = v_order.id::text;\n IF v_paid - v_credited + 0.000001 < v_amount THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: the sale has % received (% already credited back); the item is not paid for %', v_paid, v_credited, v_amount\n USING ERRCODE = '55000';\n END IF;\n\n v_person_acc := public.plg_financial_person_account(v_tenant, v_order.party_id);\n v_cancel_acc := public.plg_financial_system_account(v_tenant, 'cancellations');\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method,\n reason, notes, metadata, idempotency_key, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_order.unit_id, v_order.id, 'debit', 'transfer', v_amount, v_amount, 'paid', current_date, current_date, 'credit',\n btrim(p_reason), NULL,\n jsonb_build_object('source', 'financial_cancel_paid_item', 'credit_source', 'cancellation',\n 'cancelled_order_item_id', v_item.id, 'cancelled_order_id', v_order.id, 'reason', btrim(p_reason)),\n p_idempotency_key, auth.uid(), v_cancel_acc, v_person_acc)\n RETURNING id INTO v_mov_id;\n\n UPDATE public.order_items\n SET execution_status = 'cancelled',\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('cancelReason', btrim(p_reason), 'cancelledAt', now(), 'creditMovementId', v_mov_id)\n WHERE id = v_item.id;\n\n SELECT credit INTO v_credit FROM public.v_financial_customer_credit WHERE account_id = v_person_acc;\n PERFORM public.plg_financial_audit(v_tenant, 'financial.item.cancel_to_credit', 'order_items', v_item.id::text,\n jsonb_build_object('order_id', v_order.id, 'amount', v_amount, 'reason', btrim(p_reason), 'movement_id', v_mov_id, 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('movement_id', v_mov_id, 'person_account_id', v_person_acc, 'credit_amount', v_amount, 'credit', coalesce(v_credit, 0), 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_cancel_paid_item(uuid, text, text, numeric) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_cancel_paid_item(uuid, text, text, numeric) TO authenticated, service_role;\n\n-- \u2500\u2500 financial_reverse_movement \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- The reversal is a NEW movement with the legs swapped; the original keeps its\n-- status and gains reversed_by_id. A reversed payment's allocations stop\n-- counting, so its installment is open again.\nCREATE OR REPLACE FUNCTION public.financial_reverse_movement(\n p_movement_id uuid,\n p_reason text,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_m public.plg_financial_movements%ROWTYPE;\n v_rev uuid;\nBEGIN\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'financial_reverse_movement: a reason is required' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_m FROM public.plg_financial_movements WHERE id = p_movement_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_reverse_movement: movement % not found', p_movement_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.edit', v_m.unit_id);\n IF v_m.movement_kind NOT IN ('payment', 'transfer') OR v_m.debit_account_id IS NULL THEN\n RAISE EXCEPTION 'financial_reverse_movement: % is not a money event', p_movement_id USING ERRCODE = '55000';\n END IF;\n IF v_m.reversed_by_id IS NOT NULL THEN\n RETURN jsonb_build_object('reversal_id', v_m.reversed_by_id, 'movement_id', v_m.id, 'replayed', true);\n END IF;\n IF p_idempotency_key IS NOT NULL THEN\n SELECT m.id INTO v_rev FROM public.plg_financial_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = p_idempotency_key;\n IF v_rev IS NOT NULL THEN\n RETURN jsonb_build_object('reversal_id', v_rev, 'movement_id', v_m.id, 'replayed', true);\n END IF;\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, invoice_id, financial_invoice_id, financial_invoice_installment_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, method, cash_session_id, bank_account_id, reason, notes, metadata,\n idempotency_key, created_by, reverses_id, debit_account_id, credit_account_id)\n VALUES\n (v_m.tenant_id, v_m.unit_id, v_m.invoice_id, v_m.financial_invoice_id, v_m.financial_invoice_installment_id,\n CASE WHEN v_m.direction = 'credit' THEN 'debit' ELSE 'credit' END, v_m.movement_kind, v_m.amount, v_m.amount, 'paid',\n current_date, current_date, v_m.installment_number, v_m.method, v_m.cash_session_id, v_m.bank_account_id, btrim(p_reason),\n 'Estorno de ' || v_m.id::text,\n jsonb_build_object('source', 'financial_reverse_movement', 'reversal_of', v_m.id, 'reason', btrim(p_reason)),\n p_idempotency_key, auth.uid(), v_m.id, v_m.credit_account_id, v_m.debit_account_id)\n RETURNING id INTO v_rev;\n\n UPDATE public.plg_financial_movements SET reversed_by_id = v_rev, reason = coalesce(reason, btrim(p_reason)), updated_at = now()\n WHERE id = v_m.id;\n IF v_m.financial_invoice_installment_id IS NOT NULL THEN PERFORM public.plg_financial_sync_mirror(v_m.financial_invoice_installment_id); END IF;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.movement.reverse', 'plg_financial_movements', v_m.id::text,\n jsonb_build_object('reversal_id', v_rev, 'amount', v_m.amount, 'reason', btrim(p_reason), 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('reversal_id', v_rev, 'movement_id', v_m.id, 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_reverse_movement(uuid, text, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_reverse_movement(uuid, text, text) TO authenticated, service_role;\n\n-- \u2500\u2500 financial_set_opening_balance \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Balances are never stored: an account's opening balance is a transfer from\n-- the tenant's `opening_balances` system account (or to it, when negative).\n-- Idempotent per account: a second call with a different amount is refused\n-- (post an adjustment transfer instead).\nCREATE OR REPLACE FUNCTION public.financial_set_opening_balance(\n p_account_id uuid,\n p_amount numeric,\n p_date date DEFAULT NULL,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_acc record; v_existing record; v_amount numeric := round(coalesce(p_amount, 0), 2); v_mov uuid; v_open uuid;\nBEGIN\n SELECT a.id, a.unit_id, a.kind INTO v_acc FROM public.plg_financial_accounts a WHERE a.id = p_account_id AND a.tenant_id = v_tenant;\n IF v_acc.id IS NULL THEN\n RAISE EXCEPTION 'financial_set_opening_balance: account % not found', p_account_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.create', v_acc.unit_id);\n SELECT m.id, m.amount, m.debit_account_id INTO v_existing FROM public.plg_financial_movements m\n WHERE m.tenant_id = v_tenant AND m.metadata ->> 'opening_balance_for' = p_account_id::text;\n IF v_existing.id IS NOT NULL THEN\n IF (CASE WHEN v_existing.debit_account_id = p_account_id THEN v_existing.amount ELSE -v_existing.amount END) = v_amount THEN\n RETURN jsonb_build_object('movement_id', v_existing.id, 'amount', v_amount, 'replayed', true);\n END IF;\n RAISE EXCEPTION 'financial_set_opening_balance: account % already has an opening balance; post an adjustment transfer' , p_account_id USING ERRCODE = '55000';\n END IF;\n IF v_amount = 0 THEN\n RETURN jsonb_build_object('movement_id', NULL, 'amount', 0, 'replayed', false);\n END IF;\n v_open := public.plg_financial_system_account(v_tenant, 'opening_balances');\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, notes, metadata,\n idempotency_key, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_acc.unit_id, CASE WHEN v_amount > 0 THEN 'credit' ELSE 'debit' END, 'transfer', abs(v_amount), abs(v_amount), 'paid',\n coalesce(p_date, current_date), coalesce(p_date, current_date), 'Saldo de abertura',\n jsonb_build_object('opening_balance_for', p_account_id, 'source', 'financial_set_opening_balance'),\n p_idempotency_key, auth.uid(),\n CASE WHEN v_amount > 0 THEN p_account_id ELSE v_open END,\n CASE WHEN v_amount > 0 THEN v_open ELSE p_account_id END)\n RETURNING id INTO v_mov;\n PERFORM public.plg_financial_audit(v_tenant, 'financial.account.opening_balance', 'plg_financial_accounts', p_account_id::text,\n jsonb_build_object('amount', v_amount, 'movement_id', v_mov));\n RETURN jsonb_build_object('movement_id', v_mov, 'amount', v_amount, 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_set_opening_balance(uuid, numeric, date, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_set_opening_balance(uuid, numeric, date, text) TO authenticated, service_role;\n\nNOTIFY pgrst, 'reload schema';\n";
27
+ export declare const MIGRATION_024_CASH_SESSIONS = "-- ============================================================================\n-- plugin-financial 020 \u2014 the caixinha: one cash session per user AND unit (#142).\n--\n-- RN-FIN-02/03/08. plg_financial_cash_register_sessions evolves in place:\n-- user_id (NOT NULL \u2014 the session belongs to who opened it; owner_id follows\n-- it so the authz template hides other people's sessions unless\n-- financial.read_all), unit_id (the unit it serves), bank_account_id = the\n-- COFRE it opened from (kind cash_physical, RN-FIN-01), account_id = the\n-- session's own drawer account (kind cash_session), the counted/expected/\n-- difference pair for cash and card, idempotency keys, optional reviewers.\n-- One OPEN session per (tenant, user, unit) \u2014 a partial unique index.\n--\n-- financial_cash_session_open(cofre_account, opening_amount, idempotency_key)\n-- creates the drawer account + the session and moves opening_amount\n-- cofre \u2192 drawer (a transfer). Requires financial.cash_session in the\n-- cofre's unit. Actor = auth.uid(); tenant/unit derived.\n-- financial_cash_session_close(counted_cash, counted_card, idempotency_key, notes)\n-- closes the caller's open session: expected cash = drawer balance,\n-- expected card = card receipts of the session; the counted cash returns to\n-- the cofre; the cash difference is booked as shortage/overage against a\n-- system account so the drawer ends at ZERO; card difference is recorded\n-- (the money is at the acquirer, not in the drawer). Optional reviewers may\n-- then approve (plg_financial_cash_session_reviewers).\n-- Idempotent and replay-safe.\n-- ============================================================================\n\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS user_id uuid;\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS account_id uuid;\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS expected_cash numeric(14,2);\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS counted_cash numeric(14,2);\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS difference_cash numeric(14,2);\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS expected_card numeric(14,2);\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS counted_card numeric(14,2);\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS difference_card numeric(14,2);\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS opening_movement_id uuid;\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS closing_movement_id uuid;\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS difference_movement_id uuid;\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS open_idempotency_key text;\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS close_idempotency_key text;\nALTER TABLE public.plg_financial_cash_register_sessions ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;\n\nUPDATE public.plg_financial_cash_register_sessions SET user_id = opened_by_user_id WHERE user_id IS NULL AND opened_by_user_id IS NOT NULL;\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_cash_register_sessions WHERE user_id IS NULL) THEN\n ALTER TABLE public.plg_financial_cash_register_sessions ALTER COLUMN user_id SET NOT NULL;\n END IF;\n IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_cash_register_sessions'::regclass AND conname = 'plg_financial_cash_register_sessions_bank_account_id_fkey') THEN\n ALTER TABLE public.plg_financial_cash_register_sessions DROP CONSTRAINT plg_financial_cash_register_sessions_bank_account_id_fkey;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_cash_register_sessions'::regclass AND conname = 'plg_financial_cash_register_sessions_cofre_fk') THEN\n ALTER TABLE public.plg_financial_cash_register_sessions ADD CONSTRAINT plg_financial_cash_register_sessions_cofre_fk\n FOREIGN KEY (bank_account_id) REFERENCES public.plg_financial_accounts(id) NOT VALID;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_cash_register_sessions'::regclass AND conname = 'plg_financial_cash_register_sessions_account_fk') THEN\n ALTER TABLE public.plg_financial_cash_register_sessions ADD CONSTRAINT plg_financial_cash_register_sessions_account_fk\n FOREIGN KEY (account_id) REFERENCES public.plg_financial_accounts(id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_cash_register_sessions'::regclass AND conname = 'plg_financial_cash_register_sessions_status_check') THEN\n ALTER TABLE public.plg_financial_cash_register_sessions ADD CONSTRAINT plg_financial_cash_register_sessions_status_check\n CHECK (status IN ('open', 'closed')) NOT VALID;\n END IF;\nEND $$;\nDO $$\nBEGIN\n ALTER TABLE public.plg_financial_cash_register_sessions VALIDATE CONSTRAINT plg_financial_cash_register_sessions_cofre_fk;\nEXCEPTION WHEN foreign_key_violation THEN\n RAISE WARNING 'plg_financial_cash_register_sessions_cofre_fk left NOT VALID';\nEND $$;\nDO $$\nBEGIN\n ALTER TABLE public.plg_financial_cash_register_sessions VALIDATE CONSTRAINT plg_financial_cash_register_sessions_status_check;\nEXCEPTION WHEN check_violation THEN\n RAISE WARNING 'plg_financial_cash_register_sessions_status_check left NOT VALID';\nEND $$;\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_financial_cash_sessions_one_open_uq\n ON public.plg_financial_cash_register_sessions (tenant_id, user_id, unit_id) WHERE status = 'open';\nCREATE UNIQUE INDEX IF NOT EXISTS plg_financial_cash_sessions_open_key_uq\n ON public.plg_financial_cash_register_sessions (tenant_id, open_idempotency_key) WHERE open_idempotency_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_financial_cash_sessions_user_idx ON public.plg_financial_cash_register_sessions (tenant_id, user_id, status);\n\n-- owner_id is the user who opened the session: the template's owner scoping\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_cash_sessions_owner()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF NEW.user_id IS NULL THEN NEW.user_id := coalesce(NEW.opened_by_user_id, auth.uid()); END IF;\n NEW.opened_by_user_id := coalesce(NEW.opened_by_user_id, NEW.user_id);\n IF EXISTS (SELECT 1 FROM app.memberships m WHERE m.tenant_id = NEW.tenant_id AND m.user_id = NEW.user_id) THEN\n NEW.owner_id := NEW.user_id;\n END IF;\n RETURN NEW;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_cash_sessions_owner() FROM public, anon, authenticated;\n\nSELECT app.scaffold_table('public.plg_financial_cash_register_sessions', 'financial.cash_session', 'financial', true, 'enforce');\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_cash_register_sessions TO authenticated;\nGRANT ALL ON public.plg_financial_cash_register_sessions TO service_role;\nDROP TRIGGER IF EXISTS plg_financial_cash_register_sessions_owner ON public.plg_financial_cash_register_sessions;\nCREATE TRIGGER plg_financial_cash_register_sessions_owner BEFORE INSERT OR UPDATE OF user_id ON public.plg_financial_cash_register_sessions\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_cash_sessions_owner();\nUPDATE public.plg_financial_cash_register_sessions s SET owner_id = s.user_id\n WHERE s.owner_id IS NULL AND s.user_id IS NOT NULL\n AND EXISTS (SELECT 1 FROM app.memberships m WHERE m.tenant_id = s.tenant_id AND m.user_id = s.user_id);\n\n-- optional reviewers (RN-FIN-08 \"conferente\")\nCREATE TABLE IF NOT EXISTS public.plg_financial_cash_session_reviewers (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n session_id uuid NOT NULL REFERENCES public.plg_financial_cash_register_sessions(id) ON DELETE CASCADE,\n reviewer_id uuid NOT NULL,\n verdict text CHECK (verdict IS NULL OR verdict IN ('approved', 'rejected')),\n notes text,\n reviewed_at timestamptz,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (session_id, reviewer_id),\n CONSTRAINT plg_financial_cash_session_reviewers_tenant_id_id_key UNIQUE (tenant_id, id)\n);\nSELECT app.scaffold_table('public.plg_financial_cash_session_reviewers', 'financial.cash_session_review', 'financial', false, 'enforce');\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_cash_session_reviewers TO authenticated;\nGRANT ALL ON public.plg_financial_cash_session_reviewers TO service_role;\n\n-- \u2500\u2500 open \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.financial_cash_session_open(\n p_cofre_account_id uuid,\n p_opening_amount numeric,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_actor uuid := auth.uid();\n v_cofre record;\n v_amount numeric := round(coalesce(p_opening_amount, 0), 2);\n v_sess uuid; v_acc uuid; v_mov uuid; v_name text;\nBEGIN\n IF v_actor IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_open: a signed-in user is required' USING ERRCODE = '42501';\n END IF;\n IF v_amount < 0 THEN\n RAISE EXCEPTION 'financial_cash_session_open: opening amount must be >= 0' USING ERRCODE = '22023';\n END IF;\n SELECT a.id, a.unit_id, a.kind, a.name INTO v_cofre FROM public.plg_financial_accounts a\n WHERE a.id = p_cofre_account_id AND a.tenant_id = v_tenant AND a.is_active;\n IF v_cofre.id IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_open: cofre account % not found', p_cofre_account_id USING ERRCODE = 'P0002';\n END IF;\n IF v_cofre.kind <> 'cash_physical' THEN\n RAISE EXCEPTION 'financial_cash_session_open: account % is not a cofre (cash_physical)', p_cofre_account_id USING ERRCODE = '23514';\n END IF;\n IF v_cofre.unit_id IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_open: the cofre has no unit' USING ERRCODE = '55000';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.cash_session', v_cofre.unit_id);\n\n IF p_idempotency_key IS NOT NULL THEN\n SELECT s.id, s.account_id, s.opening_movement_id INTO v_sess, v_acc, v_mov FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.open_idempotency_key = p_idempotency_key;\n IF v_sess IS NOT NULL THEN\n RETURN jsonb_build_object('session_id', v_sess, 'account_id', v_acc, 'opening_movement_id', v_mov, 'replayed', true);\n END IF;\n END IF;\n SELECT s.id INTO v_sess FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.user_id = v_actor AND s.unit_id = v_cofre.unit_id AND s.status = 'open';\n IF v_sess IS NOT NULL THEN\n RAISE EXCEPTION 'financial_cash_session_open: you already have an open cash session in this unit' USING ERRCODE = '55000';\n END IF;\n\n SELECT coalesce(nullif(u.raw_user_meta_data ->> 'full_name', ''), u.email, v_actor::text) INTO v_name FROM auth.users u WHERE u.id = v_actor;\n INSERT INTO public.plg_financial_accounts (tenant_id, unit_id, kind, name, metadata, created_by)\n VALUES (v_tenant, v_cofre.unit_id, 'cash_session', 'Caixinha \u00B7 ' || coalesce(v_name, '') || ' \u00B7 ' || to_char(now(), 'YYYY-MM-DD HH24:MI'),\n jsonb_build_object('cash_session', true), v_actor)\n RETURNING id INTO v_acc;\n\n INSERT INTO public.plg_financial_cash_register_sessions\n (tenant_id, unit_id, user_id, bank_account_id, account_id, status, opened_at, opened_by_user_id, opened_by_name,\n opening_balance, open_idempotency_key, metadata)\n VALUES\n (v_tenant, v_cofre.unit_id, v_actor, v_cofre.id, v_acc, 'open', now(), v_actor, v_name,\n v_amount, p_idempotency_key, jsonb_build_object('cofre_name', v_cofre.name))\n RETURNING id INTO v_sess;\n UPDATE public.plg_financial_accounts SET metadata = metadata || jsonb_build_object('cash_session_id', v_sess) WHERE id = v_acc;\n\n IF v_amount > 0 THEN\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method, cash_session_id,\n bank_account_id, notes, metadata, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_cofre.unit_id, 'credit', 'transfer', v_amount, v_amount, 'paid', current_date, current_date, 'cash', v_sess,\n v_acc, 'Abertura de caixa', jsonb_build_object('source', 'financial_cash_session_open', 'cash_session_id', v_sess, 'role', 'opening'),\n v_actor, v_acc, v_cofre.id)\n RETURNING id INTO v_mov;\n UPDATE public.plg_financial_cash_register_sessions SET opening_movement_id = v_mov WHERE id = v_sess;\n END IF;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.cash_session.open', 'plg_financial_cash_register_sessions', v_sess::text,\n jsonb_build_object('cofre_account_id', v_cofre.id, 'unit_id', v_cofre.unit_id, 'opening_amount', v_amount, 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('session_id', v_sess, 'account_id', v_acc, 'opening_movement_id', v_mov, 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_cash_session_open(uuid, numeric, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_cash_session_open(uuid, numeric, text) TO authenticated, service_role;\n\n-- \u2500\u2500 close \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.financial_cash_session_close(\n p_counted_cash numeric,\n p_counted_card numeric,\n p_idempotency_key text DEFAULT NULL,\n p_notes text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_actor uuid := auth.uid();\n v_s public.plg_financial_cash_register_sessions%ROWTYPE;\n v_exp_cash numeric; v_exp_card numeric; v_diff_cash numeric; v_diff_card numeric;\n v_counted_cash numeric := round(coalesce(p_counted_cash, 0), 2);\n v_counted_card numeric := round(coalesce(p_counted_card, 0), 2);\n v_close_mov uuid; v_diff_mov uuid; v_name text;\nBEGIN\n IF v_actor IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_close: a signed-in user is required' USING ERRCODE = '42501';\n END IF;\n -- replay?\n IF p_idempotency_key IS NOT NULL THEN\n SELECT * INTO v_s FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.close_idempotency_key = p_idempotency_key;\n IF FOUND THEN\n RETURN jsonb_build_object('session_id', v_s.id, 'expected_cash', v_s.expected_cash, 'counted_cash', v_s.counted_cash,\n 'difference_cash', v_s.difference_cash, 'expected_card', v_s.expected_card, 'counted_card', v_s.counted_card,\n 'difference_card', v_s.difference_card, 'replayed', true);\n END IF;\n END IF;\n\n SELECT * INTO v_s FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.user_id = v_actor AND s.status = 'open'\n ORDER BY s.opened_at DESC LIMIT 1;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_cash_session_close: you have no open cash session' USING ERRCODE = '55000';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.cash_session', v_s.unit_id);\n\n SELECT balance INTO v_exp_cash FROM public.v_financial_account_balances WHERE account_id = v_s.account_id;\n v_exp_cash := round(coalesce(v_exp_cash, 0), 2);\n SELECT coalesce(sum(m.amount), 0) INTO v_exp_card FROM public.plg_financial_movements m\n WHERE m.cash_session_id = v_s.id AND m.movement_kind = 'payment' AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL\n AND m.method IN ('credit_card', 'debit_card');\n v_exp_card := round(coalesce(v_exp_card, 0), 2);\n v_diff_cash := round(v_counted_cash - v_exp_cash, 2);\n v_diff_card := round(v_counted_card - v_exp_card, 2);\n\n SELECT coalesce(nullif(u.raw_user_meta_data ->> 'full_name', ''), u.email) INTO v_name FROM auth.users u WHERE u.id = v_actor;\n\n -- counted cash returns to the cofre\n IF v_counted_cash > 0 THEN\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method, cash_session_id,\n bank_account_id, notes, metadata, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_s.unit_id, 'debit', 'transfer', v_counted_cash, v_counted_cash, 'paid', current_date, current_date, 'cash', v_s.id,\n v_s.bank_account_id, 'Fechamento de caixa', jsonb_build_object('source', 'financial_cash_session_close', 'cash_session_id', v_s.id, 'role', 'closing'),\n v_actor, v_s.bank_account_id, v_s.account_id)\n RETURNING id INTO v_close_mov;\n END IF;\n -- the difference is a shortage (counted < expected: drawer credited from the\n -- shortage account, i.e. we lost it) or an overage (drawer had more: booked out to overage)\n IF v_diff_cash <> 0 THEN\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method, cash_session_id,\n reason, notes, metadata, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_s.unit_id, CASE WHEN v_diff_cash < 0 THEN 'debit' ELSE 'credit' END, 'transfer', abs(v_diff_cash), abs(v_diff_cash), 'paid',\n current_date, current_date, 'cash', v_s.id,\n CASE WHEN v_diff_cash < 0 THEN 'Quebra de caixa (falta)' ELSE 'Quebra de caixa (sobra)' END, p_notes,\n jsonb_build_object('source', 'financial_cash_session_close', 'cash_session_id', v_s.id,\n 'role', CASE WHEN v_diff_cash < 0 THEN 'shortage' ELSE 'overage' END, 'difference', v_diff_cash),\n v_actor,\n CASE WHEN v_diff_cash < 0 THEN public.plg_financial_system_account(v_tenant, 'cash_shortage') ELSE v_s.account_id END,\n CASE WHEN v_diff_cash < 0 THEN v_s.account_id ELSE public.plg_financial_system_account(v_tenant, 'cash_overage') END)\n RETURNING id INTO v_diff_mov;\n END IF;\n\n UPDATE public.plg_financial_cash_register_sessions\n SET status = 'closed', closed_at = now(), closed_by_user_id = v_actor, closed_by_name = v_name,\n expected_cash = v_exp_cash, counted_cash = v_counted_cash, difference_cash = v_diff_cash,\n expected_card = v_exp_card, counted_card = v_counted_card, difference_card = v_diff_card,\n expected_balance = v_exp_cash, closing_balance = v_counted_cash, difference = v_diff_cash,\n closing_movement_id = v_close_mov, difference_movement_id = v_diff_mov,\n close_idempotency_key = p_idempotency_key,\n notes = coalesce(p_notes, notes)\n WHERE id = v_s.id;\n UPDATE public.plg_financial_accounts SET is_active = false WHERE id = v_s.account_id;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.cash_session.close', 'plg_financial_cash_register_sessions', v_s.id::text,\n jsonb_build_object('expected_cash', v_exp_cash, 'counted_cash', v_counted_cash, 'difference_cash', v_diff_cash,\n 'expected_card', v_exp_card, 'counted_card', v_counted_card, 'difference_card', v_diff_card,\n 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('session_id', v_s.id, 'expected_cash', v_exp_cash, 'counted_cash', v_counted_cash,\n 'difference_cash', v_diff_cash, 'expected_card', v_exp_card, 'counted_card', v_counted_card,\n 'difference_card', v_diff_card, 'closing_movement_id', v_close_mov, 'difference_movement_id', v_diff_mov, 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_cash_session_close(numeric, numeric, text, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_cash_session_close(numeric, numeric, text, text) TO authenticated, service_role;\n\nNOTIFY pgrst, 'reload schema';\n";
28
+ export declare const MIGRATION_025_CARD_FEES = "-- ============================================================================\n-- plugin-financial 021 \u2014 card receiving fees and the receivables provisioning\n-- contract (#142, RN-FIN-05/06).\n--\n-- plg_financial_card_fee_configs: per receiving account (kind card_receivable_\n-- credit / _debit) \u00D7 brand (NULL = any) \u00D7 installment range \u2192 fee_rate (%) and\n-- the settlement term in days. v_financial_card_receivables applies the most\n-- specific active config to every card payment and projects, per card\n-- installment k, the gross, fee, net and settlement date \u2014 the contract the\n-- cash-flow provisioning and the acquirer-deposit conference read (UI later).\n-- Idempotent and replay-safe.\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_card_fee_configs (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n account_id uuid NOT NULL REFERENCES public.plg_financial_accounts(id) ON DELETE CASCADE,\n card_brand_id uuid REFERENCES public.plg_financial_card_brands(id) ON DELETE SET NULL,\n brand text, -- brand name; NULL = any brand\n min_installments integer NOT NULL DEFAULT 1 CHECK (min_installments >= 1),\n max_installments integer NOT NULL DEFAULT 1 CHECK (max_installments >= 1),\n fee_rate numeric(7,4) NOT NULL CHECK (fee_rate >= 0 AND fee_rate <= 100), -- percent\n settlement_days integer, -- D+N; NULL \u2192 the account's, else 30 (credit) / 1 (debit)\n is_active boolean NOT NULL DEFAULT true,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_by uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CHECK (min_installments <= max_installments),\n CONSTRAINT plg_financial_card_fee_configs_tenant_id_id_key UNIQUE (tenant_id, id)\n);\nCREATE INDEX IF NOT EXISTS plg_financial_card_fee_configs_account_idx ON public.plg_financial_card_fee_configs (account_id) WHERE is_active;\nSELECT app.scaffold_table('public.plg_financial_card_fee_configs', 'financial.card_fee_config', 'financial', false, 'enforce');\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_card_fee_configs TO authenticated;\nGRANT ALL ON public.plg_financial_card_fee_configs TO service_role;\n\n-- a fee config only makes sense on a card receiving account of the same tenant\nCREATE OR REPLACE FUNCTION public.trg_plg_financial_card_fee_configs_check()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nDECLARE v_kind text; v_tenant uuid; v_unit uuid;\nBEGIN\n SELECT a.kind, a.tenant_id, a.unit_id INTO v_kind, v_tenant, v_unit FROM public.plg_financial_accounts a WHERE a.id = NEW.account_id;\n IF v_kind IS NULL OR v_kind NOT IN ('card_receivable_credit', 'card_receivable_debit') THEN\n RAISE EXCEPTION 'plg_financial_card_fee_configs: account % is not a card receiving account', NEW.account_id USING ERRCODE = '23514';\n END IF;\n IF NEW.tenant_id IS DISTINCT FROM v_tenant THEN\n RAISE EXCEPTION 'plg_financial_card_fee_configs: account belongs to another tenant' USING ERRCODE = '23514';\n END IF;\n NEW.unit_id := coalesce(NEW.unit_id, v_unit);\n NEW.brand := nullif(btrim(NEW.brand), '');\n IF NEW.brand IS NULL AND NEW.card_brand_id IS NOT NULL THEN\n SELECT b.name INTO NEW.brand FROM public.plg_financial_card_brands b WHERE b.id = NEW.card_brand_id;\n END IF;\n RETURN NEW;\nEND $$;\nREVOKE ALL ON FUNCTION public.trg_plg_financial_card_fee_configs_check() FROM public, anon, authenticated;\nDROP TRIGGER IF EXISTS plg_financial_card_fee_configs_check ON public.plg_financial_card_fee_configs;\nCREATE TRIGGER plg_financial_card_fee_configs_check BEFORE INSERT OR UPDATE ON public.plg_financial_card_fee_configs\n FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_card_fee_configs_check();\n\n-- \u2500\u2500 the provisioning contract \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- One row per card payment \u00D7 card installment k: gross_k, fee_k, net_k and the\n-- date the acquirer settles it (payment_date + term + 30\u00B7(k\u22121)); the fee comes\n-- from the most specific active config (brand-specific over any-brand,\n-- narrowest installment range). fee_rate is NULL when no config matches \u2014 the\n-- provisioning then shows the gross and flags the gap.\nCREATE OR REPLACE VIEW public.v_financial_card_receivables AS\nWITH pay AS (\n SELECT m.id AS movement_id, m.tenant_id, m.unit_id, m.financial_invoice_id, m.financial_invoice_installment_id, m.cash_session_id,\n m.debit_account_id AS account_id, a.kind AS account_kind, a.name AS account_name, a.settlement_days AS account_settlement_days,\n m.amount AS gross, m.payment_date, m.card_brand, greatest(coalesce(m.card_installments, 1), 1) AS card_installments, m.nsu, m.method\n FROM public.plg_financial_movements m\n JOIN public.plg_financial_accounts a ON a.id = m.debit_account_id\n WHERE m.movement_kind = 'payment' AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL\n AND a.kind IN ('card_receivable_credit', 'card_receivable_debit')\n), cfg AS (\n SELECT p.movement_id, c.id AS fee_config_id, c.fee_rate, c.settlement_days,\n row_number() OVER (PARTITION BY p.movement_id\n ORDER BY (c.brand IS NOT NULL) DESC, (c.max_installments - c.min_installments) ASC, c.created_at DESC) AS rn\n FROM pay p\n JOIN public.plg_financial_card_fee_configs c\n ON c.account_id = p.account_id AND c.is_active\n AND (c.brand IS NULL OR lower(c.brand) = lower(coalesce(p.card_brand, '')))\n AND p.card_installments BETWEEN c.min_installments AND c.max_installments\n), k AS (\n SELECT p.*, generate_series(1, p.card_installments) AS k FROM pay p\n)\nSELECT\n k.movement_id, k.tenant_id, k.unit_id, k.financial_invoice_id, k.financial_invoice_installment_id, k.cash_session_id,\n k.account_id, k.account_kind, k.account_name, k.card_brand, k.card_installments, k.nsu, k.method, k.payment_date,\n k.k AS installment_k, k.gross AS gross_total,\n -- installment k of n: equal split, the last one takes the rounding remainder\n (CASE WHEN k.k < k.card_installments THEN round(k.gross / k.card_installments, 2)\n ELSE round(k.gross - round(k.gross / k.card_installments, 2) * (k.card_installments - 1), 2) END)::numeric(14,2) AS gross,\n c.fee_config_id, c.fee_rate,\n round((CASE WHEN k.k < k.card_installments THEN round(k.gross / k.card_installments, 2)\n ELSE round(k.gross - round(k.gross / k.card_installments, 2) * (k.card_installments - 1), 2) END) * coalesce(c.fee_rate, 0) / 100, 2)::numeric(14,2) AS fee,\n round((CASE WHEN k.k < k.card_installments THEN round(k.gross / k.card_installments, 2)\n ELSE round(k.gross - round(k.gross / k.card_installments, 2) * (k.card_installments - 1), 2) END) * (1 - coalesce(c.fee_rate, 0) / 100), 2)::numeric(14,2) AS net,\n coalesce(c.settlement_days, k.account_settlement_days, CASE WHEN k.account_kind = 'card_receivable_debit' THEN 1 ELSE 30 END) AS settlement_days,\n (k.payment_date + (coalesce(c.settlement_days, k.account_settlement_days, CASE WHEN k.account_kind = 'card_receivable_debit' THEN 1 ELSE 30 END)\n + 30 * (k.k - 1)))::date AS settlement_date,\n (c.fee_config_id IS NULL) AS missing_fee_config\nFROM k\nLEFT JOIN cfg c ON c.movement_id = k.movement_id AND c.rn = 1;\nALTER VIEW public.v_financial_card_receivables SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_card_receivables TO authenticated, service_role;\n\nNOTIFY pgrst, 'reload schema';\n";
29
+ export declare const MIGRATION_026_RLS_AUTHZ_TEMPLATE = "-- ============================================================================\n-- plugin-financial 022 \u2014 every financial table on the authz template (#142).\n--\n-- The tables this plugin shipped before the authz core carried the generic\n-- `tenant_id IN user_tenant_ids()` policies (006/011) \u2014 tenant isolation, no\n-- permission: any member could read and write the ledger. They now get the ONE\n-- template (app.scaffold_table, enforce): financial.read to see, financial.\n-- create/edit/delete to write, unit scope where a row has a unit; the sessions\n-- table (020) is the only owner-scoped one. The tables created in this slice\n-- were scaffolded where they were created; this file converges the rest and\n-- restates the grants, and is the LAST financial file on purpose: a replay of\n-- 002/004/006/011 re-creates the generic policies and this pass drops them again.\n--\n-- Negative contract proven by pgTAP: a member without financial.* reads zero\n-- rows and cannot write on any of these tables nor call the RPCs (42501).\n-- Idempotent and replay-safe.\n-- ============================================================================\n\nSELECT app.scaffold_table('public.plg_financial_payment_method_types', 'financial.payment_method_type', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_payment_methods', 'financial.payment_method', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_bank_accounts', 'financial.bank_account', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_chart_of_accounts', 'financial.chart_of_account', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_cost_centers', 'financial.cost_center', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_card_brands', 'financial.card_brand', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_nfe', 'financial.nfe', 'financial', false, 'enforce');\n-- the tables of this slice, once more so a replay converges in one pass\nSELECT app.scaffold_table('public.plg_financial_invoices', 'financial.invoice', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_invoice_installments', 'financial.invoice_installment', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_accounts', 'financial.account', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_movements', 'financial.movement', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_allocations', 'financial.allocation', 'financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_cash_register_sessions','financial.cash_session', 'financial', true, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_cash_session_reviewers','financial.cash_session_review','financial', false, 'enforce');\nSELECT app.scaffold_table('public.plg_financial_card_fee_configs', 'financial.card_fee_config', 'financial', false, 'enforce');\n\nDO $$\nDECLARE t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY[\n 'plg_financial_payment_method_types', 'plg_financial_payment_methods', 'plg_financial_bank_accounts',\n 'plg_financial_chart_of_accounts', 'plg_financial_cost_centers', 'plg_financial_card_brands', 'plg_financial_nfe',\n 'plg_financial_invoices', 'plg_financial_invoice_installments', 'plg_financial_accounts', 'plg_financial_movements',\n 'plg_financial_allocations', 'plg_financial_cash_register_sessions', 'plg_financial_cash_session_reviewers',\n 'plg_financial_card_fee_configs'\n ] LOOP\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);\n EXECUTE format('GRANT ALL ON public.%I TO service_role', t);\n EXECUTE format('REVOKE ALL ON public.%I FROM anon', t);\n END LOOP;\nEND $$;\n\nNOTIFY pgrst, 'reload schema';\n";
30
+ export declare const MIGRATION_027_SEQUENCES_AUTHORIZED = "-- 027_sequences_authorized.sql \u2014 re-assert the tenant-authorized next_sequence().\n--\n-- 004_order_to_cash.sql created public.next_sequence(p_tenant_id, p_kind) as a\n-- SECURITY DEFINER counter with no check on p_tenant_id (Contexto \u00A79.2 P1). The\n-- spine now owns the table and the guarded function (packages/db/migrations\n-- 091_sequences_authorized.sql), but plugin migrations run AFTER the spine and\n-- 004 \u2014 an applied file, never edited \u2014 re-emits the unguarded body on a fresh\n-- chain. This file runs last in the plugin's order and re-asserts the guarded\n-- body, byte-for-byte the spine's, so whichever applied last, the counter is\n-- authorized: with a JWT, p_tenant_id must be one the caller is an active member\n-- of (and the session's tenant when the session is narrowed to one); without a\n-- JWT (service_role, migration, definer job) the caller names the tenant.\n-- Idempotent.\nCREATE OR REPLACE FUNCTION public.next_sequence(p_tenant_id uuid, p_kind text)\nRETURNS bigint\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_next bigint;\n v_scope uuid;\nBEGIN\n IF p_tenant_id IS NULL OR nullif(btrim(coalesce(p_kind, '')), '') IS NULL THEN\n RAISE EXCEPTION 'next_sequence: tenant and kind are required' USING ERRCODE = '22023';\n END IF;\n IF auth.uid() IS NOT NULL THEN\n v_scope := app.current_tenant_id();\n IF (v_scope IS NOT NULL AND p_tenant_id <> v_scope) OR NOT app.is_member(p_tenant_id) THEN\n RAISE EXCEPTION 'next_sequence: not authorized for tenant %', p_tenant_id USING ERRCODE = '42501';\n END IF;\n END IF;\n INSERT INTO public.sequences (tenant_id, kind, current_value)\n VALUES (p_tenant_id, p_kind, 1)\n ON CONFLICT (tenant_id, kind)\n DO UPDATE SET current_value = public.sequences.current_value + 1\n RETURNING current_value INTO v_next;\n RETURN v_next;\nEND $$;\nREVOKE ALL ON FUNCTION public.next_sequence(uuid, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.next_sequence(uuid, text) TO authenticated, service_role;\n";
31
+ export declare const MIGRATION_028_PLG_RENAME_COMMISSION_RULES = "-- 028_plg_rename_commission_rules.sql \u2014 the last unprefixed table the financial\n-- registries bind to. `commission_rules` was created by app-side legacy SQL\n-- (financial_extras) and read only through the plugin's commission-rules\n-- registry; the plugin-prefix rule (docs/plugins/PLUGIN-CONVENTION.md \u00A73) puts\n-- it under plg_financial_*. Guarded like 000_plg_rename: fires only when the\n-- legacy name exists and the target does not, so fresh pools and replays no-op.\n--\n-- Numbered 024 because 012\u2013023 are reserved for the order-to-cash and\n-- commissions slices of Phase 0. `price_tables` / `price_variations` are NOT\n-- renamed: they become core catalog tables in the catalog slice (#139) and the\n-- registry is re-pointed there.\nDO $$\nBEGIN\n IF to_regclass('public.commission_rules') IS NOT NULL AND to_regclass('public.plg_financial_commission_rules') IS NULL THEN\n ALTER TABLE public.commission_rules RENAME TO plg_financial_commission_rules;\n END IF;\nEND $$;\n";
32
+ export declare const MIGRATION_029_INVOICE_INSTALLMENT_METADATA = "-- 029_invoice_installment_metadata.sql\n--\n-- financial_create_invoice persists what rides on each installment.\n--\n-- 015 accepted `p_installments` as [{number, due_date, amount}] and inserted exactly\n-- those three columns. `plg_financial_invoice_installments.metadata` has existed since\n-- 013 and was never written, so anything attached to an installment at creation time --\n-- today the receipt, `receiptUrl` -- survived in the mock adapter and vanished the\n-- moment the same flow ran against the database. main fixed that on the pre-invoice\n-- path (it wrote `metadata` onto the movement row); this carries the fix onto the\n-- invoice path that replaced it, so the merge does not silently undo a shipped fix.\n--\n-- Migrations are append-only, so this is a fix-forward CREATE OR REPLACE of the whole\n-- function rather than an edit to 015. Two changes against that body, and no others:\n-- 1. the installment INSERT names tenant_id/unit_id explicitly and carries metadata;\n-- 2. a non-object `metadata` degrades to '{}' instead of raising -- an older client\n-- that sends nothing, or sends a string, must not fail an invoice.\n-- Replay-safe: CREATE OR REPLACE, no DDL, no data touched.\n\nCREATE OR REPLACE FUNCTION public.financial_create_invoice(\n p_direction text,\n p_total numeric,\n p_counterparty_person_id uuid DEFAULT NULL,\n p_order_id uuid DEFAULT NULL,\n p_unit_id uuid DEFAULT NULL,\n p_installments jsonb DEFAULT NULL,\n p_competence_date date DEFAULT NULL,\n p_description text DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_metadata jsonb DEFAULT '{}'::jsonb,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_unit uuid := p_unit_id;\n v_order public.orders%ROWTYPE;\n v_invoice_id uuid;\n v_number text;\n v_total numeric := round(coalesce(p_total, 0), 2);\n v_insts jsonb;\n v_sum numeric := 0;\n v_i jsonb;\n v_n int := 0;\n v_out jsonb := '[]'::jsonb;\n v_inst_id uuid;\nBEGIN\n IF p_direction NOT IN ('receivable', 'payable') THEN\n RAISE EXCEPTION 'financial_create_invoice: direction must be receivable|payable' USING ERRCODE = '22023';\n END IF;\n IF v_total < 0 THEN\n RAISE EXCEPTION 'financial_create_invoice: total must be >= 0' USING ERRCODE = '22023';\n END IF;\n\n IF p_order_id IS NOT NULL THEN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_create_invoice: order % not found in this tenant', p_order_id USING ERRCODE = 'P0002';\n END IF;\n IF v_order.kind IN ('invoice_receivable', 'invoice_payable') THEN\n RAISE EXCEPTION 'financial_create_invoice: order % is a retired invoice kind', p_order_id USING ERRCODE = '23514';\n END IF;\n v_unit := coalesce(v_unit, v_order.unit_id);\n END IF;\n\n PERFORM public.plg_financial_require_permission('financial.create', v_unit);\n\n -- Idempotency: same key in the tenant \u2192 the same invoice, no second effect.\n IF p_idempotency_key IS NOT NULL THEN\n SELECT id, number INTO v_invoice_id, v_number FROM public.plg_financial_invoices\n WHERE tenant_id = v_tenant AND idempotency_key = p_idempotency_key;\n IF v_invoice_id IS NOT NULL THEN\n SELECT coalesce(jsonb_agg(jsonb_build_object('id', i.id, 'number', i.number, 'due_date', i.due_date, 'amount', i.amount) ORDER BY i.number), '[]'::jsonb)\n INTO v_out FROM public.plg_financial_invoice_installments i WHERE i.invoice_id = v_invoice_id;\n RETURN jsonb_build_object('invoice_id', v_invoice_id, 'number', v_number, 'installments', v_out, 'replayed', true);\n END IF;\n END IF;\n\n -- installments: given, or one for the total\n v_insts := coalesce(p_installments, jsonb_build_array(jsonb_build_object(\n 'number', 1, 'due_date', coalesce(p_competence_date, current_date), 'amount', v_total)));\n IF jsonb_typeof(v_insts) <> 'array' OR jsonb_array_length(v_insts) = 0 THEN\n RAISE EXCEPTION 'financial_create_invoice: installments must be a non-empty array' USING ERRCODE = '22023';\n END IF;\n FOR v_i IN SELECT * FROM jsonb_array_elements(v_insts) LOOP\n v_sum := v_sum + round(coalesce((v_i ->> 'amount')::numeric, 0), 2);\n END LOOP;\n IF v_sum <> v_total THEN\n RAISE EXCEPTION 'financial_create_invoice: installments sum to % but the invoice total is %', v_sum, v_total USING ERRCODE = '23514';\n END IF;\n\n v_number := public.plg_financial_next_invoice_number(v_tenant, p_direction);\n\n INSERT INTO public.plg_financial_invoices\n (tenant_id, unit_id, direction, counterparty_person_id, order_id, number, competence_date, issue_date,\n total, description, notes, metadata, idempotency_key, created_by)\n VALUES\n (v_tenant, v_unit, p_direction, coalesce(p_counterparty_person_id, v_order.party_id), p_order_id, v_number,\n coalesce(p_competence_date, current_date), current_date, v_total, p_description, p_notes,\n coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'financial_create_invoice'),\n p_idempotency_key, auth.uid())\n RETURNING id INTO v_invoice_id;\n\n FOR v_i IN SELECT * FROM jsonb_array_elements(v_insts) LOOP\n v_n := v_n + 1;\n INSERT INTO public.plg_financial_invoice_installments\n (tenant_id, unit_id, invoice_id, number, due_date, amount, metadata)\n VALUES (v_tenant, v_unit, v_invoice_id, coalesce((v_i ->> 'number')::int, v_n),\n coalesce((v_i ->> 'due_date')::date, coalesce(p_competence_date, current_date)),\n round(coalesce((v_i ->> 'amount')::numeric, 0), 2),\n -- what came attached to THIS installment (today: receiptUrl). 015 dropped\n -- it on the floor, which is the whole point of this migration.\n CASE WHEN jsonb_typeof(v_i -> 'metadata') = 'object' THEN v_i -> 'metadata' ELSE '{}'::jsonb END)\n RETURNING id INTO v_inst_id;\n v_out := v_out || jsonb_build_object('id', v_inst_id, 'number', coalesce((v_i ->> 'number')::int, v_n),\n 'due_date', coalesce((v_i ->> 'due_date')::date, coalesce(p_competence_date, current_date)),\n 'amount', round(coalesce((v_i ->> 'amount')::numeric, 0), 2));\n END LOOP;\n\n -- The Venda records that it was invoiced (same keys the legacy readers use).\n IF p_order_id IS NOT NULL THEN\n UPDATE public.orders SET\n stage = 'invoiced',\n direction = CASE WHEN p_direction = 'payable' THEN 'debit' ELSE 'credit' END,\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('invoiceNumber', v_number, 'installmentCount', v_n),\n updated_at = now()\n WHERE id = p_order_id;\n END IF;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.invoice.create', 'plg_financial_invoices', v_invoice_id::text,\n jsonb_build_object('number', v_number, 'direction', p_direction, 'total', v_total, 'order_id', p_order_id,\n 'installments', v_n, 'idempotency_key', p_idempotency_key));\n\n RETURN jsonb_build_object('invoice_id', v_invoice_id, 'number', v_number, 'installments', v_out, 'replayed', false);\nEND $$;\nREVOKE ALL ON FUNCTION public.financial_create_invoice(text, numeric, uuid, uuid, uuid, jsonb, date, text, text, jsonb, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.financial_create_invoice(text, numeric, uuid, uuid, uuid, jsonb, date, text, text, jsonb, text) TO authenticated, service_role;\n";
33
+ export declare const MIGRATION_030_AUDIT_REASON_COLUMN = "-- 030_audit_reason_column.sql\n--\n-- financial_cancel_invoice could not complete. Fix for #200.\n--\n-- #144 added a gate on public.audit_logs: a destructive action needs a `reason`\n-- (086:81, ERRCODE 23514, HINT in pt-BR). `plg_financial_audit` (015) writes only\n-- (tenant_id, user_id, action, entity_type, entity_id, metadata) \u2014 so every RPC\n-- that passed its reason inside the metadata blob satisfied nobody: the column\n-- stayed NULL and the gate refused the row, taking the whole RPC down with it.\n--\n-- ERROR: audit_logs: a destructive action (financial.invoice.cancel) requires a reason\n--\n-- Two slices, each correct on its own, meeting at a column neither one owned.\n-- The audit helper is where it belongs: fixing it here fixes every caller at\n-- once, present and future, instead of each RPC learning to fill a column.\n--\n-- The reason is also LEFT in the metadata. It is the same string, and callers\n-- (and the two suites) already read `metadata ->> 'reason'`; dropping it to\n-- avoid duplication would break readers for no gain, and the column is what the\n-- gate and the retention policy actually look at.\n--\n-- Replay-safe: CREATE OR REPLACE with a defaulted parameter, so the existing\n-- 5-argument call sites keep resolving to the same function. No data touched.\n\nCREATE OR REPLACE FUNCTION public.plg_financial_audit(\n p_tenant uuid, p_action text, p_entity_type text, p_entity_id text, p_metadata jsonb DEFAULT '{}'::jsonb\n) RETURNS void\nLANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$\n INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata, reason)\n VALUES (p_tenant, auth.uid(), p_action, p_entity_type, p_entity_id, coalesce(p_metadata, '{}'::jsonb),\n -- the destructive-action gate (086) reads the COLUMN. Callers have\n -- always put the reason in the blob; lift it out rather than making\n -- every RPC learn a new argument.\n nullif(btrim(coalesce(p_metadata ->> 'reason', '')), ''));\n$$;\nREVOKE ALL ON FUNCTION public.plg_financial_audit(uuid, text, text, text, jsonb) FROM public, anon, authenticated;\n";
34
+ export declare const MIGRATION_031_IDEMPOTENCY_IS_SERIALIZED = "-- 031_idempotency_is_serialized.sql\n--\n-- Idempotency keys existed on every money RPC; CAS existed on none. Fix for #142.\n--\n-- Each of these functions did the same thing: SELECT for an existing row with\n-- this key, and if there is none, INSERT. Between those two statements there is\n-- nothing. Two concurrent calls with the SAME key -- a retried request, a\n-- double-clicked button, a webhook delivered twice -- both read \"no row\" and both\n-- insert, and the unique index turns the loser into\n--\n-- ERROR: duplicate key value violates unique constraint\n-- plg_financial_movements_idempotency_uq\n--\n-- which is the one thing an idempotency key exists to prevent. The caller asked\n-- \"do this once\"; it got an error instead of the first call's answer, and no way\n-- to tell a duplicate from a real failure.\n--\n-- The fix is one line per RPC, and it is a lock rather than a retry loop on\n-- purpose. `pg_advisory_xact_lock` on (tenant, key) makes the second caller WAIT\n-- at the top of the check instead of racing through it; by the time it reads, the\n-- first transaction has committed and it finds the row and replays. Nothing else\n-- in the function changes -- same reads, same writes, same return shape.\n--\n-- Why not `INSERT \u2026 ON CONFLICT DO NOTHING` and re-read: these RPCs write several\n-- rows per call (movement + allocation, session + opening movement), so the\n-- conflict can surface on any of them, and \"which write lost\" is not something\n-- the caller should have to reason about. The lock scopes to the whole operation.\n--\n-- Why not a row lock: there is no row to lock yet -- that is the entire problem.\n-- An advisory lock is the standard answer for serialising on a key that does not\n-- exist until you create it.\n--\n-- Locks are transaction-scoped, so they release on COMMIT or ROLLBACK with no\n-- cleanup path to forget. Two DIFFERENT keys hash to different values and do not\n-- contend; a hash collision costs one caller a short wait and nothing else.\n--\n-- financial_credit_customer is not re-emitted: it delegates to financial_transfer\n-- and inherits the lock.\n--\n-- Two of these were found by the assertion rather than by reading: the check\n-- asserts that any RPC ACCEPTING a key claims it, and financial_set_opening_balance\n-- (idempotent by account) and financial_cancel_invoice (idempotent by cancelled_at)\n-- both took a key and never looked at it. Neither was wrong to key off state --\n-- an account has one opening balance, an invoice is cancelled once -- but both\n-- read that state and then wrote, with nothing in between.\n--\n-- Replay-safe: CREATE OR REPLACE only, no DDL, no data touched.\n\nCREATE OR REPLACE FUNCTION public.plg_financial_claim_idempotency(p_tenant uuid, p_key text)\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF p_tenant IS NULL OR p_key IS NULL THEN RETURN; END IF;\n -- Serialise this (tenant, key) for the rest of the transaction. The caller that\n -- gets here second waits, then finds the first one's row and replays it.\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant::text || ':' || p_key, 0));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_financial_claim_idempotency(uuid, text) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION public.financial_receive_payment(\n p_installment_id uuid,\n p_amount numeric,\n p_account_id uuid DEFAULT NULL,\n p_method text DEFAULT NULL,\n p_cash_session_id uuid DEFAULT NULL,\n p_card_brand text DEFAULT NULL,\n p_card_installments integer DEFAULT NULL,\n p_nsu text DEFAULT NULL,\n p_payment_date date DEFAULT NULL,\n p_fee_amount numeric DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_idempotency_key text DEFAULT NULL,\n p_metadata jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_actor uuid := auth.uid();\n v_inst_id uuid := p_installment_id;\n v_inst record;\n v_invoice public.plg_financial_invoices%ROWTYPE;\n v_amount numeric := round(coalesce(p_amount, 0), 2);\n v_open numeric;\n v_method text := lower(coalesce(nullif(btrim(p_method), ''), 'other'));\n v_holder uuid;\n v_holder_k text;\n v_session uuid := p_cash_session_id;\n v_counter uuid;\n v_credit numeric;\n v_mov_id uuid;\n v_alloc_id uuid;\n v_dir text;\nBEGIN\n IF v_amount <= 0 THEN\n RAISE EXCEPTION 'financial_receive_payment: amount must be > 0' USING ERRCODE = '22023';\n END IF;\n IF v_method NOT IN ('cash', 'pix', 'credit_card', 'debit_card', 'transfer', 'boleto', 'check', 'credit', 'other') THEN\n RAISE EXCEPTION 'financial_receive_payment: unknown method %', v_method USING ERRCODE = '22023';\n END IF;\n\n -- accept a legacy per-order 'bill' movement id in place of the installment\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_invoice_installments i WHERE i.id = v_inst_id) THEN\n SELECT m.financial_invoice_installment_id INTO v_inst_id FROM public.plg_financial_movements m\n WHERE m.id = p_installment_id AND m.movement_kind = 'bill' AND m.tenant_id = v_tenant;\n END IF;\n SELECT i.* INTO v_inst FROM public.plg_financial_invoice_installments i WHERE i.id = v_inst_id AND i.tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_receive_payment: installment % not found', p_installment_id USING ERRCODE = 'P0002';\n END IF;\n SELECT * INTO v_invoice FROM public.plg_financial_invoices WHERE id = v_inst.invoice_id;\n PERFORM public.plg_financial_require_permission('financial.create', v_inst.unit_id);\n\n -- idempotency\n IF p_idempotency_key IS NOT NULL THEN\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n SELECT m.id INTO v_mov_id FROM public.plg_financial_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = p_idempotency_key;\n IF v_mov_id IS NOT NULL THEN\n SELECT a.id INTO v_alloc_id FROM public.plg_financial_allocations a WHERE a.payment_movement_id = v_mov_id LIMIT 1;\n RETURN jsonb_build_object('movement_id', v_mov_id, 'allocation_id', v_alloc_id, 'installment_id', v_inst.id,\n 'invoice_id', v_inst.invoice_id, 'amount', v_amount, 'replayed', true);\n END IF;\n END IF;\n\n IF v_inst.cancelled_at IS NOT NULL OR v_invoice.cancelled_at IS NOT NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: installment is cancelled' USING ERRCODE = '55000';\n END IF;\n v_open := round(v_inst.amount - public.plg_financial_installment_paid(v_inst.id), 2);\n IF v_amount > v_open + 0.000001 THEN\n RAISE EXCEPTION 'financial_receive_payment: % exceeds the open balance % of installment %/% of %', v_amount, v_open, v_inst.number, v_invoice.number, v_invoice.number\n USING ERRCODE = '23514';\n END IF;\n\n -- the money holder\n IF v_method = 'credit' THEN\n IF v_invoice.counterparty_person_id IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: the invoice has no counterparty to consume credit from' USING ERRCODE = '55000';\n END IF;\n v_holder := public.plg_financial_person_account(v_tenant, v_invoice.counterparty_person_id);\n SELECT balance INTO v_credit FROM public.v_financial_account_balances WHERE account_id = v_holder;\n IF coalesce(v_credit, 0) + 0.000001 < v_amount THEN\n RAISE EXCEPTION 'financial_receive_payment: customer credit % is below %', coalesce(v_credit, 0), v_amount USING ERRCODE = '23514';\n END IF;\n ELSIF v_method = 'cash' THEN\n IF v_session IS NULL THEN\n v_session := public.plg_financial_open_session_of(v_tenant, v_actor, v_inst.unit_id);\n END IF;\n IF v_session IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: no open cash session (caixinha) for cash in this unit' USING ERRCODE = '55000',\n HINT = 'Open your cash session first (financial_cash_session_open).';\n END IF;\n SELECT s.account_id INTO v_holder FROM public.plg_financial_cash_register_sessions s\n WHERE s.id = v_session AND s.tenant_id = v_tenant AND s.status = 'open';\n IF v_holder IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: cash session % is not open', v_session USING ERRCODE = '55000';\n END IF;\n ELSE\n v_holder := p_account_id;\n IF v_holder IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: an account is required for method %', v_method USING ERRCODE = '22023';\n END IF;\n END IF;\n SELECT a.kind INTO v_holder_k FROM public.plg_financial_accounts a WHERE a.id = v_holder AND a.tenant_id = v_tenant AND a.is_active;\n IF v_holder_k IS NULL THEN\n RAISE EXCEPTION 'financial_receive_payment: account % not found or inactive', v_holder USING ERRCODE = 'P0002';\n END IF;\n IF (v_method = 'credit_card' AND v_holder_k <> 'card_receivable_credit')\n OR (v_method = 'debit_card' AND v_holder_k <> 'card_receivable_debit') THEN\n RAISE EXCEPTION 'financial_receive_payment: method % must settle in a % account', v_method,\n CASE WHEN v_method = 'credit_card' THEN 'card_receivable_credit' ELSE 'card_receivable_debit' END USING ERRCODE = '23514';\n END IF;\n IF v_method NOT IN ('credit_card', 'debit_card') AND v_holder_k IN ('card_receivable_credit', 'card_receivable_debit') THEN\n RAISE EXCEPTION 'financial_receive_payment: a card receivable account only takes card methods' USING ERRCODE = '23514';\n END IF;\n\n IF v_invoice.direction = 'payable' THEN\n v_dir := 'debit';\n v_counter := public.plg_financial_system_account(v_tenant, 'payables');\n ELSE\n v_dir := 'credit';\n v_counter := public.plg_financial_system_account(v_tenant, 'receivables');\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, invoice_id, financial_invoice_id, financial_invoice_installment_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, method, card_brand, card_installments, nsu, cash_session_id,\n bank_account_id, fee_amount, notes, metadata, idempotency_key, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_inst.unit_id, v_invoice.order_id, v_invoice.id, v_inst.id, v_dir, 'payment', v_amount, v_amount, 'paid',\n v_inst.due_date, coalesce(p_payment_date, current_date), v_inst.number, v_method, p_card_brand, p_card_installments, p_nsu, v_session,\n CASE WHEN v_holder_k IN ('person', 'system') THEN NULL ELSE v_holder END,\n round(coalesce(p_fee_amount, 0), 2), p_notes,\n coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'financial_receive_payment'),\n p_idempotency_key, v_actor,\n CASE WHEN v_dir = 'credit' THEN v_holder ELSE v_counter END,\n CASE WHEN v_dir = 'credit' THEN v_counter ELSE v_holder END)\n RETURNING id INTO v_mov_id;\n\n INSERT INTO public.plg_financial_allocations (tenant_id, unit_id, payment_movement_id, installment_id, amount, created_by)\n VALUES (v_tenant, v_inst.unit_id, v_mov_id, v_inst.id, v_amount, v_actor)\n RETURNING id INTO v_alloc_id;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.payment.receive', 'plg_financial_movements', v_mov_id::text,\n jsonb_build_object('installment_id', v_inst.id, 'invoice_id', v_invoice.id, 'invoice_number', v_invoice.number, 'amount', v_amount,\n 'method', v_method, 'account_id', v_holder, 'cash_session_id', v_session, 'idempotency_key', p_idempotency_key));\n\n RETURN jsonb_build_object('movement_id', v_mov_id, 'allocation_id', v_alloc_id, 'installment_id', v_inst.id,\n 'invoice_id', v_inst.invoice_id, 'amount', v_amount, 'replayed', false);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_transfer(\n p_from_account_id uuid,\n p_to_account_id uuid,\n p_amount numeric,\n p_date date DEFAULT NULL,\n p_method text DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_idempotency_key text DEFAULT NULL,\n p_metadata jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_from record; v_to record;\n v_amount numeric := round(coalesce(p_amount, 0), 2);\n v_mov_id uuid;\nBEGIN\n IF v_amount <= 0 THEN\n RAISE EXCEPTION 'financial_transfer: amount must be > 0' USING ERRCODE = '22023';\n END IF;\n IF p_from_account_id IS NULL OR p_to_account_id IS NULL OR p_from_account_id = p_to_account_id THEN\n RAISE EXCEPTION 'financial_transfer: source and destination must be two different accounts' USING ERRCODE = '22023';\n END IF;\n SELECT a.id, a.unit_id, a.kind INTO v_from FROM public.plg_financial_accounts a WHERE a.id = p_from_account_id AND a.tenant_id = v_tenant AND a.is_active;\n SELECT a.id, a.unit_id, a.kind INTO v_to FROM public.plg_financial_accounts a WHERE a.id = p_to_account_id AND a.tenant_id = v_tenant AND a.is_active;\n IF v_from.id IS NULL OR v_to.id IS NULL THEN\n RAISE EXCEPTION 'financial_transfer: account not found or inactive' USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.create', coalesce(v_to.unit_id, v_from.unit_id));\n IF v_from.unit_id IS NOT NULL AND v_to.unit_id IS NOT NULL AND v_from.unit_id <> v_to.unit_id THEN\n PERFORM public.plg_financial_require_permission('financial.create', v_from.unit_id);\n END IF;\n\n IF p_idempotency_key IS NOT NULL THEN\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n SELECT m.id INTO v_mov_id FROM public.plg_financial_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = p_idempotency_key;\n IF v_mov_id IS NOT NULL THEN\n RETURN jsonb_build_object('movement_id', v_mov_id, 'amount', v_amount, 'replayed', true);\n END IF;\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method,\n bank_account_id, notes, metadata, idempotency_key, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, coalesce(v_to.unit_id, v_from.unit_id),\n CASE WHEN v_to.kind IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit', 'card_receivable_debit') THEN 'credit' ELSE 'debit' END,\n 'transfer', v_amount, v_amount, 'paid', coalesce(p_date, current_date), coalesce(p_date, current_date), lower(nullif(btrim(p_method), '')),\n CASE WHEN v_to.kind IN ('person', 'system') THEN CASE WHEN v_from.kind IN ('person', 'system') THEN NULL ELSE v_from.id END ELSE v_to.id END,\n p_notes, coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'financial_transfer', 'from_account_id', v_from.id, 'to_account_id', v_to.id),\n p_idempotency_key, auth.uid(), v_to.id, v_from.id)\n RETURNING id INTO v_mov_id;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.transfer', 'plg_financial_movements', v_mov_id::text,\n jsonb_build_object('from_account_id', v_from.id, 'to_account_id', v_to.id, 'amount', v_amount, 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('movement_id', v_mov_id, 'amount', v_amount, 'replayed', false);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_cancel_paid_item(\n p_order_item_id uuid,\n p_reason text,\n p_idempotency_key text DEFAULT NULL,\n p_amount numeric DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_item public.order_items%ROWTYPE;\n v_order public.orders%ROWTYPE;\n v_amount numeric;\n v_paid numeric; v_credited numeric;\n v_person_acc uuid; v_cancel_acc uuid; v_mov_id uuid; v_credit numeric;\nBEGIN\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: a reason is required' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_item FROM public.order_items WHERE id = p_order_item_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: order item % not found', p_order_item_id USING ERRCODE = 'P0002';\n END IF;\n SELECT * INTO v_order FROM public.orders WHERE id = v_item.order_id;\n PERFORM public.plg_financial_require_permission('financial.edit', v_order.unit_id);\n IF v_order.party_id IS NULL THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: the sale has no customer to credit' USING ERRCODE = '55000';\n END IF;\n v_amount := round(coalesce(p_amount, v_item.total, 0), 2);\n IF v_amount <= 0 THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: nothing to credit' USING ERRCODE = '22023';\n END IF;\n\n IF p_idempotency_key IS NOT NULL THEN\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n SELECT m.id INTO v_mov_id FROM public.plg_financial_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = p_idempotency_key;\n IF v_mov_id IS NOT NULL THEN\n v_person_acc := public.plg_financial_person_account(v_tenant, v_order.party_id);\n SELECT credit INTO v_credit FROM public.v_financial_customer_credit WHERE account_id = v_person_acc;\n RETURN jsonb_build_object('movement_id', v_mov_id, 'person_account_id', v_person_acc, 'credit_amount', v_amount, 'credit', coalesce(v_credit, 0), 'replayed', true);\n END IF;\n END IF;\n IF v_item.execution_status = 'cancelled' THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: item is already cancelled' USING ERRCODE = '55000';\n END IF;\n\n -- \"paid\" = the sale's invoices received at least this much beyond what was already credited back\n SELECT coalesce(sum(t.paid), 0) INTO v_paid FROM public.v_financial_invoice_balances t WHERE t.order_id = v_order.id;\n SELECT coalesce(sum(m.amount), 0) INTO v_credited FROM public.plg_financial_movements m\n WHERE m.tenant_id = v_tenant AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL\n AND m.metadata ->> 'cancelled_order_id' = v_order.id::text;\n IF v_paid - v_credited + 0.000001 < v_amount THEN\n RAISE EXCEPTION 'financial_cancel_paid_item: the sale has % received (% already credited back); the item is not paid for %', v_paid, v_credited, v_amount\n USING ERRCODE = '55000';\n END IF;\n\n v_person_acc := public.plg_financial_person_account(v_tenant, v_order.party_id);\n v_cancel_acc := public.plg_financial_system_account(v_tenant, 'cancellations');\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method,\n reason, notes, metadata, idempotency_key, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_order.unit_id, v_order.id, 'debit', 'transfer', v_amount, v_amount, 'paid', current_date, current_date, 'credit',\n btrim(p_reason), NULL,\n jsonb_build_object('source', 'financial_cancel_paid_item', 'credit_source', 'cancellation',\n 'cancelled_order_item_id', v_item.id, 'cancelled_order_id', v_order.id, 'reason', btrim(p_reason)),\n p_idempotency_key, auth.uid(), v_cancel_acc, v_person_acc)\n RETURNING id INTO v_mov_id;\n\n UPDATE public.order_items\n SET execution_status = 'cancelled',\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('cancelReason', btrim(p_reason), 'cancelledAt', now(), 'creditMovementId', v_mov_id)\n WHERE id = v_item.id;\n\n SELECT credit INTO v_credit FROM public.v_financial_customer_credit WHERE account_id = v_person_acc;\n PERFORM public.plg_financial_audit(v_tenant, 'financial.item.cancel_to_credit', 'order_items', v_item.id::text,\n jsonb_build_object('order_id', v_order.id, 'amount', v_amount, 'reason', btrim(p_reason), 'movement_id', v_mov_id, 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('movement_id', v_mov_id, 'person_account_id', v_person_acc, 'credit_amount', v_amount, 'credit', coalesce(v_credit, 0), 'replayed', false);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_reverse_movement(\n p_movement_id uuid,\n p_reason text,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_m public.plg_financial_movements%ROWTYPE;\n v_rev uuid;\nBEGIN\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'financial_reverse_movement: a reason is required' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_m FROM public.plg_financial_movements WHERE id = p_movement_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_reverse_movement: movement % not found', p_movement_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.edit', v_m.unit_id);\n IF v_m.movement_kind NOT IN ('payment', 'transfer') OR v_m.debit_account_id IS NULL THEN\n RAISE EXCEPTION 'financial_reverse_movement: % is not a money event', p_movement_id USING ERRCODE = '55000';\n END IF;\n IF v_m.reversed_by_id IS NOT NULL THEN\n RETURN jsonb_build_object('reversal_id', v_m.reversed_by_id, 'movement_id', v_m.id, 'replayed', true);\n END IF;\n IF p_idempotency_key IS NOT NULL THEN\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n SELECT m.id INTO v_rev FROM public.plg_financial_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = p_idempotency_key;\n IF v_rev IS NOT NULL THEN\n RETURN jsonb_build_object('reversal_id', v_rev, 'movement_id', v_m.id, 'replayed', true);\n END IF;\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, invoice_id, financial_invoice_id, financial_invoice_installment_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, method, cash_session_id, bank_account_id, reason, notes, metadata,\n idempotency_key, created_by, reverses_id, debit_account_id, credit_account_id)\n VALUES\n (v_m.tenant_id, v_m.unit_id, v_m.invoice_id, v_m.financial_invoice_id, v_m.financial_invoice_installment_id,\n CASE WHEN v_m.direction = 'credit' THEN 'debit' ELSE 'credit' END, v_m.movement_kind, v_m.amount, v_m.amount, 'paid',\n current_date, current_date, v_m.installment_number, v_m.method, v_m.cash_session_id, v_m.bank_account_id, btrim(p_reason),\n 'Estorno de ' || v_m.id::text,\n jsonb_build_object('source', 'financial_reverse_movement', 'reversal_of', v_m.id, 'reason', btrim(p_reason)),\n p_idempotency_key, auth.uid(), v_m.id, v_m.credit_account_id, v_m.debit_account_id)\n RETURNING id INTO v_rev;\n\n UPDATE public.plg_financial_movements SET reversed_by_id = v_rev, reason = coalesce(reason, btrim(p_reason)), updated_at = now()\n WHERE id = v_m.id;\n IF v_m.financial_invoice_installment_id IS NOT NULL THEN PERFORM public.plg_financial_sync_mirror(v_m.financial_invoice_installment_id); END IF;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.movement.reverse', 'plg_financial_movements', v_m.id::text,\n jsonb_build_object('reversal_id', v_rev, 'amount', v_m.amount, 'reason', btrim(p_reason), 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('reversal_id', v_rev, 'movement_id', v_m.id, 'replayed', false);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_cash_session_open(\n p_cofre_account_id uuid,\n p_opening_amount numeric,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_actor uuid := auth.uid();\n v_cofre record;\n v_amount numeric := round(coalesce(p_opening_amount, 0), 2);\n v_sess uuid; v_acc uuid; v_mov uuid; v_name text;\nBEGIN\n IF v_actor IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_open: a signed-in user is required' USING ERRCODE = '42501';\n END IF;\n IF v_amount < 0 THEN\n RAISE EXCEPTION 'financial_cash_session_open: opening amount must be >= 0' USING ERRCODE = '22023';\n END IF;\n SELECT a.id, a.unit_id, a.kind, a.name INTO v_cofre FROM public.plg_financial_accounts a\n WHERE a.id = p_cofre_account_id AND a.tenant_id = v_tenant AND a.is_active;\n IF v_cofre.id IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_open: cofre account % not found', p_cofre_account_id USING ERRCODE = 'P0002';\n END IF;\n IF v_cofre.kind <> 'cash_physical' THEN\n RAISE EXCEPTION 'financial_cash_session_open: account % is not a cofre (cash_physical)', p_cofre_account_id USING ERRCODE = '23514';\n END IF;\n IF v_cofre.unit_id IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_open: the cofre has no unit' USING ERRCODE = '55000';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.cash_session', v_cofre.unit_id);\n\n IF p_idempotency_key IS NOT NULL THEN\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n SELECT s.id, s.account_id, s.opening_movement_id INTO v_sess, v_acc, v_mov FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.open_idempotency_key = p_idempotency_key;\n IF v_sess IS NOT NULL THEN\n RETURN jsonb_build_object('session_id', v_sess, 'account_id', v_acc, 'opening_movement_id', v_mov, 'replayed', true);\n END IF;\n END IF;\n SELECT s.id INTO v_sess FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.user_id = v_actor AND s.unit_id = v_cofre.unit_id AND s.status = 'open';\n IF v_sess IS NOT NULL THEN\n RAISE EXCEPTION 'financial_cash_session_open: you already have an open cash session in this unit' USING ERRCODE = '55000';\n END IF;\n\n SELECT coalesce(nullif(u.raw_user_meta_data ->> 'full_name', ''), u.email, v_actor::text) INTO v_name FROM auth.users u WHERE u.id = v_actor;\n INSERT INTO public.plg_financial_accounts (tenant_id, unit_id, kind, name, metadata, created_by)\n VALUES (v_tenant, v_cofre.unit_id, 'cash_session', 'Caixinha \u00B7 ' || coalesce(v_name, '') || ' \u00B7 ' || to_char(now(), 'YYYY-MM-DD HH24:MI'),\n jsonb_build_object('cash_session', true), v_actor)\n RETURNING id INTO v_acc;\n\n INSERT INTO public.plg_financial_cash_register_sessions\n (tenant_id, unit_id, user_id, bank_account_id, account_id, status, opened_at, opened_by_user_id, opened_by_name,\n opening_balance, open_idempotency_key, metadata)\n VALUES\n (v_tenant, v_cofre.unit_id, v_actor, v_cofre.id, v_acc, 'open', now(), v_actor, v_name,\n v_amount, p_idempotency_key, jsonb_build_object('cofre_name', v_cofre.name))\n RETURNING id INTO v_sess;\n UPDATE public.plg_financial_accounts SET metadata = metadata || jsonb_build_object('cash_session_id', v_sess) WHERE id = v_acc;\n\n IF v_amount > 0 THEN\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method, cash_session_id,\n bank_account_id, notes, metadata, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_cofre.unit_id, 'credit', 'transfer', v_amount, v_amount, 'paid', current_date, current_date, 'cash', v_sess,\n v_acc, 'Abertura de caixa', jsonb_build_object('source', 'financial_cash_session_open', 'cash_session_id', v_sess, 'role', 'opening'),\n v_actor, v_acc, v_cofre.id)\n RETURNING id INTO v_mov;\n UPDATE public.plg_financial_cash_register_sessions SET opening_movement_id = v_mov WHERE id = v_sess;\n END IF;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.cash_session.open', 'plg_financial_cash_register_sessions', v_sess::text,\n jsonb_build_object('cofre_account_id', v_cofre.id, 'unit_id', v_cofre.unit_id, 'opening_amount', v_amount, 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('session_id', v_sess, 'account_id', v_acc, 'opening_movement_id', v_mov, 'replayed', false);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_cash_session_close(\n p_counted_cash numeric,\n p_counted_card numeric,\n p_idempotency_key text DEFAULT NULL,\n p_notes text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_actor uuid := auth.uid();\n v_s public.plg_financial_cash_register_sessions%ROWTYPE;\n v_exp_cash numeric; v_exp_card numeric; v_diff_cash numeric; v_diff_card numeric;\n v_counted_cash numeric := round(coalesce(p_counted_cash, 0), 2);\n v_counted_card numeric := round(coalesce(p_counted_card, 0), 2);\n v_close_mov uuid; v_diff_mov uuid; v_name text;\nBEGIN\n IF v_actor IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_close: a signed-in user is required' USING ERRCODE = '42501';\n END IF;\n -- replay?\n IF p_idempotency_key IS NOT NULL THEN\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n SELECT * INTO v_s FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.close_idempotency_key = p_idempotency_key;\n IF FOUND THEN\n RETURN jsonb_build_object('session_id', v_s.id, 'expected_cash', v_s.expected_cash, 'counted_cash', v_s.counted_cash,\n 'difference_cash', v_s.difference_cash, 'expected_card', v_s.expected_card, 'counted_card', v_s.counted_card,\n 'difference_card', v_s.difference_card, 'replayed', true);\n END IF;\n END IF;\n\n SELECT * INTO v_s FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.user_id = v_actor AND s.status = 'open'\n ORDER BY s.opened_at DESC LIMIT 1;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_cash_session_close: you have no open cash session' USING ERRCODE = '55000';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.cash_session', v_s.unit_id);\n\n SELECT balance INTO v_exp_cash FROM public.v_financial_account_balances WHERE account_id = v_s.account_id;\n v_exp_cash := round(coalesce(v_exp_cash, 0), 2);\n SELECT coalesce(sum(m.amount), 0) INTO v_exp_card FROM public.plg_financial_movements m\n WHERE m.cash_session_id = v_s.id AND m.movement_kind = 'payment' AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL\n AND m.method IN ('credit_card', 'debit_card');\n v_exp_card := round(coalesce(v_exp_card, 0), 2);\n v_diff_cash := round(v_counted_cash - v_exp_cash, 2);\n v_diff_card := round(v_counted_card - v_exp_card, 2);\n\n SELECT coalesce(nullif(u.raw_user_meta_data ->> 'full_name', ''), u.email) INTO v_name FROM auth.users u WHERE u.id = v_actor;\n\n -- counted cash returns to the cofre\n IF v_counted_cash > 0 THEN\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method, cash_session_id,\n bank_account_id, notes, metadata, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_s.unit_id, 'debit', 'transfer', v_counted_cash, v_counted_cash, 'paid', current_date, current_date, 'cash', v_s.id,\n v_s.bank_account_id, 'Fechamento de caixa', jsonb_build_object('source', 'financial_cash_session_close', 'cash_session_id', v_s.id, 'role', 'closing'),\n v_actor, v_s.bank_account_id, v_s.account_id)\n RETURNING id INTO v_close_mov;\n END IF;\n -- the difference is a shortage (counted < expected: drawer credited from the\n -- shortage account, i.e. we lost it) or an overage (drawer had more: booked out to overage)\n IF v_diff_cash <> 0 THEN\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, method, cash_session_id,\n reason, notes, metadata, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_s.unit_id, CASE WHEN v_diff_cash < 0 THEN 'debit' ELSE 'credit' END, 'transfer', abs(v_diff_cash), abs(v_diff_cash), 'paid',\n current_date, current_date, 'cash', v_s.id,\n CASE WHEN v_diff_cash < 0 THEN 'Quebra de caixa (falta)' ELSE 'Quebra de caixa (sobra)' END, p_notes,\n jsonb_build_object('source', 'financial_cash_session_close', 'cash_session_id', v_s.id,\n 'role', CASE WHEN v_diff_cash < 0 THEN 'shortage' ELSE 'overage' END, 'difference', v_diff_cash),\n v_actor,\n CASE WHEN v_diff_cash < 0 THEN public.plg_financial_system_account(v_tenant, 'cash_shortage') ELSE v_s.account_id END,\n CASE WHEN v_diff_cash < 0 THEN v_s.account_id ELSE public.plg_financial_system_account(v_tenant, 'cash_overage') END)\n RETURNING id INTO v_diff_mov;\n END IF;\n\n UPDATE public.plg_financial_cash_register_sessions\n SET status = 'closed', closed_at = now(), closed_by_user_id = v_actor, closed_by_name = v_name,\n expected_cash = v_exp_cash, counted_cash = v_counted_cash, difference_cash = v_diff_cash,\n expected_card = v_exp_card, counted_card = v_counted_card, difference_card = v_diff_card,\n expected_balance = v_exp_cash, closing_balance = v_counted_cash, difference = v_diff_cash,\n closing_movement_id = v_close_mov, difference_movement_id = v_diff_mov,\n close_idempotency_key = p_idempotency_key,\n notes = coalesce(p_notes, notes)\n WHERE id = v_s.id;\n UPDATE public.plg_financial_accounts SET is_active = false WHERE id = v_s.account_id;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.cash_session.close', 'plg_financial_cash_register_sessions', v_s.id::text,\n jsonb_build_object('expected_cash', v_exp_cash, 'counted_cash', v_counted_cash, 'difference_cash', v_diff_cash,\n 'expected_card', v_exp_card, 'counted_card', v_counted_card, 'difference_card', v_diff_card,\n 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('session_id', v_s.id, 'expected_cash', v_exp_cash, 'counted_cash', v_counted_cash,\n 'difference_cash', v_diff_cash, 'expected_card', v_exp_card, 'counted_card', v_counted_card,\n 'difference_card', v_diff_card, 'closing_movement_id', v_close_mov, 'difference_movement_id', v_diff_mov, 'replayed', false);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_create_invoice(\n p_direction text,\n p_total numeric,\n p_counterparty_person_id uuid DEFAULT NULL,\n p_order_id uuid DEFAULT NULL,\n p_unit_id uuid DEFAULT NULL,\n p_installments jsonb DEFAULT NULL,\n p_competence_date date DEFAULT NULL,\n p_description text DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_metadata jsonb DEFAULT '{}'::jsonb,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_unit uuid := p_unit_id;\n v_order public.orders%ROWTYPE;\n v_invoice_id uuid;\n v_number text;\n v_total numeric := round(coalesce(p_total, 0), 2);\n v_insts jsonb;\n v_sum numeric := 0;\n v_i jsonb;\n v_n int := 0;\n v_out jsonb := '[]'::jsonb;\n v_inst_id uuid;\nBEGIN\n IF p_direction NOT IN ('receivable', 'payable') THEN\n RAISE EXCEPTION 'financial_create_invoice: direction must be receivable|payable' USING ERRCODE = '22023';\n END IF;\n IF v_total < 0 THEN\n RAISE EXCEPTION 'financial_create_invoice: total must be >= 0' USING ERRCODE = '22023';\n END IF;\n\n IF p_order_id IS NOT NULL THEN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_create_invoice: order % not found in this tenant', p_order_id USING ERRCODE = 'P0002';\n END IF;\n IF v_order.kind IN ('invoice_receivable', 'invoice_payable') THEN\n RAISE EXCEPTION 'financial_create_invoice: order % is a retired invoice kind', p_order_id USING ERRCODE = '23514';\n END IF;\n v_unit := coalesce(v_unit, v_order.unit_id);\n END IF;\n\n PERFORM public.plg_financial_require_permission('financial.create', v_unit);\n\n -- Idempotency: same key in the tenant \u2192 the same invoice, no second effect.\n IF p_idempotency_key IS NOT NULL THEN\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n SELECT id, number INTO v_invoice_id, v_number FROM public.plg_financial_invoices\n WHERE tenant_id = v_tenant AND idempotency_key = p_idempotency_key;\n IF v_invoice_id IS NOT NULL THEN\n SELECT coalesce(jsonb_agg(jsonb_build_object('id', i.id, 'number', i.number, 'due_date', i.due_date, 'amount', i.amount) ORDER BY i.number), '[]'::jsonb)\n INTO v_out FROM public.plg_financial_invoice_installments i WHERE i.invoice_id = v_invoice_id;\n RETURN jsonb_build_object('invoice_id', v_invoice_id, 'number', v_number, 'installments', v_out, 'replayed', true);\n END IF;\n END IF;\n\n -- installments: given, or one for the total\n v_insts := coalesce(p_installments, jsonb_build_array(jsonb_build_object(\n 'number', 1, 'due_date', coalesce(p_competence_date, current_date), 'amount', v_total)));\n IF jsonb_typeof(v_insts) <> 'array' OR jsonb_array_length(v_insts) = 0 THEN\n RAISE EXCEPTION 'financial_create_invoice: installments must be a non-empty array' USING ERRCODE = '22023';\n END IF;\n FOR v_i IN SELECT * FROM jsonb_array_elements(v_insts) LOOP\n v_sum := v_sum + round(coalesce((v_i ->> 'amount')::numeric, 0), 2);\n END LOOP;\n IF v_sum <> v_total THEN\n RAISE EXCEPTION 'financial_create_invoice: installments sum to % but the invoice total is %', v_sum, v_total USING ERRCODE = '23514';\n END IF;\n\n v_number := public.plg_financial_next_invoice_number(v_tenant, p_direction);\n\n INSERT INTO public.plg_financial_invoices\n (tenant_id, unit_id, direction, counterparty_person_id, order_id, number, competence_date, issue_date,\n total, description, notes, metadata, idempotency_key, created_by)\n VALUES\n (v_tenant, v_unit, p_direction, coalesce(p_counterparty_person_id, v_order.party_id), p_order_id, v_number,\n coalesce(p_competence_date, current_date), current_date, v_total, p_description, p_notes,\n coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'financial_create_invoice'),\n p_idempotency_key, auth.uid())\n RETURNING id INTO v_invoice_id;\n\n FOR v_i IN SELECT * FROM jsonb_array_elements(v_insts) LOOP\n v_n := v_n + 1;\n INSERT INTO public.plg_financial_invoice_installments\n (tenant_id, unit_id, invoice_id, number, due_date, amount, metadata)\n VALUES (v_tenant, v_unit, v_invoice_id, coalesce((v_i ->> 'number')::int, v_n),\n coalesce((v_i ->> 'due_date')::date, coalesce(p_competence_date, current_date)),\n round(coalesce((v_i ->> 'amount')::numeric, 0), 2),\n -- what came attached to THIS installment (today: receiptUrl). 015 dropped\n -- it on the floor, which is the whole point of this migration.\n CASE WHEN jsonb_typeof(v_i -> 'metadata') = 'object' THEN v_i -> 'metadata' ELSE '{}'::jsonb END)\n RETURNING id INTO v_inst_id;\n v_out := v_out || jsonb_build_object('id', v_inst_id, 'number', coalesce((v_i ->> 'number')::int, v_n),\n 'due_date', coalesce((v_i ->> 'due_date')::date, coalesce(p_competence_date, current_date)),\n 'amount', round(coalesce((v_i ->> 'amount')::numeric, 0), 2));\n END LOOP;\n\n -- The Venda records that it was invoiced (same keys the legacy readers use).\n IF p_order_id IS NOT NULL THEN\n UPDATE public.orders SET\n stage = 'invoiced',\n direction = CASE WHEN p_direction = 'payable' THEN 'debit' ELSE 'credit' END,\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('invoiceNumber', v_number, 'installmentCount', v_n),\n updated_at = now()\n WHERE id = p_order_id;\n END IF;\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.invoice.create', 'plg_financial_invoices', v_invoice_id::text,\n jsonb_build_object('number', v_number, 'direction', p_direction, 'total', v_total, 'order_id', p_order_id,\n 'installments', v_n, 'idempotency_key', p_idempotency_key));\n\n RETURN jsonb_build_object('invoice_id', v_invoice_id, 'number', v_number, 'installments', v_out, 'replayed', false);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_set_opening_balance(\n p_account_id uuid,\n p_amount numeric,\n p_date date DEFAULT NULL,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_acc record; v_existing record; v_amount numeric := round(coalesce(p_amount, 0), 2); v_mov uuid; v_open uuid;\nBEGIN\n SELECT a.id, a.unit_id, a.kind INTO v_acc FROM public.plg_financial_accounts a WHERE a.id = p_account_id AND a.tenant_id = v_tenant;\n IF v_acc.id IS NULL THEN\n RAISE EXCEPTION 'financial_set_opening_balance: account % not found', p_account_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.create', v_acc.unit_id);\n -- idempotent by ACCOUNT here rather than by key (an account has one opening\n -- balance), which is right \u2014 and still racing without the claim.\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n SELECT m.id, m.amount, m.debit_account_id INTO v_existing FROM public.plg_financial_movements m\n WHERE m.tenant_id = v_tenant AND m.metadata ->> 'opening_balance_for' = p_account_id::text;\n IF v_existing.id IS NOT NULL THEN\n IF (CASE WHEN v_existing.debit_account_id = p_account_id THEN v_existing.amount ELSE -v_existing.amount END) = v_amount THEN\n RETURN jsonb_build_object('movement_id', v_existing.id, 'amount', v_amount, 'replayed', true);\n END IF;\n RAISE EXCEPTION 'financial_set_opening_balance: account % already has an opening balance; post an adjustment transfer' , p_account_id USING ERRCODE = '55000';\n END IF;\n IF v_amount = 0 THEN\n RETURN jsonb_build_object('movement_id', NULL, 'amount', 0, 'replayed', false);\n END IF;\n v_open := public.plg_financial_system_account(v_tenant, 'opening_balances');\n INSERT INTO public.plg_financial_movements\n (tenant_id, unit_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date, notes, metadata,\n idempotency_key, created_by, debit_account_id, credit_account_id)\n VALUES\n (v_tenant, v_acc.unit_id, CASE WHEN v_amount > 0 THEN 'credit' ELSE 'debit' END, 'transfer', abs(v_amount), abs(v_amount), 'paid',\n coalesce(p_date, current_date), coalesce(p_date, current_date), 'Saldo de abertura',\n jsonb_build_object('opening_balance_for', p_account_id, 'source', 'financial_set_opening_balance'),\n p_idempotency_key, auth.uid(),\n CASE WHEN v_amount > 0 THEN p_account_id ELSE v_open END,\n CASE WHEN v_amount > 0 THEN v_open ELSE p_account_id END)\n RETURNING id INTO v_mov;\n PERFORM public.plg_financial_audit(v_tenant, 'financial.account.opening_balance', 'plg_financial_accounts', p_account_id::text,\n jsonb_build_object('amount', v_amount, 'movement_id', v_mov));\n RETURN jsonb_build_object('movement_id', v_mov, 'amount', v_amount, 'replayed', false);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_cancel_invoice(\n p_invoice_id uuid,\n p_reason text,\n p_idempotency_key text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_invoice public.plg_financial_invoices%ROWTYPE;\n v_paid numeric;\nBEGIN\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'financial_cancel_invoice: a reason is required' USING ERRCODE = '22023';\n END IF;\n SELECT * INTO v_invoice FROM public.plg_financial_invoices WHERE id = p_invoice_id AND tenant_id = v_tenant;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'financial_cancel_invoice: invoice % not found', p_invoice_id USING ERRCODE = 'P0002';\n END IF;\n PERFORM public.plg_financial_require_permission('financial.edit', v_invoice.unit_id);\n\n -- idempotent by STATE (cancelled_at) rather than by a key lookup, which is\n -- right for a cancellation \u2014 and still racing without the claim: two callers\n -- both read cancelled_at IS NULL and both cancel.\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n\n\n IF v_invoice.cancelled_at IS NOT NULL THEN\n RETURN jsonb_build_object('invoice_id', v_invoice.id, 'status', 'cancelled', 'replayed', true);\n END IF;\n\n SELECT coalesce(sum(paid), 0) INTO v_paid FROM public.v_financial_installment_balances WHERE invoice_id = p_invoice_id;\n IF v_paid > 0 THEN\n RAISE EXCEPTION 'financial_cancel_invoice: invoice % has % already received; reverse the payments or cancel the paid items into credit first', v_invoice.number, v_paid\n USING ERRCODE = '55000';\n END IF;\n\n UPDATE public.plg_financial_invoices\n SET cancelled_at = now(), cancel_reason = btrim(p_reason),\n metadata = metadata || jsonb_build_object('cancel_idempotency_key', p_idempotency_key)\n WHERE id = p_invoice_id;\n UPDATE public.plg_financial_invoice_installments SET cancelled_at = now() WHERE invoice_id = p_invoice_id AND cancelled_at IS NULL;\n -- legacy mirror rows follow the installment\n UPDATE public.plg_financial_movements SET status = 'cancelled', updated_at = now()\n WHERE financial_invoice_id = p_invoice_id AND movement_kind = 'bill' AND status <> 'cancelled';\n\n PERFORM public.plg_financial_audit(v_tenant, 'financial.invoice.cancel', 'plg_financial_invoices', p_invoice_id::text,\n jsonb_build_object('reason', btrim(p_reason), 'idempotency_key', p_idempotency_key));\n RETURN jsonb_build_object('invoice_id', v_invoice.id, 'status', 'cancelled', 'replayed', false);\nEND $$;\n";
35
+ export declare const MIGRATION_032_SCOPE_LOCALITY = "-- The financial tables declare what unit_id means on them (#225).\n--\n-- Core migration 126 classifies the core's 47, but a plugin's tables are\n-- scaffolded after the core chain has finished \u2014 126 could not have reached\n-- them. They register as `unit_optional` (the safe default, nothing enforced)\n-- and the plugin is the only thing that knows which of them are documents.\n--\n-- A movement, an invoice, an instalment, an allocation, an NFe and a cash\n-- session all happened at exactly one branch. A payment method, a bank account,\n-- a chart of accounts, a cost centre and a card brand are configuration the\n-- network shares \u2014 those stay unit_optional rather than becoming `tenant`,\n-- because a branch is allowed to have its own bank account.\nDO $$\nDECLARE t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY[\n 'plg_financial_movements','plg_financial_invoices','plg_financial_invoice_installments',\n 'plg_financial_allocations','plg_financial_nfe','plg_financial_cash_register_sessions',\n 'plg_financial_cash_session_reviewers'] LOOP\n CONTINUE WHEN to_regclass('public.' || t) IS NULL;\n PERFORM app.scaffold_scope(('public.' || t)::regclass, 'unit', false);\n END LOOP;\nEND $$;\n";
36
+ export declare const MIGRATION_033_PERSON_PAYMENTS_VIEW = "-- ---------------------------------------------------------------------------\n-- 030_person_payments_view.sql \u2014 what a person paid, from the ledger (#285).\n--\n-- The admin timeline asked this question of `public.transactions`: give me the\n-- payments of this person, by the date of the fact. That table is the legacy\n-- generic archetype the O2C ledger superseded (065: \"legacy generic archetype;\n-- O2C's ledger supersedes it\"), and it is being retired \u2014 so the question needs\n-- an answer that comes from the book that is actually kept.\n--\n-- The person is not on the movement. It is on the T\u00EDtulo (counterparty) or, for\n-- a movement tied to a Venda, on the order's party. Both paths are followed\n-- here, so a payment reaches the timeline whichever way it was recorded.\n--\n-- security_invoker: the caller's RLS decides what they see, which is what makes\n-- this safe to expose to the shell.\n-- ---------------------------------------------------------------------------\n\n-- \u2500\u2500 one name, two shapes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `plg_financial_payment_methods` is not the same table everywhere, and that is\n-- a fact about the fleet rather than a bug in this view. On most pools it is\n-- the financial plugin's payment-method CATALOGUE and carries `name`. On the\n-- ecommerce cluster the same name holds a card VAULT \u2014 id, owner, brand, last4\n-- \u2014 because 000_plg_rename renamed a `payment_methods` that meant something\n-- else there.\n--\n-- So the join is made only where there is a name to join to. Where there is\n-- not, `l.method` answers alone, which is what the timeline showed before this\n-- view existed. Building the view dynamically is not cleverness: a static\n-- reference to a column that exists on three pools and not on the fourth is a\n-- migration that permanently blocks a production cluster.\n--\n-- The collision itself is real and outlives this file \u2014 one prefixed name\n-- meaning two things is exactly what the ownership work exists to prevent, and\n-- it is recorded rather than silently worked around.\n\nDO $$\nDECLARE\n v_has_name boolean := EXISTS (\n SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public'\n AND table_name = 'plg_financial_payment_methods'\n AND column_name = 'name'\n );\n v_method text := CASE WHEN v_has_name THEN 'COALESCE(pm.name, l.method)' ELSE 'l.method' END;\n v_join text := CASE WHEN v_has_name\n THEN 'LEFT JOIN public.plg_financial_payment_methods pm ON pm.id = l.payment_method_id'\n ELSE '' END;\nBEGIN\n EXECUTE format($v$\n CREATE OR REPLACE VIEW public.v_financial_person_payments\n WITH (security_invoker = true) AS\n SELECT\n l.id,\n l.tenant_id,\n l.unit_id,\n COALESCE(inv.counterparty_person_id, o.party_id) AS party_id,\n l.amount,\n COALESCE(inv.currency, 'BRL') AS currency,\n %s AS payment_method,\n l.status,\n COALESCE(l.payment_date::timestamptz, l.created_at) AS transacted_at,\n l.invoice_id,\n l.order_id,\n l.movement_kind,\n l.direction,\n l.created_at\n FROM public.v_financial_ledger l\n LEFT JOIN public.plg_financial_invoices inv ON inv.id = l.invoice_id\n LEFT JOIN public.orders o ON o.id = l.order_id\n %s\n WHERE l.entry_kind = 'movement'\n AND l.movement_kind <> 'bill'\n AND COALESCE(inv.counterparty_person_id, o.party_id) IS NOT NULL\n $v$, v_method, v_join);\nEND $$;\n\nCOMMENT ON VIEW public.v_financial_person_payments IS\n 'Payments attributable to a Person, from the two-leg ledger (#285). Replaces the admin timeline read of public.transactions, the legacy archetype the ledger superseded.';\n\nGRANT SELECT ON public.v_financial_person_payments TO authenticated, service_role;\n";
37
+ export declare const MIGRATION_034_LEGACY_TRANSACTIONS_DROP_GATE = "-- ---------------------------------------------------------------------------\n-- 034_legacy_transactions_drop_gate.sql \u2014 what has to be zero before the legacy\n-- archetype can be dropped (#285).\n--\n-- 148 stopped `public.transactions` from being written: it is the legacy generic\n-- archetype the two-leg ledger superseded (065, 101). It did not drop it, because\n-- a live pool holds rows and dropping has no undo.\n--\n-- This is the question that turns the drop into a mechanical step: per tenant,\n-- how many rows exist there that the ledger cannot account for. It lives with the\n-- financial plugin because half the answer is the plugin's own table \u2014 the base\n-- cannot ask it, and a pool without the financial plugin has no ledger to compare\n-- against and no reason to care.\n--\n-- The match is deliberately loose (same tenant, same amount, within a day): an\n-- exact join does not exist, because the two books were never linked. A loose\n-- match that OVER-counted as accounted-for would be dangerous; this one\n-- under-counts \u2014 anything it cannot pair shows up as work.\n-- ---------------------------------------------------------------------------\n\nDO $mig$\nBEGIN\n IF to_regclass('public.transactions') IS NULL THEN\n RETURN;\n END IF;\n\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.v_legacy_transactions_unaccounted\n WITH (security_invoker = true) AS\n SELECT t.tenant_id,\n count(*)::bigint AS rows_without_ledger_entry,\n min(t.created_at) AS oldest,\n max(t.created_at) AS newest\n FROM public.transactions t\n WHERE NOT EXISTS (\n SELECT 1 FROM public.plg_financial_movements m\n WHERE m.tenant_id = t.tenant_id\n AND m.amount = t.amount\n AND m.created_at BETWEEN t.created_at - interval '1 day' AND t.created_at + interval '1 day'\n )\n GROUP BY t.tenant_id\n $v$;\n\n EXECUTE $c$ COMMENT ON VIEW public.v_legacy_transactions_unaccounted IS\n 'The drop gate for public.transactions (#285): rows the two-leg ledger cannot account for, per tenant. Zero everywhere is what makes the DROP mechanical instead of a bet.' $c$;\n\n EXECUTE 'GRANT SELECT ON public.v_legacy_transactions_unaccounted TO authenticated, service_role';\nEND $mig$;\n";
38
+ export declare const MIGRATION_035_THE_LEGACY_BOOK_IS_DROPPED = "-- ---------------------------------------------------------------------------\n-- 035_the_legacy_book_is_dropped.sql \u2014 `public.transactions` stops existing\n-- (#285).\n--\n-- 065 and 101 both call it the archetype the two-leg ledger superseded, in\n-- their own text. 148 revoked its writes and deliberately did not drop it: a\n-- live pool held rows, dropping has no undo, and the honest order is introduce,\n-- reconcile, then retire WITH THE COUNT IN HAND. 034 built the count \u2014\n-- `v_legacy_transactions_unaccounted`, rows the ledger cannot account for, per\n-- tenant.\n--\n-- This is the third step. Zero everywhere and the table goes; anything else and\n-- nothing happens, loudly, and the drop occurs by itself on the next apply\n-- after the reconciliation is clean.\n--\n-- \u2500\u2500 Why this SKIPS rather than raises \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Refusing to apply would break `fayz db apply` on exactly the pool most likely\n-- to have unaccounted rows \u2014 the busiest one \u2014 which is the worst place to\n-- block a deploy over a cleanup. Nothing is dropped on a guess; nothing blocks\n-- on a number either.\n--\n-- \u2500\u2500 Why the view goes first \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- `v_legacy_transactions_unaccounted` reads the table. Dropping the table with\n-- the view attached would either cascade (silently taking the view) or fail.\n-- Dropping the view first makes the order explicit, and it has nothing left to\n-- say once its subject is gone.\n-- ---------------------------------------------------------------------------\n\nDO $$\nDECLARE\n v_unaccounted bigint;\nBEGIN\n IF to_regclass('public.transactions') IS NULL THEN\n RAISE NOTICE 'financial 035: public.transactions is already gone';\n RETURN;\n END IF;\n\n IF to_regclass('public.v_legacy_transactions_unaccounted') IS NULL THEN\n RAISE NOTICE 'financial 035: no reconciliation view (financial < 034) \u2014 the drop is skipped';\n RETURN;\n END IF;\n\n EXECUTE 'SELECT coalesce(sum(rows_without_ledger_entry), 0) FROM public.v_legacy_transactions_unaccounted'\n INTO v_unaccounted;\n\n IF v_unaccounted > 0 THEN\n RAISE NOTICE 'financial 035: % legacy transaction(s) the ledger cannot account for \u2014 public.transactions stays. Reconcile, then re-apply.', v_unaccounted;\n RETURN;\n END IF;\n\n DROP VIEW IF EXISTS public.v_legacy_transactions_unaccounted;\n DROP TABLE public.transactions;\n RAISE NOTICE 'financial 035: public.transactions dropped \u2014 the two-leg ledger is the only book (065, 101, #285)';\nEND $$;\n\n-- What is still in the way, for a pool where it is not zero yet.\nDO $$\nBEGIN\n IF to_regclass('public.transactions') IS NULL THEN RETURN; END IF;\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.v_legacy_book_retirement_pending\n WITH (security_invoker = true) AS\n SELECT u.tenant_id, u.rows_without_ledger_entry AS blocking_rows\n FROM public.v_legacy_transactions_unaccounted u\n WHERE u.rows_without_ledger_entry > 0\n $v$;\n EXECUTE $c$\n COMMENT ON VIEW public.v_legacy_book_retirement_pending IS\n 'Rows keeping public.transactions alive (#285). Empty means the next apply drops the table.'\n $c$;\n EXECUTE 'GRANT SELECT ON public.v_legacy_book_retirement_pending TO authenticated, service_role';\nEND $$;\n";
39
+ export declare const MIGRATION_036_WHAT_THE_LAW_MAKES_US_KEEP = "-- ---------------------------------------------------------------------------\n-- 036_what_the_law_makes_us_keep.sql \u2014 the financial plugin declares the rows a\n-- tenant purge must not delete (#284).\n--\n-- 161 gave the base a register \u2014 WHICH tables, for WHICH rule, for HOW long \u2014\n-- and deliberately declared nothing itself. A retention obligation is a fact\n-- about a domain: the plugin that owns the ledger is the one that knows the\n-- ledger is escritura\u00E7\u00E3o cont\u00E1bil, and the base has no business guessing.\n--\n-- It also has to be here for a duller reason: the base chain runs before any\n-- plugin's, so a carve-out for `plg_financial_movements` declared in 161 would\n-- have been guarded on a table that does not exist yet and skipped silently.\n--\n-- \u2500\u2500 Every row here is a customer's deletion request we will not fully honour \u2500\n-- So the list is short and each line is defensible on its own. `authority` is\n-- NOT NULL because a carve-out nobody can check is how \"we keep everything\"\n-- gets called compliance, and an erasure request cannot override a retention\n-- duty only where a duty actually exists (LGPD art. 16, I).\n--\n-- What is NOT carved out is as deliberate: a customer's name, phone, address\n-- and appointment history carry no fiscal obligation and are deleted in full.\n-- ---------------------------------------------------------------------------\n\nDO $$\nBEGIN\n IF to_regprocedure('public.register_retention_carve_out(text, text, text, integer, text)') IS NULL THEN\n RAISE NOTICE 'financial 036: the retention register is missing (spine < 161) \u2014 skipped';\n RETURN;\n END IF;\n\n IF to_regclass('public.plg_financial_movements') IS NOT NULL THEN\n PERFORM public.register_retention_carve_out(\n 'plg_financial_movements',\n 'O raz\u00E3o de duas pernas \u00E9 a escritura\u00E7\u00E3o cont\u00E1bil do per\u00EDodo.',\n 'CC art. 1.194 \u00B7 escritura\u00E7\u00E3o mantida enquanto n\u00E3o prescrita a a\u00E7\u00E3o (5 anos)', 5);\n END IF;\n\n IF to_regclass('public.plg_financial_invoices') IS NOT NULL THEN\n PERFORM public.register_retention_carve_out(\n 'plg_financial_invoices',\n 'Documento fiscal emitido: a obriga\u00E7\u00E3o de guarda \u00E9 do emissor, n\u00E3o do cliente.',\n 'CTN art. 173/174 \u00B7 decad\u00EAncia e prescri\u00E7\u00E3o em 5 anos', 5);\n END IF;\n\n IF to_regclass('public.plg_financial_nfe') IS NOT NULL THEN\n PERFORM public.register_retention_carve_out(\n 'plg_financial_nfe',\n 'XML da NF-e e seu protocolo de autoriza\u00E7\u00E3o.',\n 'Ajuste SINIEF 07/05 cl. 10\u00AA \u00B7 guarda do arquivo digital por 5 anos', 5);\n END IF;\nEND $$;\n";
40
+ export declare const MIGRATION_037_THE_SEQUENCE_TABLE_HAS_ONE_CREATOR = "-- ---------------------------------------------------------------------------\n-- 037_the_sequence_table_has_one_creator.sql \u2014 the ownership fix that #285 made\n-- in the wrong file (#285, #283).\n--\n-- \u2500\u2500 What went wrong, and how it was found \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- 004_order_to_cash.sql created `public.sequences`, a name the base has owned\n-- since spine 097. #285 fixed that by EDITING 004 \u2014 replacing the CREATE with a\n-- guard that raises if the base has not provided the table.\n--\n-- The intent was right and the place was wrong. 004 had already been applied to\n-- all eight industry pools, and the ledger treats a changed checksum on an\n-- applied file as a HARD STOP \u2014 correctly, because a file that ran once and now\n-- reads differently means nobody can say what a pool actually contains. The\n-- result: `fayz db apply` aborted at the financial step on every pool.\n--\n-- It could not have been caught from zero. A fresh database has no prior\n-- checksum to disagree with, so the whole chain test passes; only a pool that\n-- had already run 004 could tell. This is the argument for applying to one real\n-- cluster before eight.\n--\n-- 004 is restored to the bytes the pools ran. The rule it was trying to state\n-- lives here, where a new file can say it without rewriting history, and\n-- `sequences` joins the adoption register (162) so the ownership gate reads a\n-- declaration instead of carrying an exemption.\n--\n-- \u2500\u2500 Why this raises rather than creating \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Two creators for one name is how ownership gets lost. If the base has not\n-- provided `public.sequences`, the honest outcome is a stop with a message\n-- naming the spine version to apply \u2014 not a second CREATE that makes the pool\n-- work and the ownership question unanswerable.\n-- ---------------------------------------------------------------------------\n\nDO $$\nBEGIN\n IF to_regclass('public.sequences') IS NULL THEN\n RAISE EXCEPTION 'plugin-financial 037: public.sequences is missing \u2014 it is base-owned since spine 097'\n USING HINT = 'Apply the spine first. This plugin used to create the table (004) and does not any more: two creators for one name is how ownership gets lost.';\n END IF;\nEND $$;\n\nCOMMENT ON TABLE public.sequences IS\n 'Per-tenant document numbering. Base-owned since spine 097; plugin-financial created it until 004 and now only asserts it is there (037, #285).';\n";
19
41
  export declare const MIGRATIONS: Array<{
20
42
  id: string;
21
43
  sql: string;