@fayz-ai/plugin-financial 0.12.2 → 0.13.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.
- package/dist/components/CashSessionHeaderControl.d.ts +20 -0
- package/dist/components/CashSessionHeaderControl.d.ts.map +1 -0
- package/dist/components/ClientAccountModal.d.ts +9 -0
- package/dist/components/ClientAccountModal.d.ts.map +1 -0
- package/dist/components/CommissionFunctionsDialog.d.ts +11 -0
- package/dist/components/CommissionFunctionsDialog.d.ts.map +1 -0
- package/dist/components/CommissionRuleDialog.d.ts +13 -0
- package/dist/components/CommissionRuleDialog.d.ts.map +1 -0
- package/dist/components/PaymentModal.d.ts.map +1 -1
- package/dist/components/ReceivePaymentModal.d.ts +15 -0
- package/dist/components/ReceivePaymentModal.d.ts.map +1 -0
- package/dist/context.d.ts +3 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/registries.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +15 -0
- package/dist/data/tables.d.ts.map +1 -1
- package/dist/data/types.d.ts +47 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13765 -5707
- package/dist/index.js.map +1 -1
- package/dist/lib/onboarding.d.ts +16 -0
- package/dist/lib/onboarding.d.ts.map +1 -0
- package/dist/lib/settings-only.d.ts +11 -0
- package/dist/lib/settings-only.d.ts.map +1 -0
- package/dist/lib/setup-guides.d.ts +3 -0
- package/dist/lib/setup-guides.d.ts.map +1 -0
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +31 -40
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/store.d.ts +6 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +314 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/CardsView.d.ts.map +1 -1
- package/dist/views/CashRegistersView.d.ts +14 -1
- package/dist/views/CashRegistersView.d.ts.map +1 -1
- package/dist/views/ClientAccountPanel.d.ts +16 -0
- package/dist/views/ClientAccountPanel.d.ts.map +1 -0
- package/dist/views/CommissionRulesView.d.ts +7 -0
- package/dist/views/CommissionRulesView.d.ts.map +1 -0
- package/dist/views/CommissionsView.d.ts +0 -7
- package/dist/views/CommissionsView.d.ts.map +1 -1
- package/dist/views/FinancialPage.d.ts +2 -0
- package/dist/views/FinancialPage.d.ts.map +1 -1
- package/dist/views/GiftCardsView.d.ts +3 -0
- package/dist/views/GiftCardsView.d.ts.map +1 -0
- package/dist/views/GoalsView.d.ts +3 -0
- package/dist/views/GoalsView.d.ts.map +1 -0
- package/dist/views/InvoiceDetailView.d.ts.map +1 -1
- package/dist/views/InvoiceFormView.d.ts +3 -1
- package/dist/views/InvoiceFormView.d.ts.map +1 -1
- package/dist/views/InvoiceListView.d.ts.map +1 -1
- package/dist/views/NotasFiscaisView.d.ts.map +1 -1
- package/dist/views/ReconciliationView.d.ts.map +1 -1
- package/dist/views/StatementsView.d.ts.map +1 -1
- package/package.json +4 -4
|
@@ -1,43 +1,34 @@
|
|
|
1
|
-
export declare const MIGRATION_000_PLG_RENAME = "-- 000_plg_rename.sql \u2014 rename legacy financial tables to plg_financial_* for pools\n-- provisioned before the industry-pool rename. Guarded: fires only when the legacy\n-- name exists and the target does not, so fresh pools skip every branch.\nDO $$\nBEGIN\n IF to_regclass('public.financial_movements') IS NOT NULL AND to_regclass('public.plg_financial_movements') IS NULL THEN\n ALTER TABLE public.financial_movements RENAME TO plg_financial_movements;\n END IF;\n IF to_regclass('public.chart_of_accounts') IS NOT NULL AND to_regclass('public.plg_financial_chart_of_accounts') IS NULL THEN\n ALTER TABLE public.chart_of_accounts RENAME TO plg_financial_chart_of_accounts;\n END IF;\n IF to_regclass('public.bank_accounts') IS NOT NULL AND to_regclass('public.plg_financial_bank_accounts') IS NULL THEN\n ALTER TABLE public.bank_accounts RENAME TO plg_financial_bank_accounts;\n END IF;\n IF to_regclass('public.card_brands') IS NOT NULL AND to_regclass('public.plg_financial_card_brands') IS NULL THEN\n ALTER TABLE public.card_brands RENAME TO plg_financial_card_brands;\n END IF;\n IF to_regclass('public.cost_centers') IS NOT NULL AND to_regclass('public.plg_financial_cost_centers') IS NULL THEN\n ALTER TABLE public.cost_centers RENAME TO plg_financial_cost_centers;\n END IF;\n IF to_regclass('public.cash_register_sessions') IS NOT NULL AND to_regclass('public.plg_financial_cash_register_sessions') IS NULL THEN\n ALTER TABLE public.cash_register_sessions RENAME TO plg_financial_cash_register_sessions;\n END IF;\n IF to_regclass('public.payment_methods') IS NOT NULL AND to_regclass('public.plg_financial_payment_methods') IS NULL THEN\n ALTER TABLE public.payment_methods RENAME TO plg_financial_payment_methods;\n END IF;\n IF to_regclass('public.payment_method_types') IS NOT NULL AND to_regclass('public.plg_financial_payment_method_types') IS NULL THEN\n ALTER TABLE public.payment_method_types RENAME TO plg_financial_payment_method_types;\n END IF;\nEND $$;\n";
|
|
2
|
-
export declare const
|
|
3
|
-
export declare const
|
|
4
|
-
export declare const
|
|
5
|
-
export declare const
|
|
6
|
-
export declare const
|
|
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
|
-
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";
|
|
9
|
-
export declare const MIGRATION_007_RECONCILIATION = "-- ---------------------------------------------------------------------------\n-- Reconciliation (concilia\u00E7\u00E3o): bank-statement lines \u2194 internal movements\n-- ---------------------------------------------------------------------------\n-- An integration (e.g. open banking) imports bank-statement lines as\n-- plg_financial_movements tagged with (external_source, external_id). The matching\n-- UI then links each imported line to the internal movement it settles, or\n-- accepts it standalone. These columns make a movement carry its bank-line\n-- identity and its reconciliation state.\n-- external_source / external_id : the bank line's provider + unique id\n-- reconciled_at : when the line was matched/accepted (NULL = pending)\n-- matched_movement_id : the internal movement this line reconciles (NULL = standalone)\nALTER TABLE public.plg_financial_movements\n ADD COLUMN IF NOT EXISTS external_id text,\n ADD COLUMN IF NOT EXISTS external_source text,\n ADD COLUMN IF NOT EXISTS reconciled_at timestamptz,\n ADD COLUMN IF NOT EXISTS matched_movement_id uuid\n REFERENCES public.plg_financial_movements(id) ON DELETE SET NULL;\n\nCOMMENT ON COLUMN public.plg_financial_movements.external_source IS\n 'Provider of an imported bank-statement line (e.g. plugbank, inter). NULL for app-native movements.';\nCOMMENT ON COLUMN public.plg_financial_movements.external_id IS\n 'Provider-unique id of the imported bank line. Forms the import idempotency key with external_source.';\n\n-- Idempotency: a given bank line imports at most once per tenant.\nCREATE UNIQUE INDEX IF NOT EXISTS uq_plg_financial_movements_external\n ON public.plg_financial_movements (tenant_id, external_source, external_id)\n WHERE external_id IS NOT NULL AND external_source IS NOT NULL;\n\n-- Fast lookup of imported-but-unreconciled lines for the matching screen.\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_unreconciled\n ON public.plg_financial_movements (tenant_id, payment_date)\n WHERE external_source IS NOT NULL AND reconciled_at IS NULL;\n";
|
|
10
|
-
export declare const MIGRATION_007B_MOVEMENT_PAYMENT_METHOD_TYPE = "-- payment_method_type_id existed on pre-pool installs (added out-of-band on\n-- the salon/beauty DB) but was never captured in the file series; 008 requires\n-- it on plg_financial_movements. No-op where the column already exists.\n\nALTER TABLE public.plg_financial_movements\n ADD COLUMN IF NOT EXISTS payment_method_type_id uuid REFERENCES public.plg_financial_payment_method_types(id);\n";
|
|
11
|
-
export declare const MIGRATION_008_SPLIT_PAYMENT_MOVEMENTS = "-- Split bill (obligation) vs payment (cash event).\n-- Each payment is now its own movement_kind='payment' row (one per cash event), so\n-- two payments on one installment show as two extract rows instead of one summed row.\n-- Bills stay pure obligations; v_invoice_balances already filters movement_kind='bill',\n-- so the new payment rows don't affect invoice balances. Backfill is idempotent.\n-- Requires fee_amount (006b_extract_fee_amount.sql).\n\nINSERT INTO public.plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, fee_amount, status,\n due_date, payment_date, installment_number, payment_method_id, payment_method_type_id,\n bank_account_id, cash_session_id, card_brand, card_installments, notes, metadata)\nSELECT\n b.tenant_id, b.invoice_id, b.direction, 'payment', b.paid_amount, b.paid_amount,\n COALESCE(b.fee_amount, 0), 'paid',\n b.due_date, COALESCE(b.payment_date, b.updated_at::date, CURRENT_DATE), b.installment_number,\n b.payment_method_id, b.payment_method_type_id, b.bank_account_id, b.cash_session_id,\n b.card_brand, b.card_installments, b.notes,\n jsonb_build_object('backfilledFromBill', b.id)\nFROM public.plg_financial_movements b\nWHERE b.movement_kind = 'bill' AND b.paid_amount > 0\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_financial_movements p\n WHERE p.movement_kind = 'payment' AND p.metadata->>'backfilledFromBill' = b.id::text\n );\n\nUPDATE public.plg_financial_movements\nSET payment_date = NULL, bank_account_id = NULL, payment_method_id = NULL,\n payment_method_type_id = NULL, cash_session_id = NULL, card_brand = NULL, card_installments = NULL\nWHERE movement_kind = 'bill';\n";
|
|
12
|
-
export declare const
|
|
13
|
-
export declare const
|
|
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
|
-
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
|
|
17
|
-
export declare const
|
|
18
|
-
export declare const
|
|
19
|
-
export declare const
|
|
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
|
|
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
|
|
30
|
-
export declare const
|
|
31
|
-
export declare const
|
|
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";
|
|
1
|
+
export declare const MIGRATION_000_BASELINE = "-- ============================================================================\n-- plugins/plugin-financial/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.\n--\n-- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:\n-- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs\n-- prove the result still matches.\n--\n-- This replaced 48 migration files. They are kept, unapplied, in\n-- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no\n-- longer worth replaying.\n--\n-- Object order is fixed rather than dependency-sorted: schemas, types,\n-- sequences, tables, defaults, functions, constraints, views, indexes, foreign\n-- keys, triggers, row security, policies, grants. Dependencies BETWEEN units\n-- are carried by the order packages/db/chain.json declares.\n-- ============================================================================\n\n-- A LANGUAGE sql function binds its body at CREATE time, and 570 functions\n-- sorted by name are not sorted by who calls whom. This is what lets them load\n-- in any order; every body is still compiled on first call, so a genuinely\n-- broken reference surfaces then rather than never. Session-scoped.\nSET check_function_bodies = false;\n\n\n\n-- \u2500\u2500 default privileges: reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Cleared BEFORE anything is created, so every object below gets the ACL the\n-- dump describes rather than the image's defaults on top of it. Restored at\n-- the end of this file.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\n\n-- \u2500\u2500 table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nCREATE TABLE public.plg_financial_allocations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n payment_movement_id uuid NOT NULL,\n installment_id uuid NOT NULL,\n amount numeric(14,2) NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_by uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_financial_allocations_amount_check CHECK ((amount > (0)::numeric))\n);\n\nALTER TABLE ONLY public.plg_financial_allocations FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_invoice_installments (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n invoice_id uuid NOT NULL,\n number integer NOT NULL,\n due_date date NOT NULL,\n amount numeric(14,2) NOT NULL,\n cancelled_at timestamp with time zone,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_financial_invoice_installments_amount_check CHECK ((amount >= (0)::numeric)),\n CONSTRAINT plg_financial_invoice_installments_number_check CHECK ((number >= 1))\n);\n\nALTER TABLE ONLY public.plg_financial_invoice_installments FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_invoices (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n direction text NOT NULL,\n counterparty_person_id uuid,\n order_id uuid,\n number text,\n competence_date date DEFAULT CURRENT_DATE NOT NULL,\n issue_date date DEFAULT CURRENT_DATE NOT NULL,\n total numeric(14,2) NOT NULL,\n currency text DEFAULT 'BRL'::text NOT NULL,\n description text,\n notes text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n idempotency_key text,\n cancelled_at timestamp with time zone,\n cancel_reason text,\n created_by uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_financial_invoices_direction_check CHECK ((direction = ANY (ARRAY['receivable'::text, 'payable'::text]))),\n CONSTRAINT plg_financial_invoices_total_check CHECK ((total >= (0)::numeric))\n);\n\nALTER TABLE ONLY public.plg_financial_invoices FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_movements (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n invoice_id uuid,\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 DEFAULT 'pending'::text NOT NULL,\n due_date date NOT NULL,\n payment_date date,\n installment_number integer,\n payment_method_id uuid,\n bank_account_id uuid,\n cash_session_id uuid,\n card_brand text,\n card_installments integer,\n notes text,\n metadata jsonb DEFAULT '{}'::jsonb,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n fee_amount numeric(14,2) DEFAULT 0 NOT NULL,\n external_id text,\n external_source text,\n reconciled_at timestamp with time zone,\n matched_movement_id uuid,\n payment_method_type_id uuid,\n settles_movement_id uuid,\n unit_id uuid,\n owner_id uuid,\n financial_invoice_id uuid,\n financial_invoice_installment_id uuid,\n debit_account_id uuid,\n credit_account_id uuid,\n method text,\n nsu text,\n reversed_by_id uuid,\n reverses_id uuid,\n reason text,\n idempotency_key text,\n created_by uuid,\n CONSTRAINT plg_financial_movements_kind_check CHECK ((movement_kind = ANY (ARRAY['bill'::text, 'payment'::text, 'transfer'::text, 'commission'::text]))),\n CONSTRAINT plg_financial_movements_legs_check CHECK (((movement_kind = ANY (ARRAY['bill'::text, 'commission'::text])) OR (metadata ? 'legacy_mirror'::text) OR ((debit_account_id IS NOT NULL) AND (credit_account_id IS NOT NULL) AND (debit_account_id <> credit_account_id))))\n);\n\nALTER TABLE ONLY public.plg_financial_movements FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_accounts (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n kind text DEFAULT 'bank'::text NOT NULL,\n code text,\n name text NOT NULL,\n person_id uuid,\n account_type text,\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,\n is_active boolean DEFAULT true NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_by uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_financial_accounts_check CHECK (((kind = 'person'::text) = (person_id IS NOT NULL))),\n CONSTRAINT plg_financial_accounts_check1 CHECK (((kind <> 'system'::text) OR (code IS NOT NULL))),\n CONSTRAINT plg_financial_accounts_kind_check CHECK ((kind = ANY (ARRAY['bank'::text, 'cash_physical'::text, 'cash_session'::text, 'card_receivable_credit'::text, 'card_receivable_debit'::text, 'card_payable'::text, 'person'::text, 'system'::text])))\n);\n\nALTER TABLE ONLY public.plg_financial_accounts FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_bank_accounts (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n account_type text DEFAULT 'bank_account'::text NOT NULL,\n bank_name text,\n account_number text,\n agency_number text,\n current_balance numeric(14,2) DEFAULT 0 NOT NULL,\n initial_balance numeric(14,2) DEFAULT 0 NOT NULL,\n credit_limit numeric(14,2),\n due_day integer,\n closing_day integer,\n is_active boolean DEFAULT true NOT NULL,\n unit_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_financial_bank_accounts FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_card_brands (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_financial_card_brands FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_card_fee_configs (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n account_id uuid NOT NULL,\n card_brand_id uuid,\n brand text,\n min_installments integer DEFAULT 1 NOT NULL,\n max_installments integer DEFAULT 1 NOT NULL,\n fee_rate numeric(7,4) NOT NULL,\n settlement_days integer,\n is_active boolean DEFAULT true NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_by uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_financial_card_fee_configs_check CHECK ((min_installments <= max_installments)),\n CONSTRAINT plg_financial_card_fee_configs_fee_rate_check CHECK (((fee_rate >= (0)::numeric) AND (fee_rate <= (100)::numeric))),\n CONSTRAINT plg_financial_card_fee_configs_max_installments_check CHECK ((max_installments >= 1)),\n CONSTRAINT plg_financial_card_fee_configs_min_installments_check CHECK ((min_installments >= 1))\n);\n\nALTER TABLE ONLY public.plg_financial_card_fee_configs FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_cash_register_sessions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n bank_account_id uuid NOT NULL,\n status text DEFAULT 'open'::text NOT NULL,\n opened_at timestamp with time zone DEFAULT now() NOT NULL,\n opened_by_user_id uuid,\n opened_by_name text,\n opening_balance numeric(14,2) NOT NULL,\n closed_at timestamp with time zone,\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 timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n account_id uuid,\n user_id uuid NOT NULL,\n expected_cash numeric(14,2),\n counted_cash numeric(14,2),\n difference_cash numeric(14,2),\n expected_card numeric(14,2),\n counted_card numeric(14,2),\n difference_card numeric(14,2),\n opening_movement_id uuid,\n closing_movement_id uuid,\n difference_movement_id uuid,\n open_idempotency_key text,\n close_idempotency_key text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n CONSTRAINT plg_financial_cash_register_sessions_status_check CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text])))\n);\n\nALTER TABLE ONLY public.plg_financial_cash_register_sessions FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_cash_session_reviewers (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n session_id uuid NOT NULL,\n reviewer_id uuid NOT NULL,\n verdict text,\n notes text,\n reviewed_at timestamp with time zone,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_financial_cash_session_reviewers_verdict_check CHECK (((verdict IS NULL) OR (verdict = ANY (ARRAY['approved'::text, 'rejected'::text]))))\n);\n\nALTER TABLE ONLY public.plg_financial_cash_session_reviewers FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_chart_of_accounts (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n code text NOT NULL,\n name text NOT NULL,\n node_type text DEFAULT 'leaf'::text NOT NULL,\n parent_id uuid,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n account_type text,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_financial_chart_of_accounts FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_cost_centers (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n code text NOT NULL,\n name text NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_financial_cost_centers FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_invoice_items (\n item_id uuid NOT NULL,\n parent_kind text DEFAULT 'invoice'::text NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n chart_account_id uuid,\n cost_center_id uuid,\n tax_amount numeric(14,2) DEFAULT 0 NOT NULL,\n cfop text,\n ncm text,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_financial_invoice_items_parent_kind_check CHECK ((parent_kind = 'invoice'::text))\n);\n\nALTER TABLE ONLY public.plg_financial_invoice_items FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_nfe (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n model text DEFAULT 'nfe'::text NOT NULL,\n status text DEFAULT 'draft'::text NOT NULL,\n number text,\n series text,\n order_id uuid,\n contact_id uuid,\n contact_name text,\n total_amount numeric(14,2) DEFAULT 0 NOT NULL,\n issued_at timestamp with time zone,\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 DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_financial_nfe FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_payment_method_types (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n transaction_type text NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n allowed_account_types jsonb DEFAULT '[]'::jsonb,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_financial_payment_method_types FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_financial_payment_methods (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n payment_method_type_id uuid,\n is_active boolean DEFAULT true NOT NULL,\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'::text,\n service_filter_ids jsonb DEFAULT '[]'::jsonb,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_financial_payment_methods FORCE ROW LEVEL SECURITY;\n\n\n-- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nCREATE FUNCTION public.agent_financial_mark_payment_received(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_order_id uuid := nullif(p_payload ->> 'order_id', '')::uuid;\n v_method text := coalesce(nullif(p_payload ->> 'method', ''), 'cash');\n v_installments int := greatest(coalesce((p_payload ->> 'installments')::int, 1), 1);\n v_denial jsonb;\n v_order record;\n v_inst record;\n v_open numeric;\n v_paid numeric := 0;\n v_n int := 0;\nBEGIN\n IF v_order_id IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'order_id is required');\n END IF;\n\n SELECT o.id, o.status, o.total INTO v_order\n FROM public.orders o WHERE o.id = v_order_id AND o.tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown order for this tenant');\n END IF;\n IF v_order.status = 'cancelled' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'cannot receive payment on a cancelled order');\n END IF;\n\n v_denial := public.agent_guard(p_tenant_id, p_actor_user_id, 'financial.receivables', 'update');\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n -- Already fully settled? Asked of the balances, which are derived from the\n -- allocations \u2014 the one place the answer can come from.\n IF EXISTS (SELECT 1 FROM public.v_financial_installment_balances b WHERE b.order_id = v_order_id)\n AND NOT EXISTS (SELECT 1 FROM public.v_financial_installment_balances b\n WHERE b.order_id = v_order_id AND b.status NOT IN ('paid', 'cancelled')) THEN\n RETURN jsonb_build_object('ok', false, 'error', 'order is already fully paid');\n END IF;\n\n PERFORM set_config('request.jwt.claims',\n jsonb_build_object('sub', p_actor_user_id, 'role', 'authenticated')::text, true);\n PERFORM public.fn_invoice_from_order(v_order_id, current_date, NULL, v_installments, 'credit');\n\n FOR v_inst IN\n SELECT b.installment_id, b.balance\n FROM public.v_financial_installment_balances b\n WHERE b.order_id = v_order_id AND b.tenant_id = p_tenant_id AND b.balance > 0\n ORDER BY b.number NULLS FIRST\n LOOP\n v_open := round(v_inst.balance, 2);\n CONTINUE WHEN v_open <= 0;\n PERFORM public.financial_receive_payment(\n p_installment_id => v_inst.installment_id,\n p_amount => v_open,\n p_method => v_method,\n p_payment_date => current_date,\n p_notes => 'agent',\n p_metadata => jsonb_build_object('paidVia', 'agent', 'method', v_method));\n v_paid := v_paid + v_open;\n v_n := v_n + 1;\n END LOOP;\n\n RETURN jsonb_build_object('ok', true, 'order_id', v_order_id,\n 'installments_settled', v_n, 'amount', v_paid, 'method', v_method);\nEND $$;\n\nCREATE FUNCTION public.financial_cancel_invoice(p_invoice_id uuid, p_reason text, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 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\nCREATE FUNCTION public.financial_cancel_paid_item(p_order_item_id uuid, p_reason text, p_idempotency_key text DEFAULT NULL::text, p_amount numeric DEFAULT NULL::numeric) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_item public.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.items WHERE id = p_order_item_id AND tenant_id = v_tenant AND order_id IS NOT NULL;\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.cancelled_at IS NOT NULL 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.items\n SET cancelled_at = now(),\n cancel_reason = btrim(p_reason),\n metadata = coalesce(metadata, '{}'::jsonb) || jsonb_build_object('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', '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 FUNCTION public.financial_cash_session_close(p_counted_cash numeric, p_counted_card numeric, p_idempotency_key text DEFAULT NULL::text, p_notes text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 FUNCTION public.financial_cash_session_open(p_cofre_account_id uuid, p_opening_amount numeric, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 FUNCTION public.financial_create_invoice(p_direction text, p_total numeric, p_counterparty_person_id uuid DEFAULT NULL::uuid, p_order_id uuid DEFAULT NULL::uuid, p_unit_id uuid DEFAULT NULL::uuid, p_installments jsonb DEFAULT NULL::jsonb, p_competence_date date DEFAULT NULL::date, p_description text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 FUNCTION public.financial_create_payable(p_description text, p_total numeric, p_due_date date DEFAULT NULL::date, p_person_id uuid DEFAULT NULL::uuid, p_unit_id uuid DEFAULT NULL::uuid, p_chart_account_id uuid DEFAULT NULL::uuid, p_cost_center_id uuid DEFAULT NULL::uuid, p_installments jsonb DEFAULT NULL::jsonb, p_competence_date date DEFAULT NULL::date, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_res jsonb;\n v_invoice uuid;\n v_item uuid;\nBEGIN\n IF coalesce(btrim(p_description), '') = '' THEN\n RAISE EXCEPTION 'financial_create_payable: a payable with no product needs a description' USING ERRCODE = '22023';\n END IF;\n\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n\n v_res := public.financial_create_invoice(\n 'payable', round(coalesce(p_total, 0), 2), p_person_id, NULL, p_unit_id,\n coalesce(p_installments, jsonb_build_array(jsonb_build_object(\n 'number', 1, 'due_date', coalesce(p_due_date, current_date), 'amount', round(coalesce(p_total, 0), 2)))),\n p_competence_date, p_description, NULL,\n jsonb_build_object('source', 'financial_create_payable'), p_idempotency_key);\n\n v_invoice := (v_res ->> 'invoice_id')::uuid;\n IF coalesce((v_res ->> 'replayed')::boolean, false) THEN RETURN v_res; END IF;\n\n INSERT INTO public.items (tenant_id, unit_id, invoice_id, description, quantity, unit_price, total, metadata)\n VALUES (v_tenant, p_unit_id, v_invoice, p_description, 1,\n round(coalesce(p_total, 0), 2), round(coalesce(p_total, 0), 2),\n jsonb_build_object('source', 'financial_create_payable'))\n RETURNING id INTO v_item;\n\n INSERT INTO public.plg_financial_invoice_items (item_id, tenant_id, unit_id, chart_account_id, cost_center_id)\n VALUES (v_item, v_tenant, p_unit_id, p_chart_account_id, p_cost_center_id);\n\n RETURN v_res || jsonb_build_object('item_id', v_item);\nEND $$;\n\nCREATE FUNCTION public.financial_credit_customer(p_person_id uuid, p_amount numeric, p_to_account_id uuid, p_method text DEFAULT 'pix'::text, p_reason text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 $$;\n\nCREATE FUNCTION public.financial_invoice_from_lines(p_order_id uuid, p_lines jsonb, p_direction text DEFAULT 'receivable'::text, p_person_id uuid DEFAULT NULL::uuid, p_installments jsonb DEFAULT NULL::jsonb, p_competence_date date DEFAULT NULL::date, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_order public.orders%ROWTYPE;\n v_line jsonb;\n v_src public.items%ROWTYPE;\n v_qty numeric;\n v_total numeric := 0;\n v_res jsonb;\n v_invoice uuid;\n v_new uuid;\nBEGIN\n SELECT * INTO v_order FROM public.orders o WHERE o.id = p_order_id AND o.tenant_id = v_tenant;\n IF v_order.id IS NULL THEN\n RAISE EXCEPTION 'financial_invoice_from_lines: order % not found in this tenant', p_order_id USING ERRCODE = 'P0002';\n END IF;\n IF jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN\n RAISE EXCEPTION 'financial_invoice_from_lines: at least one line is required' USING ERRCODE = '22023';\n END IF;\n\n -- Same discipline as every other money RPC (031): two concurrent calls with\n -- the same key serialise, so the second finds the first one's invoice instead\n -- of raising a duplicate.\n PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);\n\n -- The total is the sum of what is actually being billed, computed here rather\n -- than trusted from the caller: a split bill whose parts do not add up to the\n -- order is the failure this whole shape exists to prevent.\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n SELECT * INTO v_src FROM public.items oi\n WHERE oi.id = (v_line ->> 'order_item_id')::uuid AND oi.order_id = p_order_id;\n IF v_src.id IS NULL THEN\n RAISE EXCEPTION 'financial_invoice_from_lines: line % is not a line of order %', v_line ->> 'order_item_id', p_order_id\n USING ERRCODE = '23503';\n END IF;\n v_qty := coalesce((v_line ->> 'quantity')::numeric, v_src.quantity);\n v_total := v_total + round(v_qty * coalesce(v_src.unit_price, 0) - coalesce(v_src.discount, 0) * (v_qty / nullif(v_src.quantity, 0)), 2);\n END LOOP;\n\n v_res := public.financial_create_invoice(\n p_direction, round(v_total, 2), coalesce(p_person_id, v_order.party_id), p_order_id, v_order.unit_id,\n p_installments, p_competence_date, NULL, NULL,\n jsonb_build_object('source', 'financial_invoice_from_lines'), p_idempotency_key);\n v_invoice := (v_res ->> 'invoice_id')::uuid;\n\n IF coalesce((v_res ->> 'replayed')::boolean, false) THEN RETURN v_res; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n SELECT * INTO v_src FROM public.items oi WHERE oi.id = (v_line ->> 'order_item_id')::uuid AND oi.order_id = p_order_id;\n v_qty := coalesce((v_line ->> 'quantity')::numeric, v_src.quantity);\n\n INSERT INTO public.items (tenant_id, unit_id, invoice_id, product_id, variant_id,\n name, description, quantity, unit_price, discount, total, sort_order, metadata)\n VALUES (v_src.tenant_id, v_src.unit_id, v_invoice, v_src.product_id, v_src.variant_id,\n v_src.name, v_src.description, v_qty, coalesce(v_src.unit_price, 0), 0,\n round(v_qty * coalesce(v_src.unit_price, 0), 2), coalesce(v_src.sort_order, 0),\n jsonb_build_object('billed_from', v_src.id))\n RETURNING id INTO v_new;\n\n INSERT INTO public.item_associations (tenant_id, from_item_id, to_item_id, quantity)\n VALUES (v_src.tenant_id, v_src.id, v_new, v_qty);\n\n IF (v_line ? 'chart_account_id') OR (v_line ? 'cost_center_id') OR (v_line ? 'cfop') THEN\n INSERT INTO public.plg_financial_invoice_items (item_id, tenant_id, unit_id, chart_account_id, cost_center_id, cfop)\n VALUES (v_new, v_src.tenant_id, v_src.unit_id,\n nullif(v_line ->> 'chart_account_id', '')::uuid,\n nullif(v_line ->> 'cost_center_id', '')::uuid,\n nullif(v_line ->> 'cfop', ''));\n END IF;\n END LOOP;\n\n RETURN v_res || jsonb_build_object('lines', jsonb_array_length(p_lines));\nEND $$;\n\nCREATE FUNCTION public.financial_receive_payment(p_installment_id uuid, p_amount numeric, p_account_id uuid DEFAULT NULL::uuid, p_method text DEFAULT NULL::text, p_cash_session_id uuid DEFAULT NULL::uuid, p_card_brand text DEFAULT NULL::text, p_card_installments integer DEFAULT NULL::integer, p_nsu text DEFAULT NULL::text, p_payment_date date DEFAULT NULL::date, p_fee_amount numeric DEFAULT NULL::numeric, p_notes text DEFAULT NULL::text, p_idempotency_key text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 FUNCTION public.financial_retire_bill_mirror(p_execute boolean DEFAULT false) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_writers text[];\n v_rows bigint;\n v_nonmirror bigint;\n v_deleted bigint := 0;\nBEGIN\n SELECT coalesce(array_agg(writer ORDER BY writer), '{}') INTO v_writers FROM public.v_bill_mirror_writers;\n SELECT count(*) INTO v_rows FROM public.plg_financial_movements WHERE movement_kind = 'bill';\n -- A `bill` row that is NOT a mirror would be real data, and there is none:\n -- 018 stamped every one it wrote. Counting them is what makes the DELETE safe\n -- rather than merely intended.\n SELECT count(*) INTO v_nonmirror FROM public.plg_financial_movements\n WHERE movement_kind = 'bill' AND coalesce((metadata ->> 'legacy_mirror')::boolean, false) IS NOT true;\n\n IF array_length(v_writers, 1) > 0 THEN\n RETURN jsonb_build_object('dropped', false, 'reason', 'writers remain', 'writers', to_jsonb(v_writers), 'rows', v_rows);\n END IF;\n IF v_nonmirror > 0 THEN\n RETURN jsonb_build_object('dropped', false, 'reason', 'some bill rows are not legacy mirrors', 'non_mirror_rows', v_nonmirror, 'rows', v_rows);\n END IF;\n IF NOT p_execute THEN\n RETURN jsonb_build_object('dropped', false, 'reason', 'dry run', 'rows', v_rows, 'would_delete', v_rows);\n END IF;\n\n DELETE FROM public.plg_financial_movements WHERE movement_kind = 'bill';\n GET DIAGNOSTICS v_deleted = ROW_COUNT;\n RETURN jsonb_build_object('dropped', true, 'rows_deleted', v_deleted);\nEND $$;\n\nCREATE FUNCTION public.financial_reverse_movement(p_movement_id uuid, p_reason text, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 FUNCTION public.financial_set_opening_balance(p_account_id uuid, p_amount numeric, p_date date DEFAULT NULL::date, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 FUNCTION public.financial_transfer(p_from_account_id uuid, p_to_account_id uuid, p_amount numeric, p_date date DEFAULT NULL::date, p_method text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_idempotency_key text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 FUNCTION public.fn_invoice_from_order(p_order_id uuid, p_due_date date DEFAULT NULL::date, p_status text DEFAULT NULL::text, p_installments integer DEFAULT 1, p_direction text DEFAULT 'credit'::text) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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$$;\n\nCREATE FUNCTION public.fn_invoice_from_order_internal(p_order_id uuid, p_due_date date DEFAULT NULL::date, p_status text DEFAULT NULL::text, p_installments integer DEFAULT 1, p_direction text DEFAULT 'credit'::text) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_invoice_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 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 -- A pool promoted before 016 carries legacy bill rows and no invoice; it is\n -- already invoiced and must not be invoiced twice.\n IF EXISTS (SELECT 1 FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill'\n AND status <> 'cancelled') 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.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_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', '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 -- No mirror. v_financial_bills gives the legacy shape to whoever still\n -- wants it, derived from exactly this row.\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\n\nCREATE FUNCTION public.fn_order_to_cash(p_kind text, p_lines jsonb, p_party_id uuid DEFAULT NULL::uuid, p_unit_id uuid DEFAULT NULL::uuid, p_status text DEFAULT NULL::text, p_channel text DEFAULT NULL::text, p_assignee_id uuid DEFAULT NULL::uuid, p_installments integer DEFAULT 1, p_due_date date DEFAULT NULL::date, p_direction text DEFAULT 'credit'::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_appointment_id uuid DEFAULT NULL::uuid, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := public.plg_financial_require_tenant();\n v_order uuid;\n v_invoice uuid;\nBEGIN\n PERFORM public.plg_financial_require_permission('financial.create', p_unit_id);\n\n -- One transaction. This is the whole point of the function existing: the two\n -- statements the browser used to make separately cannot half-happen here.\n v_order := public.order_create(\n p_kind => p_kind,\n p_lines => p_lines,\n p_party_id => p_party_id,\n p_unit_id => p_unit_id,\n p_status => p_status,\n p_channel => p_channel,\n p_assignee_id => p_assignee_id,\n p_metadata => coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'fn_order_to_cash'),\n p_idempotency_key => p_idempotency_key);\n\n PERFORM public.fn_invoice_from_order_internal(v_order, p_due_date, p_status, greatest(coalesce(p_installments, 1), 1), p_direction);\n\n SELECT f.id INTO v_invoice FROM public.plg_financial_invoices f\n WHERE f.order_id = v_order AND f.cancelled_at IS NULL\n ORDER BY f.created_at DESC LIMIT 1;\n\n -- An appointment that bills carries its Venda. Filling it here rather than in\n -- a second browser call is what stops the 5% from staying 5%.\n IF p_appointment_id IS NOT NULL THEN\n UPDATE public.appointments SET order_id = v_order\n WHERE id = p_appointment_id AND tenant_id = v_tenant AND order_id IS NULL;\n END IF;\n\n RETURN jsonb_build_object('order_id', v_order, 'invoice_id', v_invoice);\nEND $$;\n\nCREATE FUNCTION public.next_sequence_internal(p_tenant_id uuid, p_kind text) RETURNS bigint\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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\nCREATE FUNCTION public.plg_financial_account_kind_of(p_account_type text) RETURNS text\n LANGUAGE sql IMMUTABLE\n 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\nCREATE FUNCTION public.plg_financial_audit(p_tenant uuid, p_action text, p_entity_type text, p_entity_id text, p_metadata jsonb DEFAULT '{}'::jsonb) RETURNS void\n LANGUAGE sql SECURITY DEFINER\n SET search_path TO ''\n 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$$;\n\nCREATE FUNCTION public.plg_financial_backfill_ledger() RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 $$;\n\nCREATE FUNCTION public.plg_financial_claim_idempotency(p_tenant uuid, p_key text) RETURNS void\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.plg_financial_installment_paid(p_installment uuid) RETURNS numeric\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n 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$$;\n\nCREATE FUNCTION public.plg_financial_legacy_legs(p_tenant uuid, p_kind text, p_direction text, p_bank_account_id uuid, p_cash_session_id uuid, p_invoice_id uuid, p_metadata jsonb, OUT o_debit uuid, OUT o_credit uuid, OUT o_mirror boolean) RETURNS record\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.plg_financial_migrate_legacy_invoices() RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $_$\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 $_$;\n\nCREATE FUNCTION public.plg_financial_next_invoice_number(p_tenant_id uuid, p_direction text) RETURNS text\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 $$;\n\nCREATE FUNCTION public.plg_financial_open_session_of(p_tenant uuid, p_user uuid, p_unit uuid) RETURNS uuid\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n 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$$;\n\nCREATE FUNCTION public.plg_financial_person_account(p_tenant uuid, p_person uuid) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 $$;\n\nCREATE FUNCTION public.plg_financial_require_permission(p_perm text, p_unit uuid DEFAULT NULL::uuid) RETURNS void\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.plg_financial_require_tenant() RETURNS uuid\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.plg_financial_sync_mirror(p_installment uuid) RETURNS void\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.plg_financial_system_account(p_tenant uuid, p_code text) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\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 $$;\n\nCREATE FUNCTION public.trg_invoice_line_immutable() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_issued date; v_cancelled timestamptz; v_invoice uuid;\nBEGIN\n v_invoice := coalesce(NEW.invoice_id, OLD.invoice_id);\n IF v_invoice IS NULL THEN RETURN coalesce(NEW, OLD); END IF;\n SELECT f.issue_date, f.cancelled_at INTO v_issued, v_cancelled\n FROM public.plg_financial_invoices f WHERE f.id = v_invoice;\n IF v_issued IS NOT NULL AND v_cancelled IS NULL THEN\n RAISE EXCEPTION 'items: the lines of issued invoice % cannot be altered', v_invoice\n USING ERRCODE = '23514',\n HINT = 'A closed invoice is immutable (PRD 04). Raise a new invoice instead.';\n END IF;\n RETURN coalesce(NEW, OLD);\nEND $$;\n\nCREATE FUNCTION public.trg_plg_financial_allocations_check() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_allocations_sync() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_bank_accounts_forward() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_card_fee_configs_check() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_cash_sessions_owner() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_installments_inherit() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_invoice_items_inherit() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_tenant uuid; v_unit uuid;\nBEGIN\n SELECT i.tenant_id, i.unit_id INTO v_tenant, v_unit FROM public.items i WHERE i.id = NEW.item_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'plg_financial_invoice_items: line % not found', NEW.item_id USING ERRCODE = '23503';\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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_invoices_immutable() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n -- Not closed yet: a draft is a draft.\n IF OLD.issue_date IS NULL OR OLD.cancelled_at IS NOT NULL THEN RETURN NEW; END IF;\n\n -- The named exception: cancelling, and the bookkeeping that goes with it.\n IF NEW.cancelled_at IS NOT NULL AND OLD.cancelled_at IS NULL THEN RETURN NEW; END IF;\n\n IF NEW.total IS DISTINCT FROM OLD.total\n OR NEW.direction IS DISTINCT FROM OLD.direction\n OR NEW.counterparty_person_id IS DISTINCT FROM OLD.counterparty_person_id\n OR NEW.order_id IS DISTINCT FROM OLD.order_id\n OR NEW.number IS DISTINCT FROM OLD.number\n OR NEW.competence_date IS DISTINCT FROM OLD.competence_date\n OR NEW.issue_date IS DISTINCT FROM OLD.issue_date\n OR NEW.currency IS DISTINCT FROM OLD.currency THEN\n RAISE EXCEPTION 'plg_financial_invoices: invoice % is issued and cannot be altered', OLD.number\n USING ERRCODE = '23514',\n HINT = 'A closed invoice is immutable (PRD 04). Correct it with a new invoice, or cancel this one through financial_cancel_invoice.';\n END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION public.trg_plg_financial_movements_after_insert() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_movements_append_only() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\nCREATE FUNCTION public.trg_plg_financial_movements_before_insert() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n 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 $$;\n\n\n-- \u2500\u2500 constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nALTER TABLE ONLY public.plg_financial_accounts\n ADD CONSTRAINT plg_financial_accounts_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_accounts\n ADD CONSTRAINT plg_financial_accounts_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_allocations\n ADD CONSTRAINT plg_financial_allocations_payment_movement_id_installment_i_key UNIQUE (payment_movement_id, installment_id);\n\nALTER TABLE ONLY public.plg_financial_allocations\n ADD CONSTRAINT plg_financial_allocations_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_allocations\n ADD CONSTRAINT plg_financial_allocations_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_bank_accounts\n ADD CONSTRAINT plg_financial_bank_accounts_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_bank_accounts\n ADD CONSTRAINT plg_financial_bank_accounts_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_card_brands\n ADD CONSTRAINT plg_financial_card_brands_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_card_brands\n ADD CONSTRAINT plg_financial_card_brands_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_card_fee_configs\n ADD CONSTRAINT plg_financial_card_fee_configs_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_card_fee_configs\n ADD CONSTRAINT plg_financial_card_fee_configs_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_cash_register_sessions\n ADD CONSTRAINT plg_financial_cash_register_sessions_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_cash_register_sessions\n ADD CONSTRAINT plg_financial_cash_register_sessions_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_cash_session_reviewers\n ADD CONSTRAINT plg_financial_cash_session_reviewers_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_cash_session_reviewers\n ADD CONSTRAINT plg_financial_cash_session_reviewers_session_id_reviewer_id_key UNIQUE (session_id, reviewer_id);\n\nALTER TABLE ONLY public.plg_financial_cash_session_reviewers\n ADD CONSTRAINT plg_financial_cash_session_reviewers_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_chart_of_accounts\n ADD CONSTRAINT plg_financial_chart_of_accounts_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_chart_of_accounts\n ADD CONSTRAINT plg_financial_chart_of_accounts_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_cost_centers\n ADD CONSTRAINT plg_financial_cost_centers_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_cost_centers\n ADD CONSTRAINT plg_financial_cost_centers_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_invoice_installments\n ADD CONSTRAINT plg_financial_invoice_installments_invoice_id_number_key UNIQUE (invoice_id, number);\n\nALTER TABLE ONLY public.plg_financial_invoice_installments\n ADD CONSTRAINT plg_financial_invoice_installments_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_invoice_installments\n ADD CONSTRAINT plg_financial_invoice_installments_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_invoice_items\n ADD CONSTRAINT plg_financial_invoice_items_pkey PRIMARY KEY (item_id);\n\nALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_nfe\n ADD CONSTRAINT plg_financial_nfe_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_nfe\n ADD CONSTRAINT plg_financial_nfe_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_payment_method_types\n ADD CONSTRAINT plg_financial_payment_method_types_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_payment_method_types\n ADD CONSTRAINT plg_financial_payment_method_types_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_financial_payment_methods\n ADD CONSTRAINT plg_financial_payment_methods_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_financial_payment_methods\n ADD CONSTRAINT plg_financial_payment_methods_tenant_id_id_key UNIQUE (tenant_id, id);\n\n\n-- \u2500\u2500 view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nCREATE VIEW public.v_financial_installment_balances WITH (security_invoker='true') AS\n WITH paid AS (\n SELECT a.installment_id,\n 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'::text) AND (m.reversed_by_id IS NULL))\n GROUP BY a.installment_id\n )\n SELECT i.id AS installment_id,\n i.tenant_id,\n i.unit_id,\n i.invoice_id,\n t.number AS invoice_number,\n t.direction,\n t.counterparty_person_id,\n t.order_id,\n i.number,\n i.due_date,\n i.amount,\n (COALESCE(p.paid, (0)::numeric))::numeric(14,2) AS paid,\n (\n CASE\n WHEN ((i.cancelled_at IS NOT NULL) OR (t.cancelled_at IS NOT NULL)) THEN (0)::numeric\n ELSE GREATEST((i.amount - COALESCE(p.paid, (0)::numeric)), (0)::numeric)\n 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'::text\n WHEN (COALESCE(p.paid, (0)::numeric) >= i.amount) THEN 'paid'::text\n WHEN (COALESCE(p.paid, (0)::numeric) > (0)::numeric) THEN 'partial'::text\n WHEN (i.due_date < CURRENT_DATE) THEN 'overdue'::text\n ELSE 'pending'::text\n END AS status,\n i.cancelled_at,\n i.metadata,\n i.created_at,\n i.updated_at\n FROM ((public.plg_financial_invoice_installments i\n JOIN public.plg_financial_invoices t ON ((t.id = i.invoice_id)))\n LEFT JOIN paid p ON ((p.installment_id = i.id)));\n\nCREATE VIEW public.v_financial_invoice_balances WITH (security_invoker='true') AS\n WITH ib AS (\n SELECT v_financial_installment_balances.invoice_id,\n count(*) FILTER (WHERE (v_financial_installment_balances.status <> 'cancelled'::text)) AS installment_count,\n sum(v_financial_installment_balances.amount) FILTER (WHERE (v_financial_installment_balances.status <> 'cancelled'::text)) AS billed,\n sum(v_financial_installment_balances.paid) AS paid,\n bool_or((v_financial_installment_balances.status = 'overdue'::text)) AS has_overdue,\n min(v_financial_installment_balances.due_date) FILTER (WHERE (v_financial_installment_balances.status = ANY (ARRAY['pending'::text, 'partial'::text, 'overdue'::text]))) AS next_due_date,\n max(v_financial_installment_balances.due_date) FILTER (WHERE (v_financial_installment_balances.status <> 'cancelled'::text)) AS last_due_date\n FROM public.v_financial_installment_balances\n GROUP BY v_financial_installment_balances.invoice_id\n )\n SELECT t.id,\n t.tenant_id,\n t.unit_id,\n t.direction,\n t.number,\n t.counterparty_person_id,\n p.name AS counterparty_name,\n t.order_id,\n t.competence_date,\n t.issue_date,\n t.total,\n (COALESCE(ib.billed, (0)::numeric))::numeric(14,2) AS billed,\n (COALESCE(ib.paid, (0)::numeric))::numeric(14,2) AS paid,\n (\n CASE\n WHEN (t.cancelled_at IS NOT NULL) THEN (0)::numeric\n ELSE GREATEST((COALESCE(ib.billed, t.total, (0)::numeric) - COALESCE(ib.paid, (0)::numeric)), (0)::numeric)\n END)::numeric(14,2) AS balance,\n CASE\n WHEN (t.cancelled_at IS NOT NULL) THEN 'cancelled'::text\n WHEN (COALESCE(ib.billed, t.total, (0)::numeric) = (0)::numeric) THEN 'paid'::text\n WHEN (COALESCE(ib.paid, (0)::numeric) >= COALESCE(ib.billed, t.total, (0)::numeric)) THEN 'paid'::text\n WHEN (COALESCE(ib.paid, (0)::numeric) > (0)::numeric) THEN 'partial'::text\n WHEN COALESCE(ib.has_overdue, false) THEN 'overdue'::text\n ELSE 'open'::text\n END AS status,\n (COALESCE(ib.installment_count, (0)::bigint))::integer AS installment_count,\n ib.next_due_date,\n ib.last_due_date,\n t.description,\n t.notes,\n t.metadata,\n t.idempotency_key,\n t.cancelled_at,\n t.cancel_reason,\n t.created_by,\n t.created_at,\n t.updated_at\n FROM ((public.plg_financial_invoices t\n LEFT JOIN ib ON ((ib.invoice_id = t.id)))\n LEFT JOIN public.people p ON ((p.id = t.counterparty_person_id)));\n\nCREATE VIEW public.v_invoice_balances WITH (security_invoker='true') AS\n WITH 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'::text)) AS all_cancelled,\n bool_or((t.status = 'overdue'::text)) 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 )\n SELECT o.id AS invoice_id,\n o.tenant_id,\n o.kind,\n tb.reference_number,\n tb.party_id,\n CASE\n WHEN (tb.flow = 'payable'::text) THEN 'debit'::text\n ELSE 'credit'::text\n END AS direction,\n tb.flow,\n COALESCE(tb.amount, (0)::numeric) AS amount,\n COALESCE(tb.paid, (0)::numeric) AS paid,\n CASE\n WHEN ((o.status = 'cancelled'::text) OR (o.stage = 'cancelled'::text) OR tb.all_cancelled) THEN (0)::numeric\n ELSE COALESCE(tb.balance, (0)::numeric)\n END AS balance,\n CASE\n WHEN ((o.status = 'cancelled'::text) OR (o.stage = 'cancelled'::text) OR tb.all_cancelled) THEN 'cancelled'::text\n WHEN (COALESCE(tb.amount, (0)::numeric) = (0)::numeric) THEN 'open'::text\n WHEN (COALESCE(tb.paid, (0)::numeric) >= COALESCE(tb.amount, (0)::numeric)) THEN 'paid'::text\n WHEN (COALESCE(tb.paid, (0)::numeric) > (0)::numeric) THEN 'partial'::text\n WHEN COALESCE(tb.has_overdue, false) THEN 'overdue'::text\n ELSE 'open'::text\n END AS status,\n tb.created_at,\n tb.updated_at\n FROM (tb\n JOIN public.orders o ON ((o.id = tb.order_id)));\n\nCREATE VIEW public.plg_financial_rep_receivables WITH (security_invoker='true') AS\n SELECT 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 CASE\n WHEN (b.status = ANY (ARRAY['open'::text, 'overdue'::text])) THEN b.balance\n ELSE (0)::numeric\n END AS open_balance,\n CASE\n WHEN (b.status = 'overdue'::text) THEN b.balance\n ELSE (0)::numeric\n END AS overdue_balance,\n CASE\n WHEN (b.status = 'paid'::text) THEN b.amount\n ELSE (0)::numeric\n END AS settled_amount,\n CASE\n WHEN (b.status = 'overdue'::text) THEN 1\n ELSE 0\n END AS is_overdue,\n CASE\n WHEN (b.status = 'paid'::text) THEN 1\n ELSE 0\n 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'::text);\n\nCREATE VIEW public.v_bill_mirror_writers WITH (security_invoker='true') AS\n SELECT ((quote_ident((n.nspname)::text) || '.'::text) || (p.proname)::text) AS writer\n FROM (pg_proc p\n JOIN pg_namespace n ON ((n.oid = p.pronamespace)))\n WHERE ((n.nspname = ANY (ARRAY['public'::name, 'app'::name, 'migration'::name])) AND (p.prosrc ~ 'INSERT\\s+INTO\\s+public\\.plg_financial_movements'::text) AND (p.prosrc ~ '''bill'''::text));\n\nCREATE VIEW public.v_bill_mirror_pending WITH (security_invoker='true') AS\n SELECT ( SELECT count(*) AS count\n FROM public.plg_financial_movements\n WHERE (plg_financial_movements.movement_kind = 'bill'::text)) AS rows_remaining,\n ( SELECT COALESCE(array_agg(w.writer ORDER BY w.writer), '{}'::text[]) AS \"coalesce\"\n FROM public.v_bill_mirror_writers w) AS writers_remaining;\n\nCREATE VIEW public.v_bill_mirror_readers WITH (security_invoker='true') AS\n SELECT 'function'::text AS reader_kind,\n ((quote_ident((n.nspname)::text) || '.'::text) || (p.proname)::text) AS reader\n FROM (pg_proc p\n JOIN pg_namespace n ON ((n.oid = p.pronamespace)))\n WHERE ((n.nspname = ANY (ARRAY['public'::name, 'app'::name])) AND (p.prosrc ~ 'movement_kind\\s*=\\s*''bill'''::text) AND (p.proname <> 'financial_retire_bill_mirror'::name))\nUNION ALL\n SELECT 'view'::text AS reader_kind,\n ((quote_ident((n.nspname)::text) || '.'::text) || (c.relname)::text) AS reader\n FROM (pg_class c\n JOIN pg_namespace n ON ((n.oid = c.relnamespace)))\n WHERE ((c.relkind = ANY (ARRAY['v'::\"char\", 'm'::\"char\"])) AND (n.nspname = 'public'::name) AND (c.relname <> ALL (ARRAY['v_bill_mirror_readers'::name, 'v_bill_mirror_writers'::name, 'v_bill_mirror_pending'::name])) AND (pg_get_viewdef(c.oid, true) ~ 'movement_kind\\s*=\\s*''bill'''::text));\n\nCREATE VIEW public.v_financial_account_balances WITH (security_invoker='true') AS\n WITH d AS (\n SELECT m.debit_account_id AS account_id,\n sum(m.amount) AS total,\n count(*) AS n\n FROM public.plg_financial_movements m\n WHERE ((m.status <> 'cancelled'::text) AND (m.debit_account_id IS NOT NULL) AND (m.movement_kind = ANY (ARRAY['payment'::text, 'transfer'::text])))\n GROUP BY m.debit_account_id\n ), c AS (\n SELECT m.credit_account_id AS account_id,\n sum(m.amount) AS total,\n count(*) AS n\n FROM public.plg_financial_movements m\n WHERE ((m.status <> 'cancelled'::text) AND (m.credit_account_id IS NOT NULL) AND (m.movement_kind = ANY (ARRAY['payment'::text, 'transfer'::text])))\n GROUP BY m.credit_account_id\n )\n SELECT a.id AS account_id,\n a.tenant_id,\n a.unit_id,\n a.kind,\n a.code,\n a.name,\n a.person_id,\n a.is_active,\n a.account_type,\n a.bank_name,\n a.agency_number,\n a.account_number,\n a.metadata,\n (COALESCE(d.total, (0)::numeric))::numeric(14,2) AS debit_total,\n (COALESCE(c.total, (0)::numeric))::numeric(14,2) AS credit_total,\n ((COALESCE(c.total, (0)::numeric) - COALESCE(d.total, (0)::numeric)))::numeric(14,2) AS net_credit,\n (\n CASE\n WHEN (a.kind = ANY (ARRAY['bank'::text, 'cash_physical'::text, 'cash_session'::text, 'card_receivable_credit'::text, 'card_receivable_debit'::text])) THEN (COALESCE(d.total, (0)::numeric) - COALESCE(c.total, (0)::numeric))\n ELSE (COALESCE(c.total, (0)::numeric) - COALESCE(d.total, (0)::numeric))\n END)::numeric(14,2) AS balance,\n ((COALESCE(d.n, (0)::bigint) + COALESCE(c.n, (0)::bigint)))::integer AS movement_count,\n a.created_at,\n a.updated_at\n FROM ((public.plg_financial_accounts a\n LEFT JOIN d ON ((d.account_id = a.id)))\n LEFT JOIN c ON ((c.account_id = a.id)));\n\nCREATE VIEW public.v_financial_card_receivables WITH (security_invoker='true') AS\n WITH pay AS (\n SELECT m.id AS movement_id,\n m.tenant_id,\n m.unit_id,\n m.financial_invoice_id,\n m.financial_invoice_installment_id,\n m.cash_session_id,\n m.debit_account_id AS account_id,\n a.kind AS account_kind,\n a.name AS account_name,\n a.settlement_days AS account_settlement_days,\n m.amount AS gross,\n m.payment_date,\n m.card_brand,\n GREATEST(COALESCE(m.card_installments, 1), 1) AS card_installments,\n m.nsu,\n 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'::text) AND (m.status <> 'cancelled'::text) AND (m.reversed_by_id IS NULL) AND (a.kind = ANY (ARRAY['card_receivable_credit'::text, 'card_receivable_debit'::text])))\n ), cfg AS (\n SELECT p.movement_id,\n c_1.id AS fee_config_id,\n c_1.fee_rate,\n c_1.settlement_days,\n row_number() OVER (PARTITION BY p.movement_id ORDER BY (c_1.brand IS NOT NULL) DESC, (c_1.max_installments - c_1.min_installments), c_1.created_at DESC) AS rn\n FROM (pay p\n JOIN public.plg_financial_card_fee_configs c_1 ON (((c_1.account_id = p.account_id) AND c_1.is_active AND ((c_1.brand IS NULL) OR (lower(c_1.brand) = lower(COALESCE(p.card_brand, ''::text)))) AND ((p.card_installments >= c_1.min_installments) AND (p.card_installments <= c_1.max_installments)))))\n ), k AS (\n SELECT p.movement_id,\n p.tenant_id,\n p.unit_id,\n p.financial_invoice_id,\n p.financial_invoice_installment_id,\n p.cash_session_id,\n p.account_id,\n p.account_kind,\n p.account_name,\n p.account_settlement_days,\n p.gross,\n p.payment_date,\n p.card_brand,\n p.card_installments,\n p.nsu,\n p.method,\n generate_series(1, p.card_installments) AS k\n FROM pay p\n )\n SELECT k.movement_id,\n k.tenant_id,\n k.unit_id,\n k.financial_invoice_id,\n k.financial_invoice_installment_id,\n k.cash_session_id,\n k.account_id,\n k.account_kind,\n k.account_name,\n k.card_brand,\n k.card_installments,\n k.nsu,\n k.method,\n k.payment_date,\n k.k AS installment_k,\n k.gross AS gross_total,\n (\n CASE\n WHEN (k.k < k.card_installments) THEN round((k.gross / (k.card_installments)::numeric), 2)\n ELSE round((k.gross - (round((k.gross / (k.card_installments)::numeric), 2) * ((k.card_installments - 1))::numeric)), 2)\n END)::numeric(14,2) AS gross,\n c.fee_config_id,\n c.fee_rate,\n (round(((\n CASE\n WHEN (k.k < k.card_installments) THEN round((k.gross / (k.card_installments)::numeric), 2)\n ELSE round((k.gross - (round((k.gross / (k.card_installments)::numeric), 2) * ((k.card_installments - 1))::numeric)), 2)\n END * COALESCE(c.fee_rate, (0)::numeric)) / (100)::numeric), 2))::numeric(14,2) AS fee,\n (round((\n CASE\n WHEN (k.k < k.card_installments) THEN round((k.gross / (k.card_installments)::numeric), 2)\n ELSE round((k.gross - (round((k.gross / (k.card_installments)::numeric), 2) * ((k.card_installments - 1))::numeric)), 2)\n END * ((1)::numeric - (COALESCE(c.fee_rate, (0)::numeric) / (100)::numeric))), 2))::numeric(14,2) AS net,\n COALESCE(c.settlement_days, k.account_settlement_days,\n CASE\n WHEN (k.account_kind = 'card_receivable_debit'::text) THEN 1\n ELSE 30\n END) AS settlement_days,\n (k.payment_date + (COALESCE(c.settlement_days, k.account_settlement_days,\n CASE\n WHEN (k.account_kind = 'card_receivable_debit'::text) THEN 1\n ELSE 30\n END) + (30 * (k.k - 1)))) AS settlement_date,\n (c.fee_config_id IS NULL) AS missing_fee_config\n FROM (k\n LEFT JOIN cfg c ON (((c.movement_id = k.movement_id) AND (c.rn = 1))));\n\nCREATE VIEW public.v_financial_customer_credit WITH (security_invoker='true') AS\n SELECT b.tenant_id,\n b.person_id,\n b.account_id,\n p.name AS person_name,\n b.credit_total AS credited,\n b.debit_total AS consumed,\n b.balance AS credit,\n b.movement_count,\n 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'::text);\n\nCREATE VIEW public.v_financial_invoice_lines WITH (security_invoker='true') AS\n SELECT i.id AS item_id,\n i.tenant_id,\n i.unit_id,\n i.invoice_id,\n f.number AS invoice_number,\n f.direction,\n f.issue_date,\n f.cancelled_at,\n i.product_id,\n i.name,\n i.description,\n i.quantity,\n i.unit_price,\n i.discount,\n i.total,\n x.chart_account_id,\n x.cost_center_id,\n x.tax_amount,\n x.cfop,\n x.ncm,\n a.from_item_id AS billed_from_order_item_id,\n a.quantity AS billed_quantity\n FROM (((public.items i\n JOIN public.plg_financial_invoices f ON ((f.id = i.invoice_id)))\n LEFT JOIN public.plg_financial_invoice_items x ON ((x.item_id = i.id)))\n LEFT JOIN public.item_associations a ON ((a.to_item_id = i.id)))\n WHERE (i.invoice_id IS NOT NULL);\n\nCREATE VIEW public.v_financial_ledger WITH (security_invoker='true') AS\n SELECT i.installment_id AS id,\n i.tenant_id,\n i.unit_id,\n i.invoice_id,\n i.installment_id AS invoice_installment_id,\n i.order_id,\n CASE\n WHEN (i.direction = 'payable'::text) THEN 'debit'::text\n ELSE 'credit'::text\n END AS direction,\n 'bill'::text AS movement_kind,\n i.amount,\n i.paid AS paid_amount,\n i.status,\n i.due_date,\n NULL::date AS payment_date,\n i.number AS installment_number,\n NULL::uuid AS payment_method_id,\n NULL::uuid AS payment_method_type_id,\n NULL::uuid AS bank_account_id,\n NULL::uuid AS cash_session_id,\n NULL::text AS card_brand,\n NULL::integer AS card_installments,\n (0)::numeric(14,2) AS fee_amount,\n NULL::uuid AS debit_account_id,\n NULL::uuid AS credit_account_id,\n NULL::text AS method,\n NULL::text AS nsu,\n NULL::text AS notes,\n i.metadata,\n NULL::text AS external_source,\n NULL::text AS external_id,\n NULL::timestamp with time zone AS reconciled_at,\n NULL::uuid AS matched_movement_id,\n NULL::uuid AS reversed_by_id,\n NULL::uuid AS reverses_id,\n NULL::text AS reason,\n 'installment'::text AS entry_kind,\n i.created_at,\n i.updated_at\n FROM public.v_financial_installment_balances i\nUNION ALL\n SELECT m.id,\n m.tenant_id,\n m.unit_id,\n m.financial_invoice_id AS invoice_id,\n m.financial_invoice_installment_id AS invoice_installment_id,\n m.invoice_id AS order_id,\n m.direction,\n m.movement_kind,\n m.amount,\n m.paid_amount,\n m.status,\n m.due_date,\n m.payment_date,\n m.installment_number,\n m.payment_method_id,\n m.payment_method_type_id,\n COALESCE(m.bank_account_id,\n CASE\n WHEN (m.direction = 'credit'::text) THEN m.debit_account_id\n ELSE m.credit_account_id\n END) AS bank_account_id,\n m.cash_session_id,\n m.card_brand,\n m.card_installments,\n (COALESCE(m.fee_amount, (0)::numeric))::numeric(14,2) AS fee_amount,\n m.debit_account_id,\n m.credit_account_id,\n m.method,\n m.nsu,\n m.notes,\n m.metadata,\n m.external_source,\n m.external_id,\n m.reconciled_at,\n m.matched_movement_id,\n m.reversed_by_id,\n m.reverses_id,\n m.reason,\n 'movement'::text AS entry_kind,\n m.created_at,\n m.updated_at\n FROM public.plg_financial_movements m\n WHERE ((NOT ((m.movement_kind = ANY (ARRAY['bill'::text, 'commission'::text])) AND (m.financial_invoice_installment_id IS NOT NULL))) AND (NOT ((m.movement_kind = 'transfer'::text) AND (m.metadata ? 'legacy_mirror'::text))));\n\nCREATE VIEW public.v_financial_person_payments WITH (security_invoker='true') AS\n SELECT 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'::text) AS currency,\n COALESCE(pm.name, l.method) AS payment_method,\n l.status,\n COALESCE((l.payment_date)::timestamp with time zone, 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 LEFT JOIN public.plg_financial_payment_methods pm ON ((pm.id = l.payment_method_id)))\n WHERE ((l.entry_kind = 'movement'::text) AND (l.movement_kind <> 'bill'::text) AND (COALESCE(inv.counterparty_person_id, o.party_id) IS NOT NULL));\n\nCREATE VIEW public.v_order_billing WITH (security_invoker='true') AS\n SELECT o.id AS order_id,\n o.tenant_id,\n o.unit_id,\n o.total AS order_total,\n COALESCE(sum(f.total) FILTER (WHERE (f.cancelled_at IS NULL)), (0)::numeric) AS invoiced_total,\n (o.total - COALESCE(sum(f.total) FILTER (WHERE (f.cancelled_at IS NULL)), (0)::numeric)) AS uninvoiced_total,\n count(f.id) FILTER (WHERE (f.cancelled_at IS NULL)) AS invoices\n FROM (public.orders o\n LEFT JOIN public.plg_financial_invoices f ON ((f.order_id = o.id)))\n GROUP BY o.id, o.tenant_id, o.unit_id, o.total;\n\nCREATE VIEW public.v_order_payment_status WITH (security_invoker='true') AS\n SELECT o.id AS order_id,\n o.tenant_id,\n o.unit_id,\n o.status AS order_status,\n o.reference_number,\n (COALESCE(sum(b.amount), (0)::numeric))::numeric(14,2) AS total_amount,\n (COALESCE(sum(b.paid), (0)::numeric))::numeric(14,2) AS paid_amount,\n (COALESCE(sum(b.balance), (0)::numeric))::numeric(14,2) AS balance,\n CASE\n WHEN (count(b.installment_id) = 0) THEN 'none'::text\n WHEN bool_and((b.status = 'cancelled'::text)) THEN 'cancelled'::text\n WHEN bool_and((b.status = ANY (ARRAY['paid'::text, 'cancelled'::text]))) THEN 'paid'::text\n WHEN bool_or((b.status = 'overdue'::text)) THEN 'overdue'::text\n WHEN bool_or((b.status = ANY (ARRAY['paid'::text, 'partial'::text]))) THEN 'partial'::text\n ELSE 'pending'::text\n END AS status\n FROM (public.orders o\n LEFT JOIN public.v_financial_installment_balances b ON ((b.order_id = o.id)))\n GROUP BY o.id, o.tenant_id, o.unit_id, o.status, o.reference_number;\n\nCREATE VIEW public.v_orders_sem_invoice WITH (security_invoker='true') AS\n SELECT id AS order_id,\n tenant_id,\n unit_id,\n kind,\n channel,\n status,\n total,\n created_at\n FROM public.orders o\n WHERE ((kind = ANY (ARRAY['sale'::text, 'purchase'::text, 'course'::text])) AND (status = ANY (ARRAY['completed'::text, 'invoiced'::text, 'paid'::text])) AND (NOT (EXISTS ( SELECT 1\n FROM public.plg_financial_invoices f\n WHERE ((f.order_id = o.id) AND (f.cancelled_at IS NULL))))));\n\n\n-- \u2500\u2500 index \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nCREATE INDEX idx_plg_financial_bank_accounts_tenant ON public.plg_financial_bank_accounts USING btree (tenant_id);\n\nCREATE INDEX idx_plg_financial_card_brands_tenant ON public.plg_financial_card_brands USING btree (tenant_id);\n\nCREATE INDEX idx_plg_financial_cash_register_sessions_tenant ON public.plg_financial_cash_register_sessions USING btree (tenant_id);\n\nCREATE INDEX idx_plg_financial_chart_of_accounts_parent ON public.plg_financial_chart_of_accounts USING btree (parent_id);\n\nCREATE INDEX idx_plg_financial_chart_of_accounts_tenant ON public.plg_financial_chart_of_accounts USING btree (tenant_id);\n\nCREATE INDEX idx_plg_financial_cost_centers_tenant ON public.plg_financial_cost_centers USING btree (tenant_id);\n\nCREATE INDEX idx_plg_financial_movements_bank ON public.plg_financial_movements USING btree (bank_account_id);\n\nCREATE INDEX idx_plg_financial_movements_due ON public.plg_financial_movements USING btree (tenant_id, due_date);\n\nCREATE INDEX idx_plg_financial_movements_invoice ON public.plg_financial_movements USING btree (invoice_id);\n\nCREATE INDEX idx_plg_financial_movements_settles ON public.plg_financial_movements USING btree (settles_movement_id) WHERE (settles_movement_id IS NOT NULL);\n\nCREATE INDEX idx_plg_financial_movements_statement ON public.plg_financial_movements USING btree (bank_account_id, payment_date) WHERE (status = ANY (ARRAY['paid'::text, 'partial'::text]));\n\nCREATE INDEX idx_plg_financial_movements_tenant ON public.plg_financial_movements USING btree (tenant_id);\n\nCREATE INDEX idx_plg_financial_movements_unreconciled ON public.plg_financial_movements USING btree (tenant_id, payment_date) WHERE ((external_source IS NOT NULL) AND (reconciled_at IS NULL));\n\nCREATE INDEX idx_plg_financial_nfe_order ON public.plg_financial_nfe USING btree (order_id);\n\nCREATE INDEX idx_plg_financial_nfe_tenant ON public.plg_financial_nfe USING btree (tenant_id);\n\nCREATE INDEX idx_plg_financial_payment_method_types_tenant ON public.plg_financial_payment_method_types USING btree (tenant_id);\n\nCREATE INDEX idx_plg_financial_payment_methods_tenant ON public.plg_financial_payment_methods USING btree (tenant_id);\n\nCREATE UNIQUE INDEX plg_financial_accounts_code_uq ON public.plg_financial_accounts USING btree (tenant_id, code) WHERE (code IS NOT NULL);\n\nCREATE INDEX plg_financial_accounts_kind_idx ON public.plg_financial_accounts USING btree (tenant_id, kind) WHERE is_active;\n\nCREATE UNIQUE INDEX plg_financial_accounts_person_uq ON public.plg_financial_accounts USING btree (tenant_id, person_id) WHERE (person_id IS NOT NULL);\n\nCREATE INDEX plg_financial_accounts_tenant_owner_idx ON public.plg_financial_accounts USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_accounts_tenant_unit_idx ON public.plg_financial_accounts USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_allocations_installment_idx ON public.plg_financial_allocations USING btree (installment_id);\n\nCREATE INDEX plg_financial_allocations_payment_idx ON public.plg_financial_allocations USING btree (payment_movement_id);\n\nCREATE INDEX plg_financial_allocations_tenant_owner_idx ON public.plg_financial_allocations USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_allocations_tenant_unit_idx ON public.plg_financial_allocations USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_bank_accounts_tenant_owner_idx ON public.plg_financial_bank_accounts USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_bank_accounts_tenant_unit_idx ON public.plg_financial_bank_accounts USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_card_brands_tenant_owner_idx ON public.plg_financial_card_brands USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_card_brands_tenant_unit_idx ON public.plg_financial_card_brands USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_card_fee_configs_account_idx ON public.plg_financial_card_fee_configs USING btree (account_id) WHERE is_active;\n\nCREATE INDEX plg_financial_card_fee_configs_tenant_owner_idx ON public.plg_financial_card_fee_configs USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_card_fee_configs_tenant_unit_idx ON public.plg_financial_card_fee_configs USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_cash_register_sessions_tenant_owner_idx ON public.plg_financial_cash_register_sessions USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_cash_register_sessions_tenant_unit_idx ON public.plg_financial_cash_register_sessions USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_cash_session_reviewers_tenant_owner_idx ON public.plg_financial_cash_session_reviewers USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_cash_session_reviewers_tenant_unit_idx ON public.plg_financial_cash_session_reviewers USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_financial_cash_sessions_one_open_uq ON public.plg_financial_cash_register_sessions USING btree (tenant_id, user_id, unit_id) WHERE (status = 'open'::text);\n\nCREATE UNIQUE INDEX plg_financial_cash_sessions_open_key_uq ON public.plg_financial_cash_register_sessions USING btree (tenant_id, open_idempotency_key) WHERE (open_idempotency_key IS NOT NULL);\n\nCREATE INDEX plg_financial_cash_sessions_user_idx ON public.plg_financial_cash_register_sessions USING btree (tenant_id, user_id, status);\n\nCREATE INDEX plg_financial_chart_of_accounts_tenant_owner_idx ON public.plg_financial_chart_of_accounts USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_chart_of_accounts_tenant_unit_idx ON public.plg_financial_chart_of_accounts USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_cost_centers_tenant_owner_idx ON public.plg_financial_cost_centers USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_cost_centers_tenant_unit_idx ON public.plg_financial_cost_centers USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_invoice_installments_due_idx ON public.plg_financial_invoice_installments USING btree (tenant_id, due_date);\n\nCREATE INDEX plg_financial_invoice_installments_invoice_idx ON public.plg_financial_invoice_installments USING btree (invoice_id, number);\n\nCREATE INDEX plg_financial_invoice_installments_tenant_owner_idx ON public.plg_financial_invoice_installments USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_invoice_installments_tenant_unit_idx ON public.plg_financial_invoice_installments USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_invoice_items_account_idx ON public.plg_financial_invoice_items USING btree (tenant_id, chart_account_id) WHERE (chart_account_id IS NOT NULL);\n\nCREATE INDEX plg_financial_invoice_items_cost_center_idx ON public.plg_financial_invoice_items USING btree (tenant_id, cost_center_id) WHERE (cost_center_id IS NOT NULL);\n\nCREATE INDEX plg_financial_invoices_competence_idx ON public.plg_financial_invoices USING btree (tenant_id, direction, competence_date);\n\nCREATE UNIQUE INDEX plg_financial_invoices_idempotency_uq ON public.plg_financial_invoices USING btree (tenant_id, idempotency_key) WHERE (idempotency_key IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_financial_invoices_number_uq ON public.plg_financial_invoices USING btree (tenant_id, number) WHERE (number IS NOT NULL);\n\nCREATE INDEX plg_financial_invoices_order_idx ON public.plg_financial_invoices USING btree (tenant_id, order_id) WHERE (order_id IS NOT NULL);\n\nCREATE INDEX plg_financial_invoices_person_idx ON public.plg_financial_invoices USING btree (tenant_id, counterparty_person_id) WHERE (counterparty_person_id IS NOT NULL);\n\nCREATE INDEX plg_financial_invoices_tenant_owner_idx ON public.plg_financial_invoices USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_invoices_tenant_unit_idx ON public.plg_financial_invoices USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_movements_credit_idx ON public.plg_financial_movements USING btree (credit_account_id) WHERE (credit_account_id IS NOT NULL);\n\nCREATE INDEX plg_financial_movements_debit_idx ON public.plg_financial_movements USING btree (debit_account_id) WHERE (debit_account_id IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_financial_movements_idempotency_uq ON public.plg_financial_movements USING btree (tenant_id, idempotency_key) WHERE (idempotency_key IS NOT NULL);\n\nCREATE INDEX plg_financial_movements_installment_idx ON public.plg_financial_movements USING btree (financial_invoice_installment_id) WHERE (financial_invoice_installment_id IS NOT NULL);\n\nCREATE INDEX plg_financial_movements_invoice_idx ON public.plg_financial_movements USING btree (financial_invoice_id) WHERE (financial_invoice_id IS NOT NULL);\n\nCREATE INDEX plg_financial_movements_tenant_owner_idx ON public.plg_financial_movements USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_movements_tenant_unit_idx ON public.plg_financial_movements USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_nfe_tenant_owner_idx ON public.plg_financial_nfe USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_nfe_tenant_unit_idx ON public.plg_financial_nfe USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_payment_method_types_tenant_owner_idx ON public.plg_financial_payment_method_types USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_payment_method_types_tenant_unit_idx ON public.plg_financial_payment_method_types USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_financial_payment_methods_tenant_owner_idx ON public.plg_financial_payment_methods USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_financial_payment_methods_tenant_unit_idx ON public.plg_financial_payment_methods USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX uq_plg_financial_chart_of_accounts_code ON public.plg_financial_chart_of_accounts USING btree (tenant_id, code);\n\nCREATE UNIQUE INDEX uq_plg_financial_movements_external ON public.plg_financial_movements USING btree (tenant_id, external_source, external_id) WHERE ((external_id IS NOT NULL) AND (external_source IS NOT NULL));\n\n\n-- \u2500\u2500 fk constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nALTER TABLE ONLY public.items\n ADD CONSTRAINT items_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.plg_financial_invoices(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_accounts\n ADD CONSTRAINT plg_financial_accounts_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_accounts\n ADD CONSTRAINT plg_financial_accounts_person_id_fkey FOREIGN KEY (person_id) REFERENCES public.people(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_accounts\n ADD CONSTRAINT plg_financial_accounts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_accounts\n ADD CONSTRAINT plg_financial_accounts_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_allocations\n ADD CONSTRAINT plg_financial_allocations_installment_id_fkey FOREIGN KEY (installment_id) REFERENCES public.plg_financial_invoice_installments(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_allocations\n ADD CONSTRAINT plg_financial_allocations_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_allocations\n ADD CONSTRAINT plg_financial_allocations_payment_movement_id_fkey FOREIGN KEY (payment_movement_id) REFERENCES public.plg_financial_movements(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_allocations\n ADD CONSTRAINT plg_financial_allocations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_allocations\n ADD CONSTRAINT plg_financial_allocations_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_bank_accounts\n ADD CONSTRAINT plg_financial_bank_accounts_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_bank_accounts\n ADD CONSTRAINT plg_financial_bank_accounts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_bank_accounts\n ADD CONSTRAINT plg_financial_bank_accounts_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_card_brands\n ADD CONSTRAINT plg_financial_card_brands_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_card_brands\n ADD CONSTRAINT plg_financial_card_brands_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_card_brands\n ADD CONSTRAINT plg_financial_card_brands_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_card_fee_configs\n ADD CONSTRAINT plg_financial_card_fee_configs_account_id_fkey FOREIGN KEY (account_id) REFERENCES public.plg_financial_accounts(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_card_fee_configs\n ADD CONSTRAINT plg_financial_card_fee_configs_card_brand_id_fkey FOREIGN KEY (card_brand_id) REFERENCES public.plg_financial_card_brands(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_card_fee_configs\n ADD CONSTRAINT plg_financial_card_fee_configs_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_card_fee_configs\n ADD CONSTRAINT plg_financial_card_fee_configs_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_card_fee_configs\n ADD CONSTRAINT plg_financial_card_fee_configs_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_cash_register_sessions\n ADD CONSTRAINT plg_financial_cash_register_sessions_account_fk FOREIGN KEY (account_id) REFERENCES public.plg_financial_accounts(id);\n\nALTER TABLE ONLY public.plg_financial_cash_register_sessions\n ADD CONSTRAINT plg_financial_cash_register_sessions_cofre_fk FOREIGN KEY (bank_account_id) REFERENCES public.plg_financial_accounts(id);\n\nALTER TABLE ONLY public.plg_financial_cash_register_sessions\n ADD CONSTRAINT plg_financial_cash_register_sessions_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_cash_register_sessions\n ADD CONSTRAINT plg_financial_cash_register_sessions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_cash_register_sessions\n ADD CONSTRAINT plg_financial_cash_register_sessions_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_cash_session_reviewers\n ADD CONSTRAINT plg_financial_cash_session_reviewers_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_cash_session_reviewers\n ADD CONSTRAINT plg_financial_cash_session_reviewers_session_id_fkey FOREIGN KEY (session_id) REFERENCES public.plg_financial_cash_register_sessions(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_cash_session_reviewers\n ADD CONSTRAINT plg_financial_cash_session_reviewers_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_cash_session_reviewers\n ADD CONSTRAINT plg_financial_cash_session_reviewers_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_chart_of_accounts\n ADD CONSTRAINT plg_financial_chart_of_accounts_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_chart_of_accounts\n ADD CONSTRAINT plg_financial_chart_of_accounts_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.plg_financial_chart_of_accounts(id);\n\nALTER TABLE ONLY public.plg_financial_chart_of_accounts\n ADD CONSTRAINT plg_financial_chart_of_accounts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_chart_of_accounts\n ADD CONSTRAINT plg_financial_chart_of_accounts_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_cost_centers\n ADD CONSTRAINT plg_financial_cost_centers_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_cost_centers\n ADD CONSTRAINT plg_financial_cost_centers_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_cost_centers\n ADD CONSTRAINT plg_financial_cost_centers_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoice_installments\n ADD CONSTRAINT plg_financial_invoice_installments_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoice_installments\n ADD CONSTRAINT plg_financial_invoice_installments_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_invoice_installments\n ADD CONSTRAINT plg_financial_invoice_installments_tenant_id_invoice_id_fkey FOREIGN KEY (tenant_id, invoice_id) REFERENCES public.plg_financial_invoices(tenant_id, id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_invoice_installments\n ADD CONSTRAINT plg_financial_invoice_installments_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoice_items\n ADD CONSTRAINT plg_financial_invoice_items_chart_account_id_fkey FOREIGN KEY (chart_account_id) REFERENCES public.plg_financial_chart_of_accounts(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoice_items\n ADD CONSTRAINT plg_financial_invoice_items_cost_center_id_fkey FOREIGN KEY (cost_center_id) REFERENCES public.plg_financial_cost_centers(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoice_items\n ADD CONSTRAINT plg_financial_invoice_items_item_id_fkey FOREIGN KEY (item_id) REFERENCES public.items(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_invoice_items\n ADD CONSTRAINT plg_financial_invoice_items_parent_fk FOREIGN KEY (item_id, parent_kind) REFERENCES public.items(id, parent_kind) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_invoice_items\n ADD CONSTRAINT plg_financial_invoice_items_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_counterparty_person_id_fkey FOREIGN KEY (counterparty_person_id) REFERENCES public.people(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_order_id_fkey FOREIGN KEY (order_id) REFERENCES public.orders(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_tenant_order_fk FOREIGN KEY (tenant_id, order_id) REFERENCES public.orders(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_bank_account_fk FOREIGN KEY (bank_account_id) REFERENCES public.plg_financial_accounts(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_cash_session_id_fkey FOREIGN KEY (cash_session_id) REFERENCES public.plg_financial_cash_register_sessions(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_credit_fk FOREIGN KEY (credit_account_id) REFERENCES public.plg_financial_accounts(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_debit_fk FOREIGN KEY (debit_account_id) REFERENCES public.plg_financial_accounts(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_installment_fk FOREIGN KEY (financial_invoice_installment_id) REFERENCES public.plg_financial_invoice_installments(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_invoice_fk FOREIGN KEY (financial_invoice_id) REFERENCES public.plg_financial_invoices(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES public.orders(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_matched_movement_id_fkey FOREIGN KEY (matched_movement_id) REFERENCES public.plg_financial_movements(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_payment_method_id_fkey FOREIGN KEY (payment_method_id) REFERENCES public.plg_financial_payment_methods(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_payment_method_type_id_fkey FOREIGN KEY (payment_method_type_id) REFERENCES public.plg_financial_payment_method_types(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_reversed_by_fk FOREIGN KEY (reversed_by_id) REFERENCES public.plg_financial_movements(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_reverses_fk FOREIGN KEY (reverses_id) REFERENCES public.plg_financial_movements(id);\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_settles_movement_id_fkey FOREIGN KEY (settles_movement_id) REFERENCES public.plg_financial_movements(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_movements\n ADD CONSTRAINT plg_financial_movements_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_nfe\n ADD CONSTRAINT plg_financial_nfe_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_nfe\n ADD CONSTRAINT plg_financial_nfe_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_nfe\n ADD CONSTRAINT plg_financial_nfe_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_payment_method_types\n ADD CONSTRAINT plg_financial_payment_method_types_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_payment_method_types\n ADD CONSTRAINT plg_financial_payment_method_types_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_payment_method_types\n ADD CONSTRAINT plg_financial_payment_method_types_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_payment_methods\n ADD CONSTRAINT plg_financial_payment_methods_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_financial_payment_methods\n ADD CONSTRAINT plg_financial_payment_methods_payment_method_type_id_fkey FOREIGN KEY (payment_method_type_id) REFERENCES public.plg_financial_payment_method_types(id);\n\nALTER TABLE ONLY public.plg_financial_payment_methods\n ADD CONSTRAINT plg_financial_payment_methods_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_financial_payment_methods\n ADD CONSTRAINT plg_financial_payment_methods_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\n\n-- \u2500\u2500 trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nCREATE TRIGGER items_invoice_line_immutable BEFORE UPDATE ON public.items FOR EACH ROW WHEN ((COALESCE(old.invoice_id, new.invoice_id) IS NOT NULL)) EXECUTE FUNCTION public.trg_invoice_line_immutable();\n\nCREATE TRIGGER items_invoice_line_immutable_del BEFORE DELETE ON public.items FOR EACH ROW WHEN ((old.invoice_id IS NOT NULL)) EXECUTE FUNCTION public.trg_invoice_line_immutable();\n\nCREATE TRIGGER plg_financial_accounts_updated_at BEFORE UPDATE ON public.plg_financial_accounts FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_allocations_check BEFORE INSERT OR UPDATE ON public.plg_financial_allocations FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_allocations_check();\n\nCREATE TRIGGER plg_financial_allocations_sync AFTER INSERT OR DELETE OR UPDATE ON public.plg_financial_allocations FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_allocations_sync();\n\nCREATE TRIGGER plg_financial_allocations_updated_at BEFORE UPDATE ON public.plg_financial_allocations FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_bank_accounts_forward AFTER INSERT OR UPDATE ON public.plg_financial_bank_accounts FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_bank_accounts_forward();\n\nCREATE TRIGGER plg_financial_bank_accounts_updated_at BEFORE UPDATE ON public.plg_financial_bank_accounts FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_card_brands_updated_at BEFORE UPDATE ON public.plg_financial_card_brands FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_card_fee_configs_check BEFORE INSERT OR UPDATE ON public.plg_financial_card_fee_configs FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_card_fee_configs_check();\n\nCREATE TRIGGER plg_financial_card_fee_configs_updated_at BEFORE UPDATE ON public.plg_financial_card_fee_configs FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_cash_register_sessions_owner BEFORE INSERT OR UPDATE OF user_id ON public.plg_financial_cash_register_sessions FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_cash_sessions_owner();\n\nCREATE TRIGGER plg_financial_cash_register_sessions_updated_at BEFORE UPDATE ON public.plg_financial_cash_register_sessions FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_cash_session_reviewers_updated_at BEFORE UPDATE ON public.plg_financial_cash_session_reviewers FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_chart_of_accounts_updated_at BEFORE UPDATE ON public.plg_financial_chart_of_accounts FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_cost_centers_updated_at BEFORE UPDATE ON public.plg_financial_cost_centers FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_invoice_installments_inherit BEFORE INSERT OR UPDATE OF invoice_id, tenant_id ON public.plg_financial_invoice_installments FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_installments_inherit();\n\nCREATE TRIGGER plg_financial_invoice_installments_updated_at BEFORE UPDATE ON public.plg_financial_invoice_installments FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_invoice_items_inherit BEFORE INSERT OR UPDATE OF item_id ON public.plg_financial_invoice_items FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_invoice_items_inherit();\n\nCREATE TRIGGER plg_financial_invoice_items_updated_at BEFORE UPDATE ON public.plg_financial_invoice_items FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_invoices_immutable BEFORE UPDATE ON public.plg_financial_invoices FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_invoices_immutable();\n\nCREATE TRIGGER plg_financial_invoices_updated_at BEFORE UPDATE ON public.plg_financial_invoices FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_movements_after_insert AFTER INSERT ON public.plg_financial_movements FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_movements_after_insert();\n\nCREATE TRIGGER plg_financial_movements_append_only BEFORE DELETE OR UPDATE ON public.plg_financial_movements FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_movements_append_only();\n\nCREATE TRIGGER plg_financial_movements_before_insert BEFORE INSERT ON public.plg_financial_movements FOR EACH ROW EXECUTE FUNCTION public.trg_plg_financial_movements_before_insert();\n\nCREATE TRIGGER plg_financial_movements_updated_at BEFORE UPDATE ON public.plg_financial_movements FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_nfe_updated_at BEFORE UPDATE ON public.plg_financial_nfe FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_payment_method_types_updated_at BEFORE UPDATE ON public.plg_financial_payment_method_types FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_financial_payment_methods_updated_at BEFORE UPDATE ON public.plg_financial_payment_methods FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\n\n-- \u2500\u2500 row security \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nALTER TABLE public.plg_financial_accounts ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_allocations ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_bank_accounts ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_card_brands ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_card_fee_configs ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_cash_register_sessions ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_cash_session_reviewers ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_chart_of_accounts ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_cost_centers ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_invoice_installments ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_invoice_items ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_invoices ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_movements ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_nfe ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_payment_method_types ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_financial_payment_methods ENABLE ROW LEVEL SECURITY;\n\n\n-- \u2500\u2500 policy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nCREATE POLICY plg_financial_accounts_authz_delete ON public.plg_financial_accounts FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.account'::text, plg_financial_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_accounts_authz_insert ON public.plg_financial_accounts FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_accounts.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_accounts_authz_select ON public.plg_financial_accounts FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.account'::text, plg_financial_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_accounts_authz_update ON public.plg_financial_accounts FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.account'::text, plg_financial_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_accounts.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_accounts.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_allocations_authz_delete ON public.plg_financial_allocations FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_allocations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.allocation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.allocation'::text, plg_financial_allocations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_allocations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_allocations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_allocations_authz_insert ON public.plg_financial_allocations FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_allocations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_allocations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_allocations_authz_select ON public.plg_financial_allocations FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_allocations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.allocation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.allocation'::text, plg_financial_allocations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_allocations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_allocations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_allocations_authz_update ON public.plg_financial_allocations FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_allocations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.allocation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.allocation'::text, plg_financial_allocations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_allocations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_allocations.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_allocations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_allocations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_bank_accounts_authz_delete ON public.plg_financial_bank_accounts FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_bank_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.bank_account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.bank_account'::text, plg_financial_bank_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_bank_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_bank_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_bank_accounts_authz_insert ON public.plg_financial_bank_accounts FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_bank_accounts.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_bank_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_bank_accounts_authz_select ON public.plg_financial_bank_accounts FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_bank_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.bank_account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.bank_account'::text, plg_financial_bank_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_bank_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_bank_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_bank_accounts_authz_update ON public.plg_financial_bank_accounts FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_bank_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.bank_account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.bank_account'::text, plg_financial_bank_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_bank_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_bank_accounts.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_bank_accounts.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_bank_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_card_brands_authz_delete ON public.plg_financial_card_brands FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_brands.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.card_brand'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.card_brand'::text, plg_financial_card_brands.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_card_brands.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_card_brands.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_card_brands_authz_insert ON public.plg_financial_card_brands FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_brands.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_card_brands.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_card_brands_authz_select ON public.plg_financial_card_brands FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_brands.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.card_brand'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.card_brand'::text, plg_financial_card_brands.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_card_brands.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_card_brands.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_card_brands_authz_update ON public.plg_financial_card_brands FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_brands.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.card_brand'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.card_brand'::text, plg_financial_card_brands.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_card_brands.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_card_brands.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_brands.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_card_brands.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_card_fee_configs_authz_delete ON public.plg_financial_card_fee_configs FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_fee_configs.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.card_fee_config'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.card_fee_config'::text, plg_financial_card_fee_configs.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_card_fee_configs.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_card_fee_configs.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_card_fee_configs_authz_insert ON public.plg_financial_card_fee_configs FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_fee_configs.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_card_fee_configs.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_card_fee_configs_authz_select ON public.plg_financial_card_fee_configs FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_fee_configs.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.card_fee_config'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.card_fee_config'::text, plg_financial_card_fee_configs.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_card_fee_configs.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_card_fee_configs.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_card_fee_configs_authz_update ON public.plg_financial_card_fee_configs FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_fee_configs.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.card_fee_config'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.card_fee_config'::text, plg_financial_card_fee_configs.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_card_fee_configs.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_card_fee_configs.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_card_fee_configs.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_card_fee_configs.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cash_register_sessions_authz_delete ON public.plg_financial_cash_register_sessions FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_register_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cash_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cash_session'::text, plg_financial_cash_register_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cash_register_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_cash_register_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cash_register_sessions_authz_insert ON public.plg_financial_cash_register_sessions FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_register_sessions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_cash_register_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cash_register_sessions_authz_select ON public.plg_financial_cash_register_sessions FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_register_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cash_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cash_session'::text, plg_financial_cash_register_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cash_register_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_cash_register_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cash_register_sessions_authz_update ON public.plg_financial_cash_register_sessions FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_register_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cash_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cash_session'::text, plg_financial_cash_register_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cash_register_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_cash_register_sessions.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_register_sessions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_cash_register_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cash_session_reviewers_authz_delete ON public.plg_financial_cash_session_reviewers FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_session_reviewers.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cash_session_review'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cash_session_review'::text, plg_financial_cash_session_reviewers.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cash_session_reviewers.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_cash_session_reviewers.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cash_session_reviewers_authz_insert ON public.plg_financial_cash_session_reviewers FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_session_reviewers.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_cash_session_reviewers.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cash_session_reviewers_authz_select ON public.plg_financial_cash_session_reviewers FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_session_reviewers.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cash_session_review'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cash_session_review'::text, plg_financial_cash_session_reviewers.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cash_session_reviewers.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_cash_session_reviewers.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cash_session_reviewers_authz_update ON public.plg_financial_cash_session_reviewers FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_session_reviewers.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cash_session_review'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cash_session_review'::text, plg_financial_cash_session_reviewers.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cash_session_reviewers.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_cash_session_reviewers.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cash_session_reviewers.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_cash_session_reviewers.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_chart_of_accounts_authz_delete ON public.plg_financial_chart_of_accounts FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_chart_of_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.chart_of_account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.chart_of_account'::text, plg_financial_chart_of_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_chart_of_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_chart_of_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_chart_of_accounts_authz_insert ON public.plg_financial_chart_of_accounts FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_chart_of_accounts.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_chart_of_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_chart_of_accounts_authz_select ON public.plg_financial_chart_of_accounts FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_chart_of_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.chart_of_account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.chart_of_account'::text, plg_financial_chart_of_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_chart_of_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_chart_of_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_chart_of_accounts_authz_update ON public.plg_financial_chart_of_accounts FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_chart_of_accounts.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.chart_of_account'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.chart_of_account'::text, plg_financial_chart_of_accounts.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_chart_of_accounts.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_chart_of_accounts.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_chart_of_accounts.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_chart_of_accounts.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cost_centers_authz_delete ON public.plg_financial_cost_centers FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cost_centers.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cost_center'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cost_center'::text, plg_financial_cost_centers.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cost_centers.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_cost_centers.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cost_centers_authz_insert ON public.plg_financial_cost_centers FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cost_centers.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_cost_centers.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cost_centers_authz_select ON public.plg_financial_cost_centers FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cost_centers.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cost_center'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cost_center'::text, plg_financial_cost_centers.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cost_centers.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_cost_centers.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_cost_centers_authz_update ON public.plg_financial_cost_centers FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cost_centers.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.cost_center'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.cost_center'::text, plg_financial_cost_centers.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_cost_centers.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_cost_centers.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_cost_centers.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_cost_centers.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoice_installments_authz_delete ON public.plg_financial_invoice_installments FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoice_installments.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.invoice_installment'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.invoice_installment'::text, plg_financial_invoice_installments.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_invoice_installments.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_invoice_installments.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoice_installments_authz_insert ON public.plg_financial_invoice_installments FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoice_installments.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_invoice_installments.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoice_installments_authz_select ON public.plg_financial_invoice_installments FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoice_installments.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.invoice_installment'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.invoice_installment'::text, plg_financial_invoice_installments.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_invoice_installments.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_invoice_installments.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoice_installments_authz_update ON public.plg_financial_invoice_installments FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoice_installments.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.invoice_installment'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.invoice_installment'::text, plg_financial_invoice_installments.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_invoice_installments.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_invoice_installments.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoice_installments.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_invoice_installments.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoice_items_authz_delete ON public.plg_financial_invoice_items FOR DELETE TO authenticated USING (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_invoice_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoice_items_authz_insert ON public.plg_financial_invoice_items FOR INSERT TO authenticated WITH CHECK (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_invoice_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoice_items_authz_select ON public.plg_financial_invoice_items FOR SELECT TO authenticated USING ((item_id IN ( SELECT i.id\n FROM public.items i)));\n\nCREATE POLICY plg_financial_invoice_items_authz_update ON public.plg_financial_invoice_items FOR UPDATE TO authenticated USING (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_invoice_items.unit_id) AS has_permission))) WITH CHECK (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_invoice_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoices_authz_delete ON public.plg_financial_invoices FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoices.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.invoice'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.invoice'::text, plg_financial_invoices.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_invoices.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_invoices.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoices_authz_insert ON public.plg_financial_invoices FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoices.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_invoices.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoices_authz_select ON public.plg_financial_invoices FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoices.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.invoice'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.invoice'::text, plg_financial_invoices.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_invoices.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_invoices.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_invoices_authz_update ON public.plg_financial_invoices FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoices.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.invoice'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.invoice'::text, plg_financial_invoices.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_invoices.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_invoices.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_invoices.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_invoices.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_movements_authz_delete ON public.plg_financial_movements FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_movements.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.movement'::text, plg_financial_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_movements_authz_insert ON public.plg_financial_movements FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_movements.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_movements_authz_select ON public.plg_financial_movements FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_movements.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.movement'::text, plg_financial_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_movements_authz_update ON public.plg_financial_movements FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_movements.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.movement'::text, plg_financial_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_movements.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_movements.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_nfe_authz_delete ON public.plg_financial_nfe FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_nfe.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.nfe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.nfe'::text, plg_financial_nfe.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_nfe.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_nfe.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_nfe_authz_insert ON public.plg_financial_nfe FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_nfe.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_nfe.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_nfe_authz_select ON public.plg_financial_nfe FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_nfe.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.nfe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.nfe'::text, plg_financial_nfe.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_nfe.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_nfe.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_nfe_authz_update ON public.plg_financial_nfe FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_nfe.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.nfe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.nfe'::text, plg_financial_nfe.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_nfe.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_nfe.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_nfe.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_nfe.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_payment_method_types_authz_delete ON public.plg_financial_payment_method_types FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_method_types.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.payment_method_type'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.payment_method_type'::text, plg_financial_payment_method_types.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_payment_method_types.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_payment_method_types.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_payment_method_types_authz_insert ON public.plg_financial_payment_method_types FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_method_types.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_payment_method_types.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_payment_method_types_authz_select ON public.plg_financial_payment_method_types FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_method_types.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.payment_method_type'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.payment_method_type'::text, plg_financial_payment_method_types.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_payment_method_types.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_payment_method_types.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_payment_method_types_authz_update ON public.plg_financial_payment_method_types FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_method_types.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.payment_method_type'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.payment_method_type'::text, plg_financial_payment_method_types.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_payment_method_types.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_payment_method_types.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_method_types.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_payment_method_types.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_payment_methods_authz_delete ON public.plg_financial_payment_methods FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_methods.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.payment_method'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.payment_method'::text, plg_financial_payment_methods.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_payment_methods.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.delete'::text, plg_financial_payment_methods.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_payment_methods_authz_insert ON public.plg_financial_payment_methods FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_methods.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.create'::text, plg_financial_payment_methods.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_payment_methods_authz_select ON public.plg_financial_payment_methods FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_methods.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.payment_method'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.payment_method'::text, plg_financial_payment_methods.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_payment_methods.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.read'::text, plg_financial_payment_methods.unit_id) AS has_permission)));\n\nCREATE POLICY plg_financial_payment_methods_authz_update ON public.plg_financial_payment_methods FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_methods.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('financial.payment_method'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('financial.payment_method'::text, plg_financial_payment_methods.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('financial.read_all'::text, plg_financial_payment_methods.unit_id) AS has_permission)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_payment_methods.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_financial_payment_methods.unit_id) AS has_unit)) AND ( SELECT app.has_permission('financial.edit'::text, plg_financial_payment_methods.unit_id) AS has_permission)));\n\n\n-- \u2500\u2500 acl \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nREVOKE ALL ON FUNCTION public.agent_financial_mark_payment_received(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agent_financial_mark_payment_received(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agent_financial_mark_payment_received(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_cancel_invoice(p_invoice_id uuid, p_reason text, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_cancel_invoice(p_invoice_id uuid, p_reason text, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_cancel_invoice(p_invoice_id uuid, p_reason text, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_cancel_paid_item(p_order_item_id uuid, p_reason text, p_idempotency_key text, p_amount numeric) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_cancel_paid_item(p_order_item_id uuid, p_reason text, p_idempotency_key text, p_amount numeric) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_cancel_paid_item(p_order_item_id uuid, p_reason text, p_idempotency_key text, p_amount numeric) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_cash_session_close(p_counted_cash numeric, p_counted_card numeric, p_idempotency_key text, p_notes text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_cash_session_close(p_counted_cash numeric, p_counted_card numeric, p_idempotency_key text, p_notes text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_cash_session_close(p_counted_cash numeric, p_counted_card numeric, p_idempotency_key text, p_notes text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_cash_session_open(p_cofre_account_id uuid, p_opening_amount numeric, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_cash_session_open(p_cofre_account_id uuid, p_opening_amount numeric, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_cash_session_open(p_cofre_account_id uuid, p_opening_amount numeric, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_create_invoice(p_direction text, p_total numeric, p_counterparty_person_id uuid, p_order_id uuid, p_unit_id uuid, p_installments jsonb, p_competence_date date, p_description text, p_notes text, p_metadata jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_create_invoice(p_direction text, p_total numeric, p_counterparty_person_id uuid, p_order_id uuid, p_unit_id uuid, p_installments jsonb, p_competence_date date, p_description text, p_notes text, p_metadata jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_create_invoice(p_direction text, p_total numeric, p_counterparty_person_id uuid, p_order_id uuid, p_unit_id uuid, p_installments jsonb, p_competence_date date, p_description text, p_notes text, p_metadata jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_create_payable(p_description text, p_total numeric, p_due_date date, p_person_id uuid, p_unit_id uuid, p_chart_account_id uuid, p_cost_center_id uuid, p_installments jsonb, p_competence_date date, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_create_payable(p_description text, p_total numeric, p_due_date date, p_person_id uuid, p_unit_id uuid, p_chart_account_id uuid, p_cost_center_id uuid, p_installments jsonb, p_competence_date date, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_create_payable(p_description text, p_total numeric, p_due_date date, p_person_id uuid, p_unit_id uuid, p_chart_account_id uuid, p_cost_center_id uuid, p_installments jsonb, p_competence_date date, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_credit_customer(p_person_id uuid, p_amount numeric, p_to_account_id uuid, p_method text, p_reason text, p_notes text, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_credit_customer(p_person_id uuid, p_amount numeric, p_to_account_id uuid, p_method text, p_reason text, p_notes text, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_credit_customer(p_person_id uuid, p_amount numeric, p_to_account_id uuid, p_method text, p_reason text, p_notes text, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_invoice_from_lines(p_order_id uuid, p_lines jsonb, p_direction text, p_person_id uuid, p_installments jsonb, p_competence_date date, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_invoice_from_lines(p_order_id uuid, p_lines jsonb, p_direction text, p_person_id uuid, p_installments jsonb, p_competence_date date, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_invoice_from_lines(p_order_id uuid, p_lines jsonb, p_direction text, p_person_id uuid, p_installments jsonb, p_competence_date date, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_receive_payment(p_installment_id uuid, p_amount numeric, p_account_id uuid, p_method text, p_cash_session_id uuid, p_card_brand text, p_card_installments integer, p_nsu text, p_payment_date date, p_fee_amount numeric, p_notes text, p_idempotency_key text, p_metadata jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_receive_payment(p_installment_id uuid, p_amount numeric, p_account_id uuid, p_method text, p_cash_session_id uuid, p_card_brand text, p_card_installments integer, p_nsu text, p_payment_date date, p_fee_amount numeric, p_notes text, p_idempotency_key text, p_metadata jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_receive_payment(p_installment_id uuid, p_amount numeric, p_account_id uuid, p_method text, p_cash_session_id uuid, p_card_brand text, p_card_installments integer, p_nsu text, p_payment_date date, p_fee_amount numeric, p_notes text, p_idempotency_key text, p_metadata jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_retire_bill_mirror(p_execute boolean) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_retire_bill_mirror(p_execute boolean) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_reverse_movement(p_movement_id uuid, p_reason text, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_reverse_movement(p_movement_id uuid, p_reason text, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_reverse_movement(p_movement_id uuid, p_reason text, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_set_opening_balance(p_account_id uuid, p_amount numeric, p_date date, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_set_opening_balance(p_account_id uuid, p_amount numeric, p_date date, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_set_opening_balance(p_account_id uuid, p_amount numeric, p_date date, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.financial_transfer(p_from_account_id uuid, p_to_account_id uuid, p_amount numeric, p_date date, p_method text, p_notes text, p_idempotency_key text, p_metadata jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.financial_transfer(p_from_account_id uuid, p_to_account_id uuid, p_amount numeric, p_date date, p_method text, p_notes text, p_idempotency_key text, p_metadata jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.financial_transfer(p_from_account_id uuid, p_to_account_id uuid, p_amount numeric, p_date date, p_method text, p_notes text, p_idempotency_key text, p_metadata jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.fn_invoice_from_order(p_order_id uuid, p_due_date date, p_status text, p_installments integer, p_direction text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.fn_invoice_from_order(p_order_id uuid, p_due_date date, p_status text, p_installments integer, p_direction text) TO authenticated;\nGRANT ALL ON FUNCTION public.fn_invoice_from_order(p_order_id uuid, p_due_date date, p_status text, p_installments integer, p_direction text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.fn_invoice_from_order_internal(p_order_id uuid, p_due_date date, p_status text, p_installments integer, p_direction text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.fn_invoice_from_order_internal(p_order_id uuid, p_due_date date, p_status text, p_installments integer, p_direction text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.fn_order_to_cash(p_kind text, p_lines jsonb, p_party_id uuid, p_unit_id uuid, p_status text, p_channel text, p_assignee_id uuid, p_installments integer, p_due_date date, p_direction text, p_metadata jsonb, p_appointment_id uuid, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.fn_order_to_cash(p_kind text, p_lines jsonb, p_party_id uuid, p_unit_id uuid, p_status text, p_channel text, p_assignee_id uuid, p_installments integer, p_due_date date, p_direction text, p_metadata jsonb, p_appointment_id uuid, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.fn_order_to_cash(p_kind text, p_lines jsonb, p_party_id uuid, p_unit_id uuid, p_status text, p_channel text, p_assignee_id uuid, p_installments integer, p_due_date date, p_direction text, p_metadata jsonb, p_appointment_id uuid, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.next_sequence_internal(p_tenant_id uuid, p_kind text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.next_sequence_internal(p_tenant_id uuid, p_kind text) TO service_role;\n\nGRANT ALL ON FUNCTION public.plg_financial_account_kind_of(p_account_type text) TO authenticated;\nGRANT ALL ON FUNCTION public.plg_financial_account_kind_of(p_account_type text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_audit(p_tenant uuid, p_action text, p_entity_type text, p_entity_id text, p_metadata jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_audit(p_tenant uuid, p_action text, p_entity_type text, p_entity_id text, p_metadata jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_backfill_ledger() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_backfill_ledger() TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_claim_idempotency(p_tenant uuid, p_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_claim_idempotency(p_tenant uuid, p_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_installment_paid(p_installment uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_installment_paid(p_installment uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_legacy_legs(p_tenant uuid, p_kind text, p_direction text, p_bank_account_id uuid, p_cash_session_id uuid, p_invoice_id uuid, p_metadata jsonb, OUT o_debit uuid, OUT o_credit uuid, OUT o_mirror boolean) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_legacy_legs(p_tenant uuid, p_kind text, p_direction text, p_bank_account_id uuid, p_cash_session_id uuid, p_invoice_id uuid, p_metadata jsonb, OUT o_debit uuid, OUT o_credit uuid, OUT o_mirror boolean) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_migrate_legacy_invoices() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_migrate_legacy_invoices() TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_next_invoice_number(p_tenant_id uuid, p_direction text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_next_invoice_number(p_tenant_id uuid, p_direction text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_open_session_of(p_tenant uuid, p_user uuid, p_unit uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_open_session_of(p_tenant uuid, p_user uuid, p_unit uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_person_account(p_tenant uuid, p_person uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_person_account(p_tenant uuid, p_person uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_require_permission(p_perm text, p_unit uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_require_permission(p_perm text, p_unit uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_require_tenant() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_require_tenant() TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_sync_mirror(p_installment uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_sync_mirror(p_installment uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_financial_system_account(p_tenant uuid, p_code text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_financial_system_account(p_tenant uuid, p_code text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_invoice_line_immutable() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_invoice_line_immutable() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_allocations_check() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_allocations_check() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_allocations_sync() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_allocations_sync() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_bank_accounts_forward() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_bank_accounts_forward() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_card_fee_configs_check() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_card_fee_configs_check() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_cash_sessions_owner() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_cash_sessions_owner() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_installments_inherit() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_installments_inherit() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_invoice_items_inherit() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_invoice_items_inherit() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_invoices_immutable() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_invoices_immutable() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_movements_after_insert() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_movements_after_insert() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_movements_append_only() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_movements_append_only() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_financial_movements_before_insert() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_financial_movements_before_insert() TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_allocations TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_allocations TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_invoice_installments TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_invoice_installments TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_invoices TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_invoices TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_movements TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_movements TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_financial_installment_balances TO anon;\nGRANT ALL ON TABLE public.v_financial_installment_balances TO authenticated;\nGRANT ALL ON TABLE public.v_financial_installment_balances TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_financial_invoice_balances TO anon;\nGRANT ALL ON TABLE public.v_financial_invoice_balances TO authenticated;\nGRANT ALL ON TABLE public.v_financial_invoice_balances TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_invoice_balances TO anon;\nGRANT ALL ON TABLE public.v_invoice_balances TO authenticated;\nGRANT ALL ON TABLE public.v_invoice_balances TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_accounts TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_accounts TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_bank_accounts TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_bank_accounts TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_card_brands TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_card_brands TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_card_fee_configs TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_card_fee_configs TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_cash_register_sessions TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_cash_register_sessions TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_cash_session_reviewers TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_cash_session_reviewers TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_chart_of_accounts TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_chart_of_accounts TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_cost_centers TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_cost_centers TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_financial_invoice_items TO anon;\nGRANT ALL ON TABLE public.plg_financial_invoice_items TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_invoice_items TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_nfe TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_nfe TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_payment_method_types TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_payment_method_types TO service_role;\n\nGRANT ALL ON TABLE public.plg_financial_payment_methods TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_payment_methods TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_financial_rep_receivables TO anon;\nGRANT ALL ON TABLE public.plg_financial_rep_receivables TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_rep_receivables TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_bill_mirror_writers TO anon;\nGRANT ALL ON TABLE public.v_bill_mirror_writers TO authenticated;\nGRANT ALL ON TABLE public.v_bill_mirror_writers TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_bill_mirror_pending TO anon;\nGRANT ALL ON TABLE public.v_bill_mirror_pending TO authenticated;\nGRANT ALL ON TABLE public.v_bill_mirror_pending TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_bill_mirror_readers TO anon;\nGRANT ALL ON TABLE public.v_bill_mirror_readers TO authenticated;\nGRANT ALL ON TABLE public.v_bill_mirror_readers TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_financial_account_balances TO anon;\nGRANT ALL ON TABLE public.v_financial_account_balances TO authenticated;\nGRANT ALL ON TABLE public.v_financial_account_balances TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_financial_card_receivables TO anon;\nGRANT ALL ON TABLE public.v_financial_card_receivables TO authenticated;\nGRANT ALL ON TABLE public.v_financial_card_receivables TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_financial_customer_credit TO anon;\nGRANT ALL ON TABLE public.v_financial_customer_credit TO authenticated;\nGRANT ALL ON TABLE public.v_financial_customer_credit TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_financial_invoice_lines TO anon;\nGRANT ALL ON TABLE public.v_financial_invoice_lines TO authenticated;\nGRANT ALL ON TABLE public.v_financial_invoice_lines TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_financial_ledger TO anon;\nGRANT ALL ON TABLE public.v_financial_ledger TO authenticated;\nGRANT ALL ON TABLE public.v_financial_ledger TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_financial_person_payments TO anon;\nGRANT ALL ON TABLE public.v_financial_person_payments TO authenticated;\nGRANT ALL ON TABLE public.v_financial_person_payments TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_order_billing TO anon;\nGRANT ALL ON TABLE public.v_order_billing TO authenticated;\nGRANT ALL ON TABLE public.v_order_billing TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_order_payment_status TO anon;\nGRANT ALL ON TABLE public.v_order_payment_status TO authenticated;\nGRANT ALL ON TABLE public.v_order_payment_status TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_orders_sem_invoice TO anon;\nGRANT ALL ON TABLE public.v_orders_sem_invoice TO authenticated;\nGRANT ALL ON TABLE public.v_orders_sem_invoice TO service_role;\n\n\n-- \u2500\u2500 comment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nCOMMENT ON FUNCTION public.agent_financial_mark_payment_received(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) IS 'The agent receives a payment (009), settling INSTALMENTS through financial_receive_payment instead of flipping legacy bill mirrors. Reader 2 of the 4 that kept movement_kind=''bill'' alive (PRD 04).';\n\nCOMMENT ON FUNCTION public.financial_create_payable(p_description text, p_total numeric, p_due_date date, p_person_id uuid, p_unit_id uuid, p_chart_account_id uuid, p_cost_center_id uuid, p_installments jsonb, p_competence_date date, p_idempotency_key text) IS 'A payable with no sale (PRD 04): the rent. A line with a description and no product, on an invoice with no order \u2014 which is the thing 100% of today''s invoices could not be.';\n\nCOMMENT ON FUNCTION public.financial_invoice_from_lines(p_order_id uuid, p_lines jsonb, p_direction text, p_person_id uuid, p_installments jsonb, p_competence_date date, p_idempotency_key text) IS 'Bill part of an order (PRD 04). Five people at one table is five calls to this: each raises its own invoice with its own lines, each line associated back to the order line with its own quantity, and the association guard refuses a sixth invoice that would bill more than was ordered.';\n\nCOMMENT ON FUNCTION public.financial_retire_bill_mirror(p_execute boolean) IS 'Removes the legacy bill mirror, and REFUSES while anything still WRITES one or while a bill row is not a mirror (PRD 04/10). Never a side effect of an apply: the migration that calls it reports and moves on when the census is not clean.';\n\nCOMMENT ON FUNCTION public.fn_invoice_from_order_internal(p_order_id uuid, p_due_date date, p_status text, p_installments integer, p_direction text) IS 'Promotes a Venda into an invoice with N instalments (018), and \u2014 since PRD 04 \u2014 writes NO legacy bill mirror. The mirror''s shape is served by v_financial_bills, derived; what is left of the old rows is removed by the guarded retirement in 041.';\n\nCOMMENT ON FUNCTION public.fn_order_to_cash(p_kind text, p_lines jsonb, p_party_id uuid, p_unit_id uuid, p_status text, p_channel text, p_assignee_id uuid, p_installments integer, p_due_date date, p_direction text, p_metadata jsonb, p_appointment_id uuid, p_idempotency_key text) IS 'A booking becomes a Venda and a T\u00EDtulo in ONE call and ONE transaction (PRD 06). Either both are born or neither is \u2014 which is what the agenda''s two browser calls could not promise.';\n\nCOMMENT ON COLUMN public.plg_financial_movements.fee_amount IS '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\nCOMMENT ON COLUMN public.plg_financial_movements.external_id IS 'Provider-unique id of the imported bank line. Forms the import idempotency key with external_source.';\n\nCOMMENT ON COLUMN public.plg_financial_movements.external_source IS 'Provider of an imported bank-statement line (e.g. plugbank, inter). NULL for app-native movements.';\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\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\nCOMMENT ON TABLE public.plg_financial_invoice_items IS 'What only an invoice line has (PRD 04): chart of accounts, cost centre, tax and CFOP. Tied to public.items by a COMPOSITE foreign key on (item_id, parent_kind), so the database refuses this extension over a delivery line \u2014 which is why a delivery line carries no tax column and no cross-kind nulls exist.';\n\nCOMMENT ON VIEW public.v_bill_mirror_writers IS 'Who still writes a movement_kind=''bill'' row (044). It scanned only public and app until then, which is why it reported zero while migration.upsert_fin_movement was landing every V1 obligation as one. A census that cannot see a writer is a census that says a live thing has fallen.';\n\nCOMMENT ON VIEW public.v_bill_mirror_readers IS 'Everything IN THE DATABASE that mentions the legacy bill mirror (PRD 04/10) \u2014 informational. What the retirement blocks on is v_bill_mirror_writers: the rule is that a mirror falls when nobody WRITES to it, and a mention that only guards against a pool which has one is a reader.';\n\nCOMMENT ON VIEW public.v_financial_invoice_lines IS 'What an invoice charged (PRD 04): its lines, their chart-of-accounts and cost-centre, and what each was billed FROM. The origin is an order line \u2014 a row of public.items carrying order_id since 187.';\n\nCOMMENT ON VIEW public.v_financial_person_payments IS '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\nCOMMENT ON VIEW public.v_order_billing IS 'Order total against what has actually been invoiced (PRD 04). A table of five with five invoices reads as five rows summing to the order \u2014 the acceptance case, as a query.';\n\nCOMMENT ON VIEW public.v_order_payment_status IS 'How much of an order has been paid, straight from the installment balances (PRD 04/06). The agenda''s payment badge reads this instead of movement_kind=''bill'' \u2014 reader 3 of the 4 that kept the legacy mirror alive.';\n\nCOMMENT ON VIEW public.v_orders_sem_invoice IS 'Sales that COMPLETED and produced no invoice (PRD 02/04 acceptance). Empty is the bar. Two exclusions, both deliberate: a sale still open is billed when it closes, and an unbilled appointment is legitimate \u2014 what is not legitimate is a completed one, which v_appointments_sem_order counts separately.';\n\n\n-- \u2500\u2500 default privileges: restore \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Put back, so the NEXT thing installed inherits what the chain had.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEND $dp$;\n";
|
|
2
|
+
export declare const MIGRATION_001_THE_COMANDA_BECOMES_A_RECEIVABLE_WHEN_IT_CLOSES = "-- ---------------------------------------------------------------------------\n-- 001 \u2014 a comanda vira conta a receber quando FECHA.\n--\n-- Medido antes de escrever: `dine_in` tinha 6 pedidos e ZERO faturas, enquanto\n-- `sale` tinha 7 e 6. N\u00E3o era chamada esquecida \u2014 o gatilho que levanta o\n-- receb\u00EDvel chama-se `plg_shop_order_extensions_raise_receivable` e est\u00E1\n-- pendurado na tabela de extens\u00E3o da LOJA. Uma comanda n\u00E3o tem linha de\n-- extens\u00E3o de loja, ent\u00E3o nada disparava. O restaurante inteiro passava ao\n-- largo do Financeiro.\n--\n-- QUANDO, e por que n\u00E3o na abertura. Na loja o pedido nasce fechado, ent\u00E3o\n-- faturar no checkout \u00E9 certo. Uma comanda nasce aberta e cresce por uma hora:\n-- faturar na abertura produziria uma fatura errada a cada item novo, e depois\n-- uma corre\u00E7\u00E3o para cada uma. Fatura-se no fechamento, e a comanda aberta\n-- aparece como servi\u00E7o em andamento, n\u00E3o como receb\u00EDvel.\n--\n-- POR QUE UMA FUN\u00C7\u00C3O NOVA, e n\u00E3o uma das cinco que j\u00E1 existem:\n--\n-- \u00B7 `financial_create_invoice` \u00E9 a moderna e a correta em quase tudo \u2014 n\u00E3o\n-- mexe no `reference_number`, tem idempot\u00EAncia de verdade. Mas exige\n-- `financial.create` de QUEM CHAMA, e quem fecha uma comanda \u00E9 o gar\u00E7om.\n-- Levantar o receb\u00EDvel \u00E9 consequ\u00EAncia da venda, n\u00E3o um ato de escritura\u00E7\u00E3o\n-- dele; exigir a permiss\u00E3o faria o fechamento falhar para a pessoa certa.\n--\n-- \u00B7 `fn_invoice_from_order_internal`, que a loja usa, n\u00E3o exige permiss\u00E3o \u2014\n-- mas SOBRESCREVE `orders.reference_number` com o n\u00FAmero da fatura. Isso \u00E9\n-- coerente com a origem dela (promover um or\u00E7amento em fatura, no lugar) e\n-- \u00E9 destrutivo aqui: a comanda PED-000003 viraria REC-000004 e o sal\u00E3o\n-- perderia o n\u00FAmero pelo qual a chama.\n--\n-- Ent\u00E3o: a idempot\u00EAncia e o n\u00E3o-mexer-no-n\u00FAmero da primeira, sem a exig\u00EAncia de\n-- permiss\u00E3o da segunda. Uma linha de c\u00F3digo a mais, e o n\u00FAmero da comanda\n-- sobrevive.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.order_raise_receivable(\n p_order_id uuid,\n p_due_date date DEFAULT NULL\n) RETURNS uuid\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_invoice_id uuid;\n v_number text;\n v_summary text;\n v_total numeric;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RETURN NULL; END IF;\n\n -- J\u00E1 faturada \u00E9 o caso comum numa reexecu\u00E7\u00E3o, n\u00E3o um erro.\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 v_invoice_id; END IF;\n\n -- Comanda sem nada dentro n\u00E3o \u00E9 conta a receber de zero real; \u00E9 uma comanda\n -- que ningu\u00E9m usou. Fechar uma dessas n\u00E3o deve sujar o Financeiro.\n v_total := round(coalesce(v_order.total, 0), 2);\n IF v_total <= 0 THEN RETURN NULL; END IF;\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.items WHERE order_id = p_order_id;\n\n v_number := public.plg_financial_next_invoice_number(v_order.tenant_id, 'receivable');\n\n INSERT INTO public.plg_financial_invoices\n (tenant_id, unit_id, direction, counterparty_person_id, order_id, number,\n competence_date, issue_date, total, description, metadata, created_by)\n VALUES\n (v_order.tenant_id, v_order.unit_id, 'receivable', 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', 'order_raise_receivable', 'orderKind', v_order.kind),\n auth.uid())\n RETURNING id INTO v_invoice_id;\n\n INSERT INTO public.plg_financial_invoice_installments\n (tenant_id, unit_id, invoice_id, number, due_date, amount)\n VALUES (v_order.tenant_id, v_order.unit_id, v_invoice_id, 1,\n coalesce(p_due_date, current_date), v_total);\n\n -- O pedido registra que foi faturado, com as MESMAS chaves que os leitores\n -- antigos usam \u2014 e sem tocar em `reference_number`, que \u00E9 o nome da comanda.\n UPDATE public.orders SET\n stage = 'invoiced',\n direction = 'credit',\n metadata = coalesce(metadata, '{}'::jsonb)\n || jsonb_build_object('invoiceNumber', v_number, 'installmentCount', 1),\n updated_at = now()\n WHERE id = p_order_id;\n\n RETURN v_invoice_id;\nEND $$;\n\nCOMMENT ON FUNCTION public.order_raise_receivable(uuid, date) IS\n 'Levanta a conta a receber de um pedido, sem exigir permiss\u00E3o financeira de quem fecha e sem renomear o pedido (001). Idempotente por order_id.';\n\n-- \u2500\u2500 o gatilho \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- S\u00F3 `dine_in`, e de prop\u00F3sito. A loja j\u00E1 fatura no checkout, pela extens\u00E3o\n-- dela, e o guard de idempot\u00EAncia acima faria a segunda chamada n\u00E3o ter efeito\n-- \u2014 mas um gatilho gen\u00E9rico tamb\u00E9m passaria a faturar `appointment`, que tem 21\n-- pedidos e 1 fatura hoje. Se a agenda deve faturar ao concluir \u00E9 uma decis\u00E3o\n-- da agenda, tomada por quem conhece o fluxo dela, n\u00E3o um efeito colateral\n-- desta migration.\n\nCREATE OR REPLACE FUNCTION public.trg_order_raise_receivable_on_close()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nBEGIN\n IF NEW.kind = 'dine_in'\n AND NEW.status = 'completed'\n AND coalesce(OLD.status, '') IS DISTINCT FROM 'completed' THEN\n -- Nunca deixa o fechamento falhar por causa da escritura\u00E7\u00E3o: a comanda\n -- fechou de verdade, e uma fatura que n\u00E3o subiu \u00E9 repar\u00E1vel (a fun\u00E7\u00E3o \u00E9\n -- idempotente, ent\u00E3o reprocessar \u00E9 seguro). O contr\u00E1rio \u2014 o gar\u00E7om n\u00E3o\n -- conseguir fechar a mesa porque o Financeiro recusou \u2014 n\u00E3o \u00E9.\n BEGIN\n PERFORM public.order_raise_receivable(NEW.id);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'order_raise_receivable falhou para o pedido %: %', NEW.id, SQLERRM;\n END;\n END IF;\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS orders_raise_receivable_on_close ON public.orders;\nCREATE TRIGGER orders_raise_receivable_on_close\n AFTER UPDATE OF status ON public.orders\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_order_raise_receivable_on_close();\n\nGRANT EXECUTE ON FUNCTION public.order_raise_receivable(uuid, date) TO authenticated;\n";
|
|
3
|
+
export declare const MIGRATION_002_PAYMENT_TYPES_ARE_THE_COUNTRY_NOT_THE_TENANT = "-- ---------------------------------------------------------------------------\n-- 002 \u2014 as formas de pagamento que existem s\u00E3o do PA\u00CDS, n\u00E3o do lojista.\n--\n-- \"Tipo de pagamento\" era um cadastro livre: cada tenant come\u00E7ava com a tabela\n-- vazia e tinha de inventar do zero que Pix existe. Isso \u00E9 o software pedindo\n-- ao usu\u00E1rio uma informa\u00E7\u00E3o que o software j\u00E1 tem \u2014 e o custo aparece na ponta:\n-- sem nenhum tipo cadastrado o modal de recebimento abre dizendo \"nenhuma forma\n-- de pagamento configurada\", e o lojista fica sem receber por causa de um\n-- cadastro que ningu\u00E9m deveria ter de fazer.\n--\n-- Pix, cart\u00E3o e boleto existem no Brasil independentemente de opini\u00E3o. O que \u00C9\n-- do lojista \u00E9 ACEITAR ou n\u00E3o cada um \u2014 e isso j\u00E1 tem coluna: `is_active`.\n--\n-- POR QUE POR TENANT E N\u00C3O GLOBAL. `tenant_id` \u00E9 NOT NULL, ent\u00E3o uma linha\n-- global n\u00E3o cabe sem mudar o schema e a RLS de uma tabela que j\u00E1 tem dados.\n-- Semear por tenant d\u00E1 o mesmo resultado vis\u00EDvel \u2014 a lista vem pronta, e o\n-- lojista liga e desliga \u2014 sem tocar em isolamento. `unit_id` continua\n-- dispon\u00EDvel para o dia em que uma filial aceitar o que a outra n\u00E3o aceita.\n--\n-- O QUE \u00C9 PADR\u00C3O LIGADO e o que vem desligado: dinheiro, Pix e os dois cart\u00F5es\n-- s\u00E3o o balc\u00E3o brasileiro e v\u00EAm aceitos. Boleto, transfer\u00EAncia e cheque existem\n-- e v\u00EAm desligados \u2014 quem usa liga, e quem n\u00E3o usa nunca v\u00EA. Um menu com sete\n-- formas onde s\u00F3 quatro s\u00E3o reais \u00E9 a mesma polui\u00E7\u00E3o que a tabela vazia, do\n-- outro lado.\n--\n-- `allowed_account_types` fala o vocabul\u00E1rio do RAZ\u00C3O \u2014 `plg_financial_accounts.kind`\n-- (bank, cash_physical, card_receivable_credit\u2026) \u2014, n\u00E3o o do cliente\n-- (`bank_account`, `cash_register`). A primeira volta desta migration copiou o\n-- mapa do PaymentModal e produziu exatamente a recusa que ela existia para\n-- evitar: \"method credit_card must settle in a card_receivable_credit account\".\n-- Quem valida \u00E9 `financial_receive_payment`, ent\u00E3o o vocabul\u00E1rio \u00E9 o dela.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.financial_seed_payment_method_types(p_tenant uuid)\n RETURNS integer\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_inserted integer := 0;\nBEGIN\n -- `transaction_type` \u00E9 a chave: o nome \u00E9 texto de tela e pode ser reescrito,\n -- o c\u00F3digo \u00E9 o que a RPC de recebimento valida. Conflito nele \u00E9 \"j\u00E1 existe\",\n -- n\u00E3o erro \u2014 reexecutar n\u00E3o duplica e n\u00E3o sobrescreve o que o lojista mexeu.\n INSERT INTO public.plg_financial_payment_method_types\n (tenant_id, name, transaction_type, is_active, allowed_account_types)\n SELECT p_tenant, d.name, d.code, d.active, d.accounts\n FROM (VALUES\n ('Dinheiro', 'cash', true, '[\"cash_physical\",\"cash_session\"]'::jsonb),\n ('Pix', 'pix', true, '[\"bank\"]'::jsonb),\n ('Cart\u00E3o de cr\u00E9dito', 'credit_card', true, '[\"card_receivable_credit\"]'::jsonb),\n ('Cart\u00E3o de d\u00E9bito', 'debit_card', true, '[\"card_receivable_debit\"]'::jsonb),\n ('Boleto', 'boleto', false, '[\"bank\"]'::jsonb),\n ('Transfer\u00EAncia banc\u00E1ria', 'bank_transfer', false, '[\"bank\"]'::jsonb),\n ('Cheque', 'check', false, '[\"bank\"]'::jsonb)\n ) AS d(name, code, active, accounts)\n WHERE NOT EXISTS (\n SELECT 1 FROM public.plg_financial_payment_method_types t\n WHERE t.tenant_id = p_tenant AND t.transaction_type = d.code\n );\n\n GET DIAGNOSTICS v_inserted = ROW_COUNT;\n RETURN v_inserted;\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_seed_payment_method_types(uuid) IS\n 'Semeia as formas de pagamento do Brasil para um tenant (002). Idempotente por transaction_type: reexecutar n\u00E3o duplica nem sobrescreve o que o lojista mudou.';\n\n-- \u2500\u2500 toda conta nova j\u00E1 nasce com a lista \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Sem isto o problema volta para o pr\u00F3ximo tenant criado, e volta silencioso: a\n-- loja abre, funciona em tudo, e falha na primeira vez que algu\u00E9m tenta receber\n-- \u2014 que \u00E9 o pior momento para descobrir um cadastro faltando.\n\nCREATE OR REPLACE FUNCTION public.trg_seed_payment_method_types()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nBEGIN\n -- N\u00E3o deixa a cria\u00E7\u00E3o da conta falhar por causa do cat\u00E1logo: um tenant sem\n -- formas de pagamento \u00E9 repar\u00E1vel (a fun\u00E7\u00E3o \u00E9 idempotente); um cadastro de\n -- workspace que n\u00E3o completa, n\u00E3o.\n BEGIN\n PERFORM public.financial_seed_payment_method_types(NEW.id);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'financial_seed_payment_method_types falhou para o tenant %: %', NEW.id, SQLERRM;\n END;\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS tenants_seed_payment_method_types ON public.tenants;\nCREATE TRIGGER tenants_seed_payment_method_types\n AFTER INSERT ON public.tenants\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_seed_payment_method_types();\n\n-- \u2500\u2500 o que j\u00E1 existe \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Um tenant migrado do pool antigo trouxe os seis tipos com nome em ingl\u00EAs\n-- (\"PIX\", \"Bank Transfer\"). O `NOT EXISTS` por c\u00F3digo preserva as linhas dele \u2014\n-- o id \u00E9 referenciado por m\u00E9todos e movimentos j\u00E1 gravados, e renomear seria\n-- mexer no que o hist\u00F3rico aponta. Ele ganha s\u00F3 o que falta (boleto).\n\nSELECT public.financial_seed_payment_method_types(t.id) FROM public.tenants t;\n\nGRANT EXECUTE ON FUNCTION public.financial_seed_payment_method_types(uuid) TO authenticated;\n";
|
|
4
|
+
export declare const MIGRATION_003_THE_COMMISSION_RULE_GETS_A_TABLE = "-- 003_the_commission_rule_gets_a_table.sql\n--\n-- A regra de comiss\u00E3o \u00E9 a \u00FAnica entidade do financeiro que a tela oferece e o\n-- chain n\u00E3o cria. O caminho at\u00E9 aqui: `commission_rules` nasceu em SQL do app\n-- (financial_extras), a 028 renomeou para o nome prefixado, e a regenera\u00E7\u00E3o do\n-- baseline (#338) n\u00E3o trouxe nenhum dos dois \u2014 a tabela sobreviveu s\u00F3 nos pools\n-- que existiam antes. Hoje o cluster n\u00E3o a tem, e `data/registries.ts` ainda\n-- pede o nome SEM prefixo, que n\u00E3o existe em pool nenhum. Ligar\n-- `modules.commissions` abre uma tela sobre nada, dos dois lados.\n--\n-- Esta migration \u00E9 escrita para os dois casos: cria onde falta, e converge o\n-- pool legado adotando a tabela que j\u00E1 est\u00E1 l\u00E1 \u2014 unit_id e owner_id s\u00E3o o que a\n-- conven\u00E7\u00E3o de escopo passou a exigir depois que ela foi criada.\n--\n-- O que ela N\u00C3O faz: calcular. A regra passa a ter onde morar; a apura\u00E7\u00E3o\n-- depende de saber quem EXECUTOU o item, que \u00E9 trabalho de outro m\u00F3dulo.\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_commission_rules (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n name text NOT NULL,\n priority integer DEFAULT 1 NOT NULL,\n value_type text DEFAULT 'percentage'::text NOT NULL,\n value numeric(14,4) DEFAULT 0 NOT NULL,\n description text,\n is_active boolean DEFAULT true NOT NULL,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\n-- Converg\u00EAncia do pool legado: a tabela existe l\u00E1 sem as colunas de escopo.\nALTER TABLE public.plg_financial_commission_rules ADD COLUMN IF NOT EXISTS unit_id uuid;\nALTER TABLE public.plg_financial_commission_rules ADD COLUMN IF NOT EXISTS owner_id uuid;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_commission_rules'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rules ADD CONSTRAINT plg_financial_commission_rules_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rules_tenant_id_id_key') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rules ADD CONSTRAINT plg_financial_commission_rules_tenant_id_id_key UNIQUE (tenant_id, id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rules_value_type_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rules ADD CONSTRAINT plg_financial_commission_rules_value_type_check\n CHECK (value_type = ANY (ARRAY['percentage'::text, 'fixed'::text]));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rules_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rules ADD CONSTRAINT plg_financial_commission_rules_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rules_unit_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rules ADD CONSTRAINT plg_financial_commission_rules_unit_fk\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rules_owner_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rules ADD CONSTRAINT plg_financial_commission_rules_owner_fk\n FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS idx_plg_financial_commission_rules_tenant\n ON public.plg_financial_commission_rules USING btree (tenant_id);\nCREATE INDEX IF NOT EXISTS plg_financial_commission_rules_tenant_unit_idx\n ON public.plg_financial_commission_rules USING btree (tenant_id, unit_id);\nCREATE INDEX IF NOT EXISTS plg_financial_commission_rules_tenant_owner_idx\n ON public.plg_financial_commission_rules USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n-- A apura\u00E7\u00E3o l\u00EA por prioridade decrescente, s\u00F3 as ativas.\nCREATE INDEX IF NOT EXISTS plg_financial_commission_rules_active_priority_idx\n ON public.plg_financial_commission_rules USING btree (tenant_id, priority DESC) WHERE is_active;\n\nDROP TRIGGER IF EXISTS plg_financial_commission_rules_updated_at ON public.plg_financial_commission_rules;\nCREATE TRIGGER plg_financial_commission_rules_updated_at\n BEFORE UPDATE ON public.plg_financial_commission_rules\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_commission_rules ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_commission_rules FORCE ROW LEVEL SECURITY;\n\n-- Mesmo recorte de plg_financial_cost_centers: tenant, unidade, dono e permiss\u00E3o.\n-- O escopo 'financial.commission_rule' \u00E9 o que a lista de owner_scoped consulta.\nDROP POLICY IF EXISTS plg_financial_commission_rules_authz_select ON public.plg_financial_commission_rules;\nCREATE POLICY plg_financial_commission_rules_authz_select ON public.plg_financial_commission_rules\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND ((NOT (SELECT app.owner_scoped('financial.commission_rule')))\n OR owner_id IS NULL\n OR owner_id = (SELECT auth.uid())\n OR (SELECT app.has_grant('financial.commission_rule', id, ARRAY['shared_with']))\n OR (SELECT app.has_permission('financial.read_all', unit_id)))\n AND (SELECT app.has_permission('financial.read', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_commission_rules_authz_insert ON public.plg_financial_commission_rules;\nCREATE POLICY plg_financial_commission_rules_authz_insert ON public.plg_financial_commission_rules\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND (SELECT app.has_permission('financial.create', unit_id)));\n\n-- `financial.update` N\u00C3O existe no cat\u00E1logo: a chave da casa \u00E9 `financial.edit`\n-- (app.permissions). Uma policy sobre chave inexistente nega tudo em sil\u00EAncio.\nDROP POLICY IF EXISTS plg_financial_commission_rules_authz_update ON public.plg_financial_commission_rules;\nCREATE POLICY plg_financial_commission_rules_authz_update ON public.plg_financial_commission_rules\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND ((NOT (SELECT app.owner_scoped('financial.commission_rule')))\n OR owner_id IS NULL\n OR owner_id = (SELECT auth.uid())\n OR (SELECT app.has_grant('financial.commission_rule', id, ARRAY['shared_with']))\n OR (SELECT app.has_permission('financial.read_all', unit_id)))\n AND (SELECT app.has_permission('financial.edit', unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND (SELECT app.has_permission('financial.edit', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_commission_rules_authz_delete ON public.plg_financial_commission_rules;\nCREATE POLICY plg_financial_commission_rules_authz_delete ON public.plg_financial_commission_rules\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND ((NOT (SELECT app.owner_scoped('financial.commission_rule')))\n OR owner_id IS NULL\n OR owner_id = (SELECT auth.uid())\n OR (SELECT app.has_grant('financial.commission_rule', id, ARRAY['shared_with']))\n OR (SELECT app.has_permission('financial.read_all', unit_id)))\n AND (SELECT app.has_permission('financial.delete', unit_id)));\n\n-- app.owner_scoped() devolve TRUE quando o resource_type n\u00E3o est\u00E1 registrado\n-- (\"configuration relaxes; it never tightens by omission\"). Sem esta linha a\n-- regra nasceria com escopo de dono, diferente de todo o resto do cadastro\n-- financeiro \u2014 que \u00E9 `false`, como o centro de custo.\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_commission_rules', 'financial.commission_rule', 'financial', false, 'enforce', ARRAY[]::text[], false, 'unit_optional', false)\nON CONFLICT DO NOTHING;\n\nGRANT ALL ON TABLE public.plg_financial_commission_rules TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_commission_rules TO service_role;\n\nCOMMENT ON TABLE public.plg_financial_commission_rules IS\n 'Regra de comiss\u00E3o: percentual ou fixo, aplicada por prioridade. Guarda a REGRA; a apura\u00E7\u00E3o por atendimento \u00E9 outra tabela e depende de saber quem executou o item.';\n";
|
|
5
|
+
export declare const MIGRATION_004_ONE_BRIDGE_FOR_THREE_DOORS = "-- 004_one_bridge_for_three_doors.sql\n--\n-- Tr\u00EAs portas levam uma venda ao Financeiro \u2014 a comanda que fecha, o checkout\n-- da loja, o atendimento que termina \u2014 e elas discordavam em tr\u00EAs pontos que\n-- 006_three_doors_one_book.sql agora cobra. Este arquivo alinha a ponte da\n-- loja (`fn_invoice_from_order_internal`) com a da comanda\n-- (`order_raise_receivable`), que j\u00E1 estava certa.\n--\n-- 1. O PEDIDO PARA DE PERDER O N\u00DAMERO DELE.\n--\n-- A fun\u00E7\u00E3o reescrevia `orders.reference_number` com o n\u00FAmero da fatura.\n-- Isso vem da origem dela \u2014 promover um OR\u00C7AMENTO em fatura, onde o\n-- documento realmente muda de nome \u2014 e \u00E9 destrutivo em tudo o mais: no\n-- cluster, toda venda e o \u00FAnico atendimento faturado passaram a se chamar\n-- `REC-xxxxx`, e o lojista perdeu o n\u00FAmero pelo qual procura o pedido.\n--\n-- O n\u00FAmero da fatura nunca precisou morar ali: `v_invoice_balances`\n-- (que a loja l\u00EA) tira `reference_number` do N\u00DAMERO DA FATURA, n\u00E3o do\n-- pedido, e `metadata.invoiceNumber` j\u00E1 guardava o mesmo valor. Ningu\u00E9m\n-- perde informa\u00E7\u00E3o; o pedido recupera o nome.\n--\n-- 2. A PARCELA GANHA UNIDADE.\n--\n-- O INSERT n\u00E3o passava `unit_id`, ent\u00E3o 19 das 24 parcelas do cluster\n-- estavam sem unidade. Quem enxerga s\u00F3 a pr\u00F3pria unidade n\u00E3o via a pr\u00F3pria\n-- parcela \u2014 o extrato dela mentia por omiss\u00E3o, e n\u00E3o por erro de conta.\n-- `tenant_id` j\u00E1 vinha por gatilho; a unidade n\u00E3o.\n--\n-- 3. O `p_status` DEIXA DE SER UMA ARMADILHA.\n--\n-- A fun\u00E7\u00E3o for\u00E7a `status = p_status`, e o guard de esp\u00E9cie recusa um status\n-- que a esp\u00E9cie n\u00E3o declara. `invoiced` est\u00E1 no ciclo de `appointment` e\n-- N\u00C3O no de `sale` \u2014 ent\u00E3o a mesma chamada que a agenda faz estoura com\n-- 23514 numa venda. A loja s\u00F3 funcionava porque passava NULL, e isso n\u00E3o\n-- estava escrito em lugar nenhum.\n--\n-- O faturamento j\u00E1 \u00E9 registrado em `stage = 'invoiced'`, que n\u00E3o \u00E9 o ciclo\n-- de vida da esp\u00E9cie. Ent\u00E3o: aplica-se `p_status` quando a esp\u00E9cie o\n-- declara, e ignora-se quando n\u00E3o \u2014 em vez de derrubar o faturamento por\n-- causa de uma palavra que aquele tipo de pedido n\u00E3o usa.\n\n-- \u2500\u2500 a ponte \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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::date,\n p_status text DEFAULT NULL::text,\n p_installments integer DEFAULT 1,\n p_direction text DEFAULT 'credit'::text\n) RETURNS uuid\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_invoice_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 v_status text;\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 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 -- A pool promoted before 016 carries legacy bill rows and no invoice; it is\n -- already invoiced and must not be invoiced twice.\n IF EXISTS (SELECT 1 FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill'\n AND status <> 'cancelled') 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.items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n -- S\u00F3 o status que a esp\u00E9cie declara. Um pedido faturado \u00E9 `stage =\n -- 'invoiced'`; o `status` continua sendo o ciclo de vida daquele tipo de\n -- pedido, e for\u00E7ar nele uma palavra emprestada de outra esp\u00E9cie faz o guard\n -- derrubar o faturamento inteiro.\n v_status := v_order.status;\n IF p_status IS NOT NULL AND EXISTS (\n SELECT 1 FROM public.order_kinds k\n WHERE k.kind = v_order.kind\n AND (k.statuses IS NULL OR p_status = ANY (k.statuses))\n ) THEN\n v_status := p_status;\n END IF;\n\n UPDATE public.orders SET\n status = v_status,\n stage = 'invoiced',\n direction = p_direction,\n -- `reference_number` N\u00C3O entra aqui: o pedido continua se chamando pelo\n -- n\u00FAmero dele. O n\u00FAmero da fatura vai para metadata, onde j\u00E1 ia.\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', '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 -- tenant E unidade: sem a unidade, quem l\u00EA escopado \u00E0 pr\u00F3pria unidade n\u00E3o\n -- enxerga a pr\u00F3pria parcela.\n INSERT INTO public.plg_financial_invoice_installments\n (tenant_id, unit_id, invoice_id, number, due_date, amount)\n VALUES (v_order.tenant_id, v_order.unit_id, v_invoice_id, i,\n (v_due + ((i - 1) || ' months')::interval)::date, v_amt);\n -- No mirror. v_financial_bills gives the legacy shape to whoever still\n -- wants it, derived from exactly this row.\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\n\nCOMMENT ON FUNCTION public.fn_invoice_from_order_internal(uuid, date, text, integer, text) IS\n 'Promove a venda em fatura sem renomear o pedido, com unidade na parcela e sem for\u00E7ar status que a esp\u00E9cie n\u00E3o declara (004).';\n\n-- \u2500\u2500 reparo: devolver aos pedidos o n\u00FAmero que era deles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Guardado ao m\u00E1ximo de prop\u00F3sito: s\u00F3 mexe onde o n\u00FAmero ATUAL \u00E9 exatamente o\n-- da fatura (`invoiceNumber`) E existe o antigo guardado (`quoteNumber`). Um\n-- pedido que nunca passou por aqui, ou que j\u00E1 foi renomeado por outra raz\u00E3o,\n-- n\u00E3o \u00E9 tocado.\nUPDATE public.orders o SET\n reference_number = o.metadata ->> 'quoteNumber',\n updated_at = now()\nWHERE o.metadata ? 'quoteNumber'\n AND o.metadata ? 'invoiceNumber'\n AND nullif(o.metadata ->> 'quoteNumber', '') IS NOT NULL\n AND o.reference_number IS NOT DISTINCT FROM (o.metadata ->> 'invoiceNumber')\n AND (o.metadata ->> 'quoteNumber') IS DISTINCT FROM (o.metadata ->> 'invoiceNumber');\n\n-- \u2500\u2500 reparo: a parcela que nasceu sem unidade herda a da fatura \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nUPDATE public.plg_financial_invoice_installments p SET\n unit_id = f.unit_id,\n updated_at = now()\nFROM public.plg_financial_invoices f\nWHERE f.id = p.invoice_id\n AND p.unit_id IS NULL\n AND f.unit_id IS NOT NULL;\n";
|
|
6
|
+
export declare const MIGRATION_005_THE_APPOINTMENT_CLOSES_LIKE_THE_COMANDA = "-- 005_the_appointment_closes_like_the_comanda.sql\n--\n-- Concluir um atendimento n\u00E3o levantava conta a receber nenhuma. No cluster:\n-- 21 pedidos `appointment`, 1 fatura. A comanda fecha e vira receb\u00EDvel desde a\n-- 001; o atendimento, n\u00E3o \u2014 e o sal\u00E3o inteiro passava ao largo do Financeiro\n-- pelo mesmo motivo pelo qual o restaurante passava antes dela.\n--\n-- A 001 deixou isso explicitamente em aberto: \"se a agenda deve faturar ao\n-- concluir \u00E9 uma decis\u00E3o da agenda, tomada por quem conhece o fluxo dela, n\u00E3o\n-- um efeito colateral desta migration\". A decis\u00E3o, agora tomada, \u00E9 SIM \u2014 e a\n-- raz\u00E3o \u00E9 o fluxo real da recep\u00E7\u00E3o:\n--\n-- agendar \u2192 confirmar \u2192 CHECK-IN \u2192 atender \u2192 concluir \u2192 receber\n--\n-- No V1 o pagamento n\u00E3o bloqueia o check-in (decis\u00E3o de produto deles, e boa:\n-- a cliente j\u00E1 est\u00E1 na porta). Quem recebe abre a ficha e cobra o que est\u00E1 em\n-- aberto. Para existir \"o que est\u00E1 em aberto\", a conta precisa ter sido\n-- levantada \u2014 e o momento em que ela passa a ser devida \u00E9 quando o servi\u00E7o foi\n-- prestado, n\u00E3o quando algu\u00E9m lembra de faturar.\n--\n-- POR QUE order_raise_receivable, e n\u00E3o agenda_booking_invoice(). A segunda\n-- existe, e n\u00E3o tem um \u00FAnico chamador em TypeScript. Ela chama\n-- fn_invoice_from_order, que exige `financial.create` de QUEM CHAMA \u2014 e quem\n-- conclui um atendimento \u00E9 a recep\u00E7\u00E3o, n\u00E3o o financeiro. Exigir a permiss\u00E3o\n-- faria a conclus\u00E3o falhar para a pessoa certa. \u00C9 o mesmo racioc\u00EDnio que a 001\n-- fez para o gar\u00E7om, e a mesma fun\u00E7\u00E3o resolve.\n--\n-- agenda_booking_invoice() continua onde est\u00E1, para quem quiser faturar um\n-- atendimento explicitamente, em N parcelas, sendo do Financeiro.\n\nCREATE OR REPLACE FUNCTION public.trg_order_raise_receivable_on_close()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nBEGIN\n -- Duas esp\u00E9cies, uma regra: o servi\u00E7o acabou, ent\u00E3o a conta \u00E9 devida.\n -- `sale` n\u00E3o entra \u2014 a loja fatura no checkout, pela extens\u00E3o dela, e o\n -- pedido de loja nasce fechado.\n IF NEW.kind IN ('dine_in', 'appointment', 'service_order')\n AND NEW.status = 'completed'\n AND coalesce(OLD.status, '') IS DISTINCT FROM 'completed' THEN\n -- Nunca deixa o fechamento falhar por causa da escritura\u00E7\u00E3o: o atendimento\n -- terminou de verdade, e uma fatura que n\u00E3o subiu \u00E9 repar\u00E1vel (a fun\u00E7\u00E3o \u00E9\n -- idempotente, ent\u00E3o reprocessar \u00E9 seguro). O contr\u00E1rio \u2014 a recep\u00E7\u00E3o n\u00E3o\n -- conseguir concluir porque o Financeiro recusou \u2014 n\u00E3o \u00E9.\n BEGIN\n PERFORM public.order_raise_receivable(NEW.id);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'order_raise_receivable falhou para o pedido %: %', NEW.id, SQLERRM;\n END;\n END IF;\n RETURN NEW;\nEND $$;\n\nCOMMENT ON FUNCTION public.trg_order_raise_receivable_on_close() IS\n 'A comanda, o atendimento e a ordem de servi\u00E7o levantam o receb\u00EDvel ao concluir (001, 005). A loja fatura no checkout, pela extens\u00E3o dela.';\n\n-- \u2500\u2500 o que j\u00E1 foi conclu\u00EDdo e nunca foi faturado \u2500\u2500\u2500\u2500\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-- Idempotente por constru\u00E7\u00E3o: order_raise_receivable devolve a fatura que j\u00E1\n-- existe em vez de criar a segunda, e recusa pedido de total zero.\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN\n SELECT o.id FROM public.orders o\n WHERE o.kind IN ('appointment', 'service_order')\n AND o.status = 'completed'\n AND coalesce(o.total, 0) > 0\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_invoices f\n WHERE f.order_id = o.id AND f.cancelled_at IS NULL)\n LOOP\n BEGIN\n PERFORM public.order_raise_receivable(r.id);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'backfill: order_raise_receivable falhou para %: %', r.id, SQLERRM;\n END;\n END LOOP;\nEND $$;\n";
|
|
7
|
+
export declare const MIGRATION_006_THE_MONTH_CAN_BE_CLOSED_AND_THE_BOOK_CAN_BE_PROVEN = "-- 006_the_month_can_be_closed_and_the_book_can_be_proven.sql\n--\n-- Duas aus\u00EAncias que separam \"telas de financeiro\" de \"um livro cont\u00E1bil\", e\n-- que ficam mais caras a cada tela nova constru\u00EDda sem elas.\n--\n-- 1. N\u00C3O EXISTE FECHAMENTO DE COMPET\u00CANCIA. Nenhuma tabela de per\u00EDodo, nenhuma\n-- trava. Qualquer lan\u00E7amento pode ser datado para dentro de um m\u00EAs j\u00E1\n-- entregue ao contador \u2014 e o DRE daquele m\u00EAs muda depois de ter sido\n-- reportado, em sil\u00EAncio. Um relat\u00F3rio que pode mudar depois de fechado n\u00E3o\n-- \u00E9 um relat\u00F3rio, \u00E9 uma consulta.\n--\n-- 2. NADA PROVA QUE O LIVRO FECHA. O movimento j\u00E1 nasce com duas pernas\n-- (`debit_account_id` e `credit_account_id`) \u2014 a partida dobrada est\u00E1 no\n-- modelo. O que nunca existiu foi a soma: ningu\u00E9m jamais afirmou que o total\n-- a d\u00E9bito \u00E9 igual ao total a cr\u00E9dito. Estamos a uma view de poder afirmar,\n-- e essa afirma\u00E7\u00E3o \u00E9 a diferen\u00E7a entre audit\u00E1vel e \"confia\".\n--\n-- POR QUE AS DUAS JUNTAS. Balancete sem fechamento \u00E9 um n\u00FAmero que muda quando\n-- algu\u00E9m lan\u00E7a retroativo; fechamento sem balancete \u00E9 uma tranca sem nada\n-- provado dentro. Uma \u00E9 a foto, a outra \u00E9 a moldura.\n--\n-- A COMPET\u00CANCIA \u00C9 DO TENANT, n\u00E3o da unidade. Fechar o m\u00EAs \u00E9 um ato cont\u00E1bil da\n-- empresa; fragmentar por unidade produziria um m\u00EAs meio fechado, que \u00E9 a pior\n-- das respostas para quem precisa dizer ao contador que acabou.\n\n-- \u2500\u2500 o per\u00EDodo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_periods (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n -- Sempre o dia 1\u00BA: a compet\u00EAncia \u00E9 o M\u00CAS, e guardar o m\u00EAs como data evita\n -- a dupla (ano int, m\u00EAs int) que todo relat\u00F3rio teria de remontar.\n competence_month date NOT NULL,\n status text DEFAULT 'open'::text NOT NULL,\n closed_at timestamp with time zone,\n closed_by_user_id uuid,\n reopened_at timestamp with time zone,\n reopened_by_user_id uuid,\n -- Reabrir sem dizer por qu\u00EA \u00E9 como n\u00E3o ter fechado. O motivo \u00E9 obrigat\u00F3rio\n -- na RPC; a coluna aceita nulo porque um per\u00EDodo nunca reaberto n\u00E3o tem um.\n reopen_reason text,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_periods'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_periods ADD CONSTRAINT plg_financial_periods_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_periods_tenant_month_key') THEN\n ALTER TABLE ONLY public.plg_financial_periods ADD CONSTRAINT plg_financial_periods_tenant_month_key UNIQUE (tenant_id, competence_month);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_periods_status_check') THEN\n ALTER TABLE ONLY public.plg_financial_periods ADD CONSTRAINT plg_financial_periods_status_check\n CHECK (status = ANY (ARRAY['open'::text, 'closed'::text]));\n END IF;\n -- O dia 1\u00BA n\u00E3o \u00E9 conven\u00E7\u00E3o de quem escreve: \u00E9 constraint.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_periods_first_of_month_check') THEN\n ALTER TABLE ONLY public.plg_financial_periods ADD CONSTRAINT plg_financial_periods_first_of_month_check\n CHECK (competence_month = date_trunc('month', competence_month)::date);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_periods_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_periods ADD CONSTRAINT plg_financial_periods_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_periods_tenant_month_idx\n ON public.plg_financial_periods USING btree (tenant_id, competence_month DESC);\n-- A trava consulta \"este m\u00EAs est\u00E1 fechado?\" a cada lan\u00E7amento: \u00EDndice parcial.\nCREATE INDEX IF NOT EXISTS plg_financial_periods_closed_idx\n ON public.plg_financial_periods USING btree (tenant_id, competence_month) WHERE status = 'closed';\n\nDROP TRIGGER IF EXISTS plg_financial_periods_updated_at ON public.plg_financial_periods;\nCREATE TRIGGER plg_financial_periods_updated_at\n BEFORE UPDATE ON public.plg_financial_periods\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_periods ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_periods FORCE ROW LEVEL SECURITY;\n\n-- Quem l\u00EA o Financeiro v\u00EA o estado do m\u00EAs \u2014 saber que fevereiro fechou n\u00E3o \u00E9\n-- informa\u00E7\u00E3o privilegiada, e a tela precisa disso para desabilitar o bot\u00E3o.\nDROP POLICY IF EXISTS plg_financial_periods_authz_select ON public.plg_financial_periods;\nCREATE POLICY plg_financial_periods_authz_select ON public.plg_financial_periods\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL)));\n\n-- ESCREVER \u00C9 S\u00D3 PELA RPC. Sem policy de INSERT/UPDATE/DELETE: fechar e reabrir\n-- s\u00E3o atos, n\u00E3o edi\u00E7\u00F5es de linha, e o ato \u00E9 onde a permiss\u00E3o e o registro de\n-- QUEM moram. Uma policy de UPDATE aqui seria a porta dos fundos da trava.\n\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_periods', 'financial.period', 'financial', false, 'enforce', ARRAY[]::text[], true, 'tenant', false)\nON CONFLICT DO NOTHING;\n\nGRANT SELECT ON TABLE public.plg_financial_periods TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_periods TO service_role;\n\nCOMMENT ON TABLE public.plg_financial_periods IS\n 'Compet\u00EAncia mensal e seu estado. Fechado = nada mais entra nem muda com data naquele m\u00EAs. Escrita s\u00F3 por financial_period_close/reopen (006).';\n\n-- \u2500\u2500 a permiss\u00E3o de fechar \u00E9 pr\u00F3pria \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Segrega\u00E7\u00E3o de fun\u00E7\u00E3o: quem lan\u00E7a n\u00E3o \u00E9 necessariamente quem fecha, e quem\n-- fecha n\u00E3o \u00E9 necessariamente quem reabre. `financial.edit` \u00E9 a permiss\u00E3o de\n-- mexer num t\u00EDtulo; fechar o m\u00EAs \u00E9 outra ordem de decis\u00E3o.\nINSERT INTO app.permissions (key, plugin, description)\nVALUES ('financial.close_period', 'financial',\n 'Fechar e reabrir a compet\u00EAncia mensal. Separada de financial.edit de prop\u00F3sito: quem lan\u00E7a n\u00E3o \u00E9 quem fecha.')\nON CONFLICT (key) DO NOTHING;\n\n-- \u2500\u2500 a trava \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_period_is_closed(p_tenant uuid, p_date date)\n RETURNS boolean\n LANGUAGE sql\n STABLE\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\n SELECT EXISTS (\n SELECT 1 FROM public.plg_financial_periods p\n WHERE p.tenant_id = p_tenant\n AND p.status = 'closed'\n AND p.competence_month = date_trunc('month', p_date)::date\n );\n$$;\n\nCOMMENT ON FUNCTION public.plg_financial_period_is_closed(uuid, date) IS\n 'O m\u00EAs desta data est\u00E1 fechado para este tenant? (006)';\n\nCREATE OR REPLACE FUNCTION public.trg_financial_refuse_closed_period()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_date date;\n v_tenant uuid;\nBEGIN\n v_tenant := NEW.tenant_id;\n\n -- A data que conta \u00E9 a da COMPET\u00CANCIA, n\u00E3o a de cria\u00E7\u00E3o da linha. Num t\u00EDtulo\n -- \u00E9 `competence_date`; num movimento \u00E9 a data em que o dinheiro andou.\n IF TG_TABLE_NAME = 'plg_financial_invoices' THEN\n v_date := coalesce(NEW.competence_date, NEW.issue_date, current_date);\n ELSE\n v_date := coalesce(NEW.payment_date, NEW.due_date, current_date);\n END IF;\n\n IF public.plg_financial_period_is_closed(v_tenant, v_date) THEN\n RAISE EXCEPTION 'a compet\u00EAncia de % est\u00E1 fechada \u2014 reabra o per\u00EDodo para lan\u00E7ar nela', to_char(v_date, 'MM/YYYY')\n USING ERRCODE = '23514',\n HINT = 'financial_period_reopen(date_trunc(''month'', data)::date, ''motivo'') exige financial.close_period.';\n END IF;\n\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_financial_invoices_refuse_closed_period ON public.plg_financial_invoices;\nCREATE TRIGGER plg_financial_invoices_refuse_closed_period\n BEFORE INSERT OR UPDATE OF competence_date, total ON public.plg_financial_invoices\n FOR EACH ROW EXECUTE FUNCTION public.trg_financial_refuse_closed_period();\n\nDROP TRIGGER IF EXISTS plg_financial_movements_refuse_closed_period ON public.plg_financial_movements;\nCREATE TRIGGER plg_financial_movements_refuse_closed_period\n BEFORE INSERT ON public.plg_financial_movements\n FOR EACH ROW EXECUTE FUNCTION public.trg_financial_refuse_closed_period();\n\n-- \u2500\u2500 fechar e reabrir \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_period_close(p_month date, p_notes text DEFAULT NULL)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_month date := date_trunc('month', p_month)::date;\n v_open numeric;\n v_row public.plg_financial_periods%ROWTYPE;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_period_close: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.close_period', NULL) THEN\n RAISE EXCEPTION 'financial_period_close: exige financial.close_period' USING ERRCODE = '42501';\n END IF;\n\n -- Um m\u00EAs no futuro n\u00E3o acabou; fech\u00E1-lo trancaria lan\u00E7amentos que ainda v\u00E3o\n -- acontecer no curso normal do neg\u00F3cio.\n IF v_month > date_trunc('month', current_date)::date THEN\n RAISE EXCEPTION 'financial_period_close: % ainda n\u00E3o terminou', to_char(v_month, 'MM/YYYY')\n USING ERRCODE = '22023';\n END IF;\n\n -- Caixa aberto \u00E9 dinheiro que ainda vai se mexer naquele m\u00EAs. Fechar por cima\n -- produz um saldo que muda depois de fechado, que \u00E9 exatamente o que a trava\n -- existe para impedir.\n IF EXISTS (SELECT 1 FROM public.plg_financial_cash_register_sessions s\n WHERE s.tenant_id = v_tenant AND s.status = 'open'\n AND date_trunc('month', s.opened_at)::date <= v_month) THEN\n RAISE EXCEPTION 'financial_period_close: existe caixa aberto em % ou antes \u2014 feche o caixa primeiro', to_char(v_month, 'MM/YYYY')\n USING ERRCODE = '23514';\n END IF;\n\n INSERT INTO public.plg_financial_periods (tenant_id, competence_month, status, closed_at, closed_by_user_id, notes)\n VALUES (v_tenant, v_month, 'closed', now(), auth.uid(), p_notes)\n ON CONFLICT (tenant_id, competence_month) DO UPDATE\n SET status = 'closed', closed_at = now(), closed_by_user_id = auth.uid(),\n notes = coalesce(EXCLUDED.notes, public.plg_financial_periods.notes), updated_at = now()\n RETURNING * INTO v_row;\n\n SELECT coalesce(sum(t.balance), 0) INTO v_open\n FROM public.v_financial_installment_balances t\n WHERE t.tenant_id = v_tenant AND t.status <> 'cancelled'\n AND date_trunc('month', t.due_date)::date <= v_month;\n\n RETURN jsonb_build_object(\n 'period_id', v_row.id, 'competence_month', v_month, 'status', 'closed',\n 'closed_at', v_row.closed_at,\n -- Fechar n\u00E3o quita: o que estava em aberto continua em aberto, e quem fecha\n -- merece ver o n\u00FAmero em vez de descobrir depois.\n 'open_balance_carried', v_open);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_period_reopen(p_month date, p_reason text)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_month date := date_trunc('month', p_month)::date;\n v_row public.plg_financial_periods%ROWTYPE;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_period_reopen: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.close_period', NULL) THEN\n RAISE EXCEPTION 'financial_period_reopen: exige financial.close_period' USING ERRCODE = '42501';\n END IF;\n -- Reabrir sem motivo registrado \u00E9 como n\u00E3o ter fechado: some o rastro de por\n -- que um m\u00EAs entregue voltou a mudar.\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'financial_period_reopen: o motivo \u00E9 obrigat\u00F3rio' USING ERRCODE = '22023';\n END IF;\n\n UPDATE public.plg_financial_periods SET\n status = 'open', reopened_at = now(), reopened_by_user_id = auth.uid(),\n reopen_reason = p_reason, updated_at = now()\n WHERE tenant_id = v_tenant AND competence_month = v_month\n RETURNING * INTO v_row;\n\n IF v_row.id IS NULL THEN\n RAISE EXCEPTION 'financial_period_reopen: % nunca foi fechado', to_char(v_month, 'MM/YYYY')\n USING ERRCODE = 'P0002';\n END IF;\n\n RETURN jsonb_build_object('period_id', v_row.id, 'competence_month', v_month,\n 'status', 'open', 'reopened_at', v_row.reopened_at, 'reason', p_reason);\nEND $$;\n\nREVOKE ALL ON FUNCTION public.financial_period_close(date, text) FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.financial_period_reopen(date, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.financial_period_close(date, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_period_reopen(date, text) TO authenticated;\n\n-- \u2500\u2500 o balancete \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A partida dobrada j\u00E1 est\u00E1 no dado: todo pagamento nasce com as duas pernas.\n-- Esta view s\u00F3 faz a soma que ningu\u00E9m fazia \u2014 cada conta com o que entrou a\n-- d\u00E9bito e a cr\u00E9dito, e o saldo.\n--\n-- S\u00F3 movimentos LIQUIDADOS e n\u00E3o estornados: uma parcela em aberto \u00E9 uma\n-- promessa, n\u00E3o um lan\u00E7amento, e som\u00E1-la ao balancete faria o livro n\u00E3o fechar\n-- por motivo certo.\n--\n-- `security_invoker` para a RLS de plg_financial_movements continuar decidindo\n-- quem v\u00EA o qu\u00EA \u2014 um balancete que ignora escopo \u00E9 um vazamento com totais.\nCREATE OR REPLACE VIEW public.v_financial_trial_balance\nWITH (security_invoker = 'true') AS\nWITH legs AS (\n SELECT m.tenant_id, m.unit_id, m.debit_account_id AS account_id,\n date_trunc('month', coalesce(m.payment_date, m.due_date))::date AS competence_month,\n m.paid_amount AS debit, 0::numeric AS credit\n FROM public.plg_financial_movements m\n WHERE m.status = 'paid' AND m.reversed_by_id IS NULL AND m.debit_account_id IS NOT NULL\n UNION ALL\n SELECT m.tenant_id, m.unit_id, m.credit_account_id,\n date_trunc('month', coalesce(m.payment_date, m.due_date))::date,\n 0::numeric, m.paid_amount\n FROM public.plg_financial_movements m\n WHERE m.status = 'paid' AND m.reversed_by_id IS NULL AND m.credit_account_id IS NOT NULL\n)\nSELECT l.tenant_id,\n l.unit_id,\n l.competence_month,\n l.account_id,\n a.code AS account_code,\n a.name AS account_name,\n a.account_type,\n a.kind AS account_kind,\n sum(l.debit)::numeric(14,2) AS total_debit,\n sum(l.credit)::numeric(14,2) AS total_credit,\n (sum(l.debit) - sum(l.credit))::numeric(14,2) AS balance\n FROM legs l\n JOIN public.plg_financial_accounts a ON a.id = l.account_id\n GROUP BY l.tenant_id, l.unit_id, l.competence_month, l.account_id,\n a.code, a.name, a.account_type, a.kind;\n\nCOMMENT ON VIEW public.v_financial_trial_balance IS\n 'Balancete: cada conta com d\u00E9bito, cr\u00E9dito e saldo, por compet\u00EAncia. S\u00F3 movimentos liquidados e n\u00E3o estornados. A soma dos d\u00E9bitos tem de bater com a dos cr\u00E9ditos \u2014 \u00E9 a afirma\u00E7\u00E3o que torna o livro audit\u00E1vel (006).';\n\nGRANT SELECT ON public.v_financial_trial_balance TO authenticated;\nGRANT SELECT ON public.v_financial_trial_balance TO service_role;\n";
|
|
8
|
+
export declare const MIGRATION_007_THE_INVOICE_KNOWS_WHO_ISSUED_IT_AND_WHAT_IT_OWES = "-- 007_the_invoice_knows_who_issued_it_and_what_it_owes.sql\n--\n-- Dois buracos que impedem qualquer obriga\u00E7\u00E3o fiscal brasileira, e que a\n-- reforma tribut\u00E1ria transforma de \"falta\" em \"impede de operar\".\n--\n-- 1. A FATURA N\u00C3O SABIA QUEM A EMITIU.\n--\n-- `company_legal_entities` existe, com CNPJ e regime tribut\u00E1rio, e uma\n-- unidade aponta para a sua (v_unit_company_legal_entities). A fatura, n\u00E3o:\n-- ZERO colunas fiscais em plg_financial_invoices. Um tenant com quatro\n-- CNPJs \u2014 que \u00E9 o caso do sal\u00E3o no cluster \u2014 n\u00E3o conseguia dizer qual deles\n-- vendeu. Cada CNPJ apura e declara o SEU IBS/CBS; sem o emitente na\n-- fatura, n\u00E3o h\u00E1 o que apurar.\n--\n-- 2. O IMPOSTO ERA UM N\u00DAMERO S\u00D3.\n--\n-- A linha tinha `tax_amount numeric` \u2014 um campo. CBS e IBS s\u00E3o dois tributos\n-- distintos, com bases, al\u00EDquotas e CADEIAS DE CR\u00C9DITO separadas, e durante\n-- a transi\u00E7\u00E3o (2026\u20132032) convivem com PIS/COFINS/ICMS/ISS, que v\u00E3o saindo\n-- em datas diferentes. Um campo n\u00E3o carrega isso em nenhum ano do\n-- cronograma, e um par de campos novos por tributo envelheceria a cada\n-- etapa.\n--\n-- Ent\u00E3o: uma LINHA POR TRIBUTO. Em 2026 a linha tem CBS a 0,9% e IBS a\n-- 0,1% ao lado de PIS/COFINS; em 2033 tem CBS e IBS e mais nada. A forma\n-- n\u00E3o muda \u2014 muda o conte\u00FAdo, que \u00E9 o que uma transi\u00E7\u00E3o de sete anos exige.\n--\n-- SOBRE O SPLIT PAYMENT, e por que ele N\u00C3O entra aqui.\n--\n-- A partir de 2027 o banco/adquirente separa o tributo na liquida\u00E7\u00E3o e\n-- deposita o l\u00EDquido. Contabilmente isso \u00E9 UM lan\u00E7amento de TR\u00CAS pernas:\n-- d\u00E9bito banco (l\u00EDquido) + d\u00E9bito imposto retido / cr\u00E9dito a receber\n-- (bruto). O `plg_financial_movements` tem exatamente DUAS pernas.\n--\n-- A sa\u00EDda certa dentro do modelo \u00E9 DOIS movimentos alocados \u00E0 mesma\n-- parcela \u2014 o l\u00EDquido e o retido \u2014 que somam o bruto e mant\u00EAm o livro\n-- fechando. \u00C9 desenho, n\u00E3o migration: fica registrado aqui e \u00E9 o pr\u00F3ximo\n-- passo, depois que a apura\u00E7\u00E3o souber quanto reter.\n\n-- \u2500\u2500 1. o emitente \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_invoices\n ADD COLUMN IF NOT EXISTS company_legal_entity_id uuid;\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_invoices_legal_entity_fk') THEN\n ALTER TABLE ONLY public.plg_financial_invoices\n ADD CONSTRAINT plg_financial_invoices_legal_entity_fk\n FOREIGN KEY (company_legal_entity_id) REFERENCES public.company_legal_entities(id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_invoices_legal_entity_idx\n ON public.plg_financial_invoices USING btree (tenant_id, company_legal_entity_id)\n WHERE company_legal_entity_id IS NOT NULL;\n\nCOMMENT ON COLUMN public.plg_financial_invoices.company_legal_entity_id IS\n 'O CNPJ que emitiu. Cada pessoa jur\u00EDdica apura o SEU IBS/CBS \u2014 sem isto, um tenant com v\u00E1rios CNPJs n\u00E3o tem o que declarar (007).';\n\n-- O emitente sai da unidade quando ningu\u00E9m disse outra coisa: \u00E9 a unidade que\n-- tem CNPJ, e obrigar toda chamada a inform\u00E1-lo faria as tr\u00EAs portas de\n-- faturamento repetirem a mesma consulta \u2014 e esquecerem em lugares diferentes.\nCREATE OR REPLACE FUNCTION public.trg_financial_invoice_default_issuer()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nBEGIN\n IF NEW.company_legal_entity_id IS NULL AND NEW.unit_id IS NOT NULL THEN\n SELECT e.id INTO NEW.company_legal_entity_id\n FROM public.company_legal_entities e\n WHERE e.tenant_id = NEW.tenant_id AND e.unit_id = NEW.unit_id AND e.is_active\n ORDER BY e.created_at\n LIMIT 1;\n END IF;\n -- Ainda nulo \u00E9 leg\u00EDtimo: um tenant que nunca cadastrou pessoa jur\u00EDdica\n -- continua faturando internamente. O que ele n\u00E3o pode \u00E9 emitir documento\n -- fiscal, e quem cobra isso \u00E9 a emiss\u00E3o, n\u00E3o o t\u00EDtulo.\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_financial_invoices_default_issuer ON public.plg_financial_invoices;\nCREATE TRIGGER plg_financial_invoices_default_issuer\n BEFORE INSERT ON public.plg_financial_invoices\n FOR EACH ROW EXECUTE FUNCTION public.trg_financial_invoice_default_issuer();\n\n-- O que j\u00E1 existe herda o emitente da unidade.\nUPDATE public.plg_financial_invoices f SET company_legal_entity_id = e.id\n FROM public.company_legal_entities e\n WHERE f.company_legal_entity_id IS NULL\n AND e.tenant_id = f.tenant_id AND e.unit_id = f.unit_id AND e.is_active;\n\n-- \u2500\u2500 2. um tributo por linha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_invoice_item_taxes (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n item_id uuid NOT NULL,\n -- Aberto de prop\u00F3sito, e n\u00E3o um enum: os tributos deste pa\u00EDs mudam por lei,\n -- e a transi\u00E7\u00E3o 2026\u20132033 tem anos em que sete convivem. Um CHECK com a\n -- lista de hoje viraria uma migration a cada etapa do cronograma.\n tax text NOT NULL,\n base_amount numeric(14,2) DEFAULT 0 NOT NULL,\n rate numeric(9,6) DEFAULT 0 NOT NULL,\n amount numeric(14,2) DEFAULT 0 NOT NULL,\n -- Situa\u00E7\u00E3o tribut\u00E1ria e, na reforma, a classifica\u00E7\u00E3o (cClassTrib) que diz\n -- por que a al\u00EDquota \u00E9 a que \u00E9 \u2014 reduzida 30/60/100%, isenta, monof\u00E1sica.\n cst text,\n classification text,\n -- Reten\u00E7\u00E3o: o tributo que QUEM PAGA recolhe no lugar de quem recebe (ISS,\n -- IRRF, INSS sobre servi\u00E7o). Muda o l\u00EDquido, n\u00E3o o valor da venda.\n is_withheld boolean DEFAULT false NOT NULL,\n -- Cr\u00E9dito apropri\u00E1vel pelo comprador. A n\u00E3o-cumulatividade plena \u00E9 o\n -- cora\u00E7\u00E3o do IBS/CBS: sem guardar o cr\u00E9dito, o comprador n\u00E3o tem o que\n -- abater e a cadeia quebra no elo seguinte.\n credit_amount numeric(14,2) DEFAULT 0 NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_invoice_item_taxes'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_invoice_item_taxes\n ADD CONSTRAINT plg_financial_invoice_item_taxes_pkey PRIMARY KEY (id);\n END IF;\n -- Um tributo aparece UMA vez por linha. Duas linhas de CBS na mesma linha da\n -- fatura \u00E9 a forma mais silenciosa de dobrar um imposto.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_invoice_item_taxes_item_tax_key') THEN\n ALTER TABLE ONLY public.plg_financial_invoice_item_taxes\n ADD CONSTRAINT plg_financial_invoice_item_taxes_item_tax_key UNIQUE (item_id, tax);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_invoice_item_taxes_tax_check') THEN\n ALTER TABLE ONLY public.plg_financial_invoice_item_taxes\n ADD CONSTRAINT plg_financial_invoice_item_taxes_tax_check CHECK (tax ~ '^[a-z][a-z0-9_]*$');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_invoice_item_taxes_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_invoice_item_taxes\n ADD CONSTRAINT plg_financial_invoice_item_taxes_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_invoice_item_taxes_item_fk') THEN\n ALTER TABLE ONLY public.plg_financial_invoice_item_taxes\n ADD CONSTRAINT plg_financial_invoice_item_taxes_item_fk\n FOREIGN KEY (item_id) REFERENCES public.items(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_invoice_item_taxes_unit_fk') THEN\n ALTER TABLE ONLY public.plg_financial_invoice_item_taxes\n ADD CONSTRAINT plg_financial_invoice_item_taxes_unit_fk\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_invoice_item_taxes_owner_fk') THEN\n ALTER TABLE ONLY public.plg_financial_invoice_item_taxes\n ADD CONSTRAINT plg_financial_invoice_item_taxes_owner_fk\n FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_invoice_item_taxes_item_idx\n ON public.plg_financial_invoice_item_taxes USING btree (item_id);\nCREATE INDEX IF NOT EXISTS plg_financial_invoice_item_taxes_tenant_tax_idx\n ON public.plg_financial_invoice_item_taxes USING btree (tenant_id, tax);\nCREATE INDEX IF NOT EXISTS plg_financial_invoice_item_taxes_tenant_unit_idx\n ON public.plg_financial_invoice_item_taxes USING btree (tenant_id, unit_id);\n\nDROP TRIGGER IF EXISTS plg_financial_invoice_item_taxes_updated_at ON public.plg_financial_invoice_item_taxes;\nCREATE TRIGGER plg_financial_invoice_item_taxes_updated_at\n BEFORE UPDATE ON public.plg_financial_invoice_item_taxes\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_invoice_item_taxes ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_invoice_item_taxes FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_invoice_item_taxes_authz_select ON public.plg_financial_invoice_item_taxes;\nCREATE POLICY plg_financial_invoice_item_taxes_authz_select ON public.plg_financial_invoice_item_taxes\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND (SELECT app.has_permission('financial.read', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_invoice_item_taxes_authz_insert ON public.plg_financial_invoice_item_taxes;\nCREATE POLICY plg_financial_invoice_item_taxes_authz_insert ON public.plg_financial_invoice_item_taxes\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND (SELECT app.has_permission('financial.create', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_invoice_item_taxes_authz_update ON public.plg_financial_invoice_item_taxes;\nCREATE POLICY plg_financial_invoice_item_taxes_authz_update ON public.plg_financial_invoice_item_taxes\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND (SELECT app.has_permission('financial.edit', unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_invoice_item_taxes_authz_delete ON public.plg_financial_invoice_item_taxes;\nCREATE POLICY plg_financial_invoice_item_taxes_authz_delete ON public.plg_financial_invoice_item_taxes\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (unit_id IS NULL OR (SELECT app.has_unit(unit_id)))\n AND (SELECT app.has_permission('financial.delete', unit_id)));\n\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_invoice_item_taxes', 'financial.invoice_item_tax', 'financial', false, 'enforce', ARRAY[]::text[], false, 'unit_optional', false)\nON CONFLICT DO NOTHING;\n\nGRANT ALL ON TABLE public.plg_financial_invoice_item_taxes TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_invoice_item_taxes TO service_role;\n\nCOMMENT ON TABLE public.plg_financial_invoice_item_taxes IS\n 'Um tributo por linha da fatura: base, al\u00EDquota, valor, CST/classifica\u00E7\u00E3o, reten\u00E7\u00E3o e cr\u00E9dito. Uma linha por tributo porque CBS e IBS s\u00E3o dois, com cadeias de cr\u00E9dito separadas, e a transi\u00E7\u00E3o 2026-2033 tem anos em que sete convivem (007).';\n\n-- \u2500\u2500 o que a apura\u00E7\u00E3o l\u00EA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Por fatura e por tributo: o que se deve, o que foi retido na fonte e o que\n-- gera cr\u00E9dito. \u00C9 a forma que uma apura\u00E7\u00E3o mensal consome, e o que a emiss\u00E3o\n-- do documento precisa preencher campo a campo.\nCREATE OR REPLACE VIEW public.v_financial_invoice_taxes\nWITH (security_invoker = 'true') AS\nSELECT f.id AS invoice_id,\n f.tenant_id,\n f.unit_id,\n f.company_legal_entity_id,\n e.document_number AS issuer_document,\n e.tax_regime AS issuer_tax_regime,\n f.direction,\n f.competence_date,\n t.tax,\n sum(t.base_amount)::numeric(14,2) AS base_amount,\n sum(t.amount)::numeric(14,2) AS amount,\n sum(t.credit_amount)::numeric(14,2) AS credit_amount,\n sum(t.amount) FILTER (WHERE t.is_withheld)::numeric(14,2) AS withheld_amount\n FROM public.plg_financial_invoices f\n JOIN public.items i ON i.invoice_id = f.id\n JOIN public.plg_financial_invoice_item_taxes t ON t.item_id = i.id\n LEFT JOIN public.company_legal_entities e ON e.id = f.company_legal_entity_id\n WHERE f.cancelled_at IS NULL\n GROUP BY f.id, f.tenant_id, f.unit_id, f.company_legal_entity_id,\n e.document_number, e.tax_regime, f.direction, f.competence_date, t.tax;\n\nCOMMENT ON VIEW public.v_financial_invoice_taxes IS\n 'Apura\u00E7\u00E3o por fatura e tributo, com o CNPJ emitente e o regime \u2014 que \u00E9 quem declara (007).';\n\nGRANT SELECT ON public.v_financial_invoice_taxes TO authenticated;\nGRANT SELECT ON public.v_financial_invoice_taxes TO service_role;\n\n-- \u2500\u2500 a irm\u00E3 que ningu\u00E9m tinha declarado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_invoice_items` \u2014 a extens\u00E3o que carrega plano de contas e\n-- centro de custo da LINHA \u2014 tem RLS ligada, for\u00E7ada e quatro policies, e\n-- mesmo assim n\u00E3o estava em `app.scaffold_registry`. N\u00E3o \u00E9 um furo: a policy\n-- dela delega ao pai (\"v\u00EA o acess\u00F3rio da linha que voc\u00EA j\u00E1 v\u00EA\"), e `items`\n-- decide. \u00C9 uma AUS\u00CANCIA DE DECLARA\u00C7\u00C3O: o censo de authz n\u00E3o a enxergava, e\n-- nada podia afirmar que o acesso dela fora pensado.\n--\n-- Encontrada porque a asser\u00E7\u00E3o de 004 deixou de contar tabelas e passou a\n-- cobrar que nenhuma fique de fora \u2014 a primeira coisa que ela achou.\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_invoice_items', 'financial.invoice_item', 'financial', false, 'enforce', ARRAY[]::text[], false, 'unit_optional', false)\nON CONFLICT DO NOTHING;\n";
|
|
9
|
+
export declare const MIGRATION_008_THE_COMMISSION_RULE_LEARNS_TO_CHOOSE_AND_TO_SPLIT = "-- 008_the_commission_rule_learns_to_choose_and_to_split.sql\n--\n-- A regra de comiss\u00E3o existe desde a 003 e sabe uma coisa s\u00F3: um percentual.\n-- No V1 ela sabe DUAS outras, e s\u00E3o elas que fazem a comiss\u00E3o de um sal\u00E3o\n-- funcionar de verdade:\n--\n-- QUANDO ela se aplica \u2014 nove tipos de crit\u00E9rio, e a de maior prioridade\n-- entre as que casam \u00E9 a que vale. \"10% em colora\u00E7\u00E3o\", \"15% para a Marta\",\n-- \"5% no que for pago em cart\u00E3o\", \"s\u00E1bado paga mais\".\n--\n-- QUEM recebe \u2014 uma regra pode pagar V\u00C1RIAS pessoas pelo mesmo servi\u00E7o: o\n-- executor, a assistente, o fornecedor do produto. Cada uma com o seu\n-- percentual, a sua base e a sua faixa.\n--\n-- E falta o terceiro: QUEM EXECUTOU cada linha. Hoje a comiss\u00E3o sai de\n-- `order.total \u00D7 uma taxa do profissional`, o que s\u00F3 funciona quando uma pessoa\n-- fez tudo sozinha. Uma comanda com corte da Marta e escova da Bia \u00E9 uma conta\n-- s\u00F3, e dividir o total pela metade \u00E9 errado nas duas pontas.\n--\n-- POR QUE POR LINHA. \u00C9 como o V1 apura, e \u00E9 a \u00FAnica forma que responde\n-- \"quanto a Marta ganhou hoje\" numa venda com quatro itens de tr\u00EAs pessoas. A\n-- linha j\u00E1 \u00E9 o lugar certo: `public.items` tem `invoice_id` desde a fatura\n-- can\u00F4nica, e `plg_financial_invoice_items` j\u00E1 pendura contabilidade nela.\n--\n-- O que este arquivo N\u00C3O faz \u00E9 apurar. Aqui a regra ganha express\u00E3o e a linha\n-- ganha dono; a gera\u00E7\u00E3o do lan\u00E7amento \u00E9 a 009.\n\n-- \u2500\u2500 a regra pode ser a padr\u00E3o da casa \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commission_rules\n ADD COLUMN IF NOT EXISTS is_default boolean DEFAULT false NOT NULL;\n\nCOMMENT ON COLUMN public.plg_financial_commission_rules.is_default IS\n 'A regra que vale quando nenhuma outra casa. Sem ela, um servi\u00E7o fora de todo crit\u00E9rio n\u00E3o comissiona nada e ningu\u00E9m descobre at\u00E9 o fim do m\u00EAs (008).';\n\n-- \u2500\u2500 1. QUANDO: os crit\u00E9rios \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commission_rule_criteria (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n rule_id uuid NOT NULL,\n -- Os nove do V1. CHECK e n\u00E3o enum: acrescentar um d\u00E9cimo \u00E9 um ALTER de\n -- constraint, n\u00E3o uma migra\u00E7\u00E3o de tipo em toda coluna que o referencia.\n criteria_type text NOT NULL,\n -- A forma muda com o tipo, e por isso \u00E9 jsonb: listas de id\n -- (`{\"ids\": [...]}`), dias da semana (`{\"days\": [1,6]}`), faixas de hor\u00E1rio\n -- (`{\"ranges\": [{\"from\": \"18:00\", \"to\": \"22:00\"}]}`).\n criteria_value jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_commission_rule_criteria'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rule_criteria\n ADD CONSTRAINT plg_financial_commission_rule_criteria_pkey PRIMARY KEY (id);\n END IF;\n -- Um tipo de crit\u00E9rio aparece UMA vez por regra: dois \"professional\" na mesma\n -- regra s\u00E3o duas listas concorrentes, e nenhuma resposta \u00E9 a certa.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rule_criteria_rule_type_key') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rule_criteria\n ADD CONSTRAINT plg_financial_commission_rule_criteria_rule_type_key UNIQUE (rule_id, criteria_type);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rule_criteria_type_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rule_criteria\n ADD CONSTRAINT plg_financial_commission_rule_criteria_type_check\n CHECK (criteria_type = ANY (ARRAY['service', 'category', 'professional', 'professional_group',\n 'profession', 'unit', 'payment_method_type', 'weekday', 'time_range']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rule_criteria_rule_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rule_criteria\n ADD CONSTRAINT plg_financial_commission_rule_criteria_rule_fk\n FOREIGN KEY (rule_id) REFERENCES public.plg_financial_commission_rules(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_rule_criteria_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_commission_rule_criteria\n ADD CONSTRAINT plg_financial_commission_rule_criteria_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_commission_rule_criteria_rule_idx\n ON public.plg_financial_commission_rule_criteria USING btree (rule_id);\n\nDROP TRIGGER IF EXISTS plg_financial_commission_rule_criteria_updated_at ON public.plg_financial_commission_rule_criteria;\nCREATE TRIGGER plg_financial_commission_rule_criteria_updated_at\n BEFORE UPDATE ON public.plg_financial_commission_rule_criteria\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\n-- \u2500\u2500 2. QUEM RECEBE: as fun\u00E7\u00F5es da regra \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commission_functions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n rule_id uuid NOT NULL,\n function_name text NOT NULL,\n -- Quem \u00E9 o destinat\u00E1rio. `professional_executor` \u00E9 o mais usado e o mais\n -- interessante: n\u00E3o nomeia ningu\u00E9m \u2014 paga QUEM FEZ, resolvido por linha na\n -- apura\u00E7\u00E3o. Os outros nomeiam (`person_id`), ou perguntam no lan\u00E7amento.\n account_type text DEFAULT 'professional_executor'::text NOT NULL,\n person_id uuid,\n value_type text DEFAULT 'percentage'::text NOT NULL,\n value_amount numeric(14,4) DEFAULT 0 NOT NULL,\n -- Bruto ou l\u00EDquido de descontos. Muda quem paga o desconto: no l\u00EDquido, o\n -- profissional divide a conta do desconto com a casa; no bruto, a casa\n -- banca sozinha. \u00C9 decis\u00E3o comercial, n\u00E3o detalhe de c\u00E1lculo.\n calculation_base text DEFAULT 'net'::text NOT NULL,\n -- Faixa de produ\u00E7\u00E3o: `marginal` paga cada faixa na al\u00EDquota dela;\n -- `retroactive` aplica a al\u00EDquota da faixa alcan\u00E7ada a TUDO; `guaranteed_min`\n -- garante um piso. As tr\u00EAs existem no V1 e pagam valores diferentes pelo\n -- mesmo m\u00EAs \u2014 n\u00E3o s\u00E3o varia\u00E7\u00F5es de estilo.\n tier_mode text DEFAULT 'none'::text NOT NULL,\n min_guaranteed numeric(14,2),\n tiers jsonb DEFAULT '[]'::jsonb NOT NULL,\n is_variable boolean DEFAULT false NOT NULL,\n display_order integer DEFAULT 0 NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_commission_functions'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_functions_account_type_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_account_type_check\n CHECK (account_type = ANY (ARRAY['professional_executor', 'professional', 'supplier', 'select_on_launch']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_functions_value_type_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_value_type_check\n CHECK (value_type = ANY (ARRAY['percentage', 'fixed']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_functions_base_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_base_check\n CHECK (calculation_base = ANY (ARRAY['total', 'net']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_functions_tier_mode_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_tier_mode_check\n CHECK (tier_mode = ANY (ARRAY['none', 'marginal', 'retroactive', 'guaranteed_min']));\n END IF;\n -- Nomear uma pessoa \u00E9 obrigat\u00F3rio justamente quando o tipo diz que h\u00E1 uma.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_functions_person_required_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_person_required_check\n CHECK (account_type NOT IN ('professional', 'supplier') OR person_id IS NOT NULL);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_functions_rule_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_rule_fk\n FOREIGN KEY (rule_id) REFERENCES public.plg_financial_commission_rules(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_functions_person_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_person_fk\n FOREIGN KEY (person_id) REFERENCES public.people(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_functions_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_commission_functions\n ADD CONSTRAINT plg_financial_commission_functions_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_commission_functions_rule_idx\n ON public.plg_financial_commission_functions USING btree (rule_id, display_order);\n\nDROP TRIGGER IF EXISTS plg_financial_commission_functions_updated_at ON public.plg_financial_commission_functions;\nCREATE TRIGGER plg_financial_commission_functions_updated_at\n BEFORE UPDATE ON public.plg_financial_commission_functions\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\n-- \u2500\u2500 3. QUEM EXECUTOU: os participantes da linha \u2500\u2500\u2500\u2500\u2500\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-- A pe\u00E7a que faltava. Sem ela a comiss\u00E3o \u00E9 `order.total \u00D7 taxa`, que s\u00F3 est\u00E1\n-- certo quando uma pessoa fez tudo. Uma comanda com corte da Marta e escova da\n-- Bia \u00E9 uma conta s\u00F3, e metade para cada uma erra nas duas pontas.\nCREATE TABLE IF NOT EXISTS public.plg_financial_item_participants (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n item_id uuid NOT NULL,\n person_id uuid NOT NULL,\n -- `executor` \u00E9 quem prestou; `assistant` ajudou. O papel importa porque a\n -- regra paga percentuais diferentes por papel.\n role text DEFAULT 'executor'::text NOT NULL,\n -- Quanto DA LINHA \u00E9 desta pessoa, em fra\u00E7\u00E3o (1.0 = a linha inteira). Duas\n -- pessoas a 0.5 dividem; um executor a 1.0 com uma assistente a 0.2 \u00E9 a\n -- casa pagando 120% da linha em comiss\u00E3o, o que \u00E9 uma decis\u00E3o comercial\n -- leg\u00EDtima \u2014 e por isso a soma N\u00C3O \u00E9 for\u00E7ada a 1.\n share numeric(9,6) DEFAULT 1 NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_item_participants'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_item_participants\n ADD CONSTRAINT plg_financial_item_participants_pkey PRIMARY KEY (id);\n END IF;\n -- A mesma pessoa no mesmo papel na mesma linha \u00E9 a mesma participa\u00E7\u00E3o.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_item_participants_item_person_role_key') THEN\n ALTER TABLE ONLY public.plg_financial_item_participants\n ADD CONSTRAINT plg_financial_item_participants_item_person_role_key UNIQUE (item_id, person_id, role);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_item_participants_role_check') THEN\n ALTER TABLE ONLY public.plg_financial_item_participants\n ADD CONSTRAINT plg_financial_item_participants_role_check\n CHECK (role = ANY (ARRAY['executor', 'assistant']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_item_participants_share_check') THEN\n ALTER TABLE ONLY public.plg_financial_item_participants\n ADD CONSTRAINT plg_financial_item_participants_share_check CHECK (share > 0 AND share <= 1);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_item_participants_item_fk') THEN\n ALTER TABLE ONLY public.plg_financial_item_participants\n ADD CONSTRAINT plg_financial_item_participants_item_fk\n FOREIGN KEY (item_id) REFERENCES public.items(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_item_participants_person_fk') THEN\n ALTER TABLE ONLY public.plg_financial_item_participants\n ADD CONSTRAINT plg_financial_item_participants_person_fk\n FOREIGN KEY (person_id) REFERENCES public.people(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_item_participants_unit_fk') THEN\n ALTER TABLE ONLY public.plg_financial_item_participants\n ADD CONSTRAINT plg_financial_item_participants_unit_fk\n FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_item_participants_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_item_participants\n ADD CONSTRAINT plg_financial_item_participants_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_item_participants_item_idx\n ON public.plg_financial_item_participants USING btree (item_id);\n-- \"Quanto a Marta produziu este m\u00EAs\" l\u00EA por pessoa.\nCREATE INDEX IF NOT EXISTS plg_financial_item_participants_person_idx\n ON public.plg_financial_item_participants USING btree (tenant_id, person_id);\n\nDROP TRIGGER IF EXISTS plg_financial_item_participants_updated_at ON public.plg_financial_item_participants;\nCREATE TRIGGER plg_financial_item_participants_updated_at\n BEFORE UPDATE ON public.plg_financial_item_participants\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\n-- \u2500\u2500 RLS: escrita por extenso, e n\u00E3o num la\u00E7o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A primeira vers\u00E3o ligava RLS num `DO ... EXECUTE format(...)` sobre a lista\n-- de tabelas \u2014 menos texto, e invis\u00EDvel. O revisor de plugins leu o arquivo,\n-- n\u00E3o achou `ENABLE ROW LEVEL SECURITY` em lugar nenhum e reprovou: \"cria\n-- tabela com tenant_id e nunca liga row security\". Ele estava certo pelo\n-- motivo certo \u2014 quem revisa l\u00EA o mesmo texto, e uma prote\u00E7\u00E3o que s\u00F3 existe\n-- depois de um `format()` ser montado n\u00E3o \u00E9 leg\u00EDvel por ningu\u00E9m.\n--\n-- Mais linhas, e cada uma diz o que faz.\n\nALTER TABLE public.plg_financial_commission_rule_criteria ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_commission_rule_criteria FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_commission_rule_criteria_authz_select ON public.plg_financial_commission_rule_criteria;\nCREATE POLICY plg_financial_commission_rule_criteria_authz_select ON public.plg_financial_commission_rule_criteria\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_rule_criteria_authz_insert ON public.plg_financial_commission_rule_criteria;\nCREATE POLICY plg_financial_commission_rule_criteria_authz_insert ON public.plg_financial_commission_rule_criteria\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.create', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_rule_criteria_authz_update ON public.plg_financial_commission_rule_criteria;\nCREATE POLICY plg_financial_commission_rule_criteria_authz_update ON public.plg_financial_commission_rule_criteria\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_rule_criteria_authz_delete ON public.plg_financial_commission_rule_criteria;\nCREATE POLICY plg_financial_commission_rule_criteria_authz_delete ON public.plg_financial_commission_rule_criteria\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.delete', NULL)));\n\nGRANT ALL ON TABLE public.plg_financial_commission_rule_criteria TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_commission_rule_criteria TO service_role;\n\nALTER TABLE public.plg_financial_commission_functions ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_commission_functions FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_commission_functions_authz_select ON public.plg_financial_commission_functions;\nCREATE POLICY plg_financial_commission_functions_authz_select ON public.plg_financial_commission_functions\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_functions_authz_insert ON public.plg_financial_commission_functions;\nCREATE POLICY plg_financial_commission_functions_authz_insert ON public.plg_financial_commission_functions\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.create', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_functions_authz_update ON public.plg_financial_commission_functions;\nCREATE POLICY plg_financial_commission_functions_authz_update ON public.plg_financial_commission_functions\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_functions_authz_delete ON public.plg_financial_commission_functions;\nCREATE POLICY plg_financial_commission_functions_authz_delete ON public.plg_financial_commission_functions\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.delete', NULL)));\n\nGRANT ALL ON TABLE public.plg_financial_commission_functions TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_commission_functions TO service_role;\n\nALTER TABLE public.plg_financial_item_participants ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_item_participants FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_item_participants_authz_select ON public.plg_financial_item_participants;\nCREATE POLICY plg_financial_item_participants_authz_select ON public.plg_financial_item_participants\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_item_participants_authz_insert ON public.plg_financial_item_participants;\nCREATE POLICY plg_financial_item_participants_authz_insert ON public.plg_financial_item_participants\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.create', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_item_participants_authz_update ON public.plg_financial_item_participants;\nCREATE POLICY plg_financial_item_participants_authz_update ON public.plg_financial_item_participants\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_item_participants_authz_delete ON public.plg_financial_item_participants;\nCREATE POLICY plg_financial_item_participants_authz_delete ON public.plg_financial_item_participants\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.delete', NULL)));\n\nGRANT ALL ON TABLE public.plg_financial_item_participants TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_item_participants TO service_role;\n\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_commission_rule_criteria', 'financial.commission_criteria', 'financial', false, 'enforce', ARRAY[]::text[], false, 'tenant', false),\n ('public', 'plg_financial_commission_functions', 'financial.commission_function', 'financial', false, 'enforce', ARRAY[]::text[], false, 'tenant', false),\n ('public', 'plg_financial_item_participants', 'financial.item_participant', 'financial', false, 'enforce', ARRAY[]::text[], false, 'unit_optional', false)\nON CONFLICT DO NOTHING;\n\nCOMMENT ON TABLE public.plg_financial_commission_rule_criteria IS\n 'QUANDO a regra vale: servi\u00E7o, categoria, profissional, grupo, profiss\u00E3o, unidade, forma de pagamento, dia da semana, faixa de hor\u00E1rio (008).';\nCOMMENT ON TABLE public.plg_financial_commission_functions IS\n 'QUEM a regra paga: uma regra pode pagar v\u00E1rias pessoas pelo mesmo servi\u00E7o, cada uma com percentual, base e faixa pr\u00F3prios (008).';\nCOMMENT ON TABLE public.plg_financial_item_participants IS\n 'QUEM EXECUTOU cada linha, e em que fra\u00E7\u00E3o. Sem isto a comiss\u00E3o \u00E9 order.total \u00D7 taxa, que s\u00F3 est\u00E1 certo quando uma pessoa fez tudo (008).';\n";
|
|
10
|
+
export declare const MIGRATION_009_THE_COMMISSION_IS_CALCULATED_LINE_BY_LINE = "-- 009_the_commission_is_calculated_line_by_line.sql\n--\n-- A regra sabe quando se aplica e quem paga (008); a linha sabe quem executou.\n-- Falta apurar \u2014 transformar isso em VALOR, por linha e por pessoa, de forma\n-- que algu\u00E9m possa conferir cada centavo at\u00E9 a venda que o originou.\n--\n-- O LAN\u00C7AMENTO \u00C9 POR LINHA E POR PESSOA. \u00C9 a menor unidade que responde \u00E0s\n-- perguntas reais: \"quanto a Marta ganhou hoje\", \"por que este m\u00EAs veio menos\",\n-- \"esta comiss\u00E3o saiu de qual atendimento\". Um total por profissional por m\u00EAs\n-- responde a primeira e nenhuma das outras.\n--\n-- A APURA\u00C7\u00C3O \u00C9 UM LOTE, e o lote \u00E9 o que se confere e se paga. Sem ele, um\n-- reprocessamento duplica silenciosamente e a confer\u00EAncia do m\u00EAs passa a ser\n-- uma pergunta sobre quando cada linha foi gerada.\n--\n-- O QUE ELA N\u00C3O FAZ AINDA, e de prop\u00F3sito:\n--\n-- \u00B7 faixas de produ\u00E7\u00E3o (`tier_mode`). Marginal, retroativa e piso garantido\n-- dependem do ACUMULADO do profissional no per\u00EDodo, e um acumulado s\u00F3 \u00E9\n-- conhecido depois que o per\u00EDodo fecha \u2014 \u00E9 outra passada, sobre o mesmo\n-- lan\u00E7amento, e ela merece o pr\u00F3prio arquivo em vez de virar um `CASE`\n-- dentro deste.\n-- \u00B7 pagar. Comiss\u00E3o apurada \u00E9 conta a pagar ao profissional, e juntar N\n-- comiss\u00F5es num pagamento s\u00F3 \u00E9 o border\u00F4 \u2014 outro conceito, com outra tela.\n\n-- \u2500\u2500 o lote \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commission_generations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n period_start date NOT NULL,\n period_end date NOT NULL,\n status text DEFAULT 'draft'::text NOT NULL,\n total_amount numeric(14,2) DEFAULT 0 NOT NULL,\n entry_count integer DEFAULT 0 NOT NULL,\n generated_by_user_id uuid,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_commission_generations'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_commission_generations\n ADD CONSTRAINT plg_financial_commission_generations_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_generations_status_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_generations\n ADD CONSTRAINT plg_financial_commission_generations_status_check\n CHECK (status = ANY (ARRAY['draft', 'approved', 'cancelled']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_generations_period_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_generations\n ADD CONSTRAINT plg_financial_commission_generations_period_check CHECK (period_end >= period_start);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_generations_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_commission_generations\n ADD CONSTRAINT plg_financial_commission_generations_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_commission_generations_tenant_period_idx\n ON public.plg_financial_commission_generations USING btree (tenant_id, period_start DESC);\n\nDROP TRIGGER IF EXISTS plg_financial_commission_generations_updated_at ON public.plg_financial_commission_generations;\nCREATE TRIGGER plg_financial_commission_generations_updated_at\n BEFORE UPDATE ON public.plg_financial_commission_generations\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\n-- \u2500\u2500 o lan\u00E7amento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commission_entries (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n generation_id uuid,\n item_id uuid NOT NULL,\n person_id uuid NOT NULL,\n rule_id uuid,\n function_id uuid,\n -- A conta inteira, guardada: base \u00D7 al\u00EDquota = valor. Guardar s\u00F3 o valor\n -- torna \"por que deu isso\" uma reconstitui\u00E7\u00E3o a partir de regras que podem\n -- ter mudado desde ent\u00E3o \u2014 e a pergunta aparece justamente quando mudaram.\n base_amount numeric(14,2) DEFAULT 0 NOT NULL,\n rate numeric(14,4) DEFAULT 0 NOT NULL,\n amount numeric(14,2) DEFAULT 0 NOT NULL,\n share numeric(9,6) DEFAULT 1 NOT NULL,\n role text,\n status text DEFAULT 'pending'::text NOT NULL,\n -- A conta a pagar que quitou esta comiss\u00E3o, quando houver. \u00C9 o elo entre a\n -- apura\u00E7\u00E3o e o dinheiro que saiu.\n payable_invoice_id uuid,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_commission_entries'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_pkey PRIMARY KEY (id);\n END IF;\n -- A trava contra a duplicata: uma linha paga uma pessoa por uma fun\u00E7\u00E3o UMA\n -- vez. Reprocessar o m\u00EAs n\u00E3o pode pagar duas.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_entries_item_person_function_key') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_item_person_function_key\n UNIQUE (item_id, person_id, function_id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_entries_status_check') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_status_check\n CHECK (status = ANY (ARRAY['pending', 'approved', 'paid', 'cancelled']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_entries_item_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_item_fk\n FOREIGN KEY (item_id) REFERENCES public.items(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_entries_person_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_person_fk\n FOREIGN KEY (person_id) REFERENCES public.people(id) ON DELETE CASCADE;\n END IF;\n -- A regra pode ser apagada depois; o lan\u00E7amento n\u00E3o some junto, porque ele \u00E9\n -- o registro do que FOI pago e a regra \u00E9 s\u00F3 o motivo.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_entries_rule_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_rule_fk\n FOREIGN KEY (rule_id) REFERENCES public.plg_financial_commission_rules(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_entries_function_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_function_fk\n FOREIGN KEY (function_id) REFERENCES public.plg_financial_commission_functions(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_entries_generation_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_generation_fk\n FOREIGN KEY (generation_id) REFERENCES public.plg_financial_commission_generations(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_commission_entries_payable_fk') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_payable_fk\n FOREIGN KEY (payable_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 conname = 'plg_financial_commission_entries_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_commission_entries\n ADD CONSTRAINT plg_financial_commission_entries_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_commission_entries_person_idx\n ON public.plg_financial_commission_entries USING btree (tenant_id, person_id, status);\nCREATE INDEX IF NOT EXISTS plg_financial_commission_entries_generation_idx\n ON public.plg_financial_commission_entries USING btree (generation_id);\nCREATE INDEX IF NOT EXISTS plg_financial_commission_entries_item_idx\n ON public.plg_financial_commission_entries USING btree (item_id);\n\nDROP TRIGGER IF EXISTS plg_financial_commission_entries_updated_at ON public.plg_financial_commission_entries;\nCREATE TRIGGER plg_financial_commission_entries_updated_at\n BEFORE UPDATE ON public.plg_financial_commission_entries\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_commission_generations ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_commission_generations FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_commission_generations_authz_select ON public.plg_financial_commission_generations;\nCREATE POLICY plg_financial_commission_generations_authz_select ON public.plg_financial_commission_generations\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_generations_authz_insert ON public.plg_financial_commission_generations;\nCREATE POLICY plg_financial_commission_generations_authz_insert ON public.plg_financial_commission_generations\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.create', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_generations_authz_update ON public.plg_financial_commission_generations;\nCREATE POLICY plg_financial_commission_generations_authz_update ON public.plg_financial_commission_generations\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_generations_authz_delete ON public.plg_financial_commission_generations;\nCREATE POLICY plg_financial_commission_generations_authz_delete ON public.plg_financial_commission_generations\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.delete', NULL)));\n\nGRANT ALL ON TABLE public.plg_financial_commission_generations TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_commission_generations TO service_role;\n\nALTER TABLE public.plg_financial_commission_entries ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_commission_entries FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_commission_entries_authz_select ON public.plg_financial_commission_entries;\nCREATE POLICY plg_financial_commission_entries_authz_select ON public.plg_financial_commission_entries\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_entries_authz_insert ON public.plg_financial_commission_entries;\nCREATE POLICY plg_financial_commission_entries_authz_insert ON public.plg_financial_commission_entries\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.create', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_entries_authz_update ON public.plg_financial_commission_entries;\nCREATE POLICY plg_financial_commission_entries_authz_update ON public.plg_financial_commission_entries\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', NULL)));\n\nDROP POLICY IF EXISTS plg_financial_commission_entries_authz_delete ON public.plg_financial_commission_entries;\nCREATE POLICY plg_financial_commission_entries_authz_delete ON public.plg_financial_commission_entries\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.delete', NULL)));\n\nGRANT ALL ON TABLE public.plg_financial_commission_entries TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_commission_entries TO service_role;\n\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_commission_generations', 'financial.commission_generation', 'financial', false, 'enforce', ARRAY[]::text[], true, 'unit_optional', false),\n ('public', 'plg_financial_commission_entries', 'financial.commission_entry', 'financial', false, 'enforce', ARRAY[]::text[], true, 'unit_optional', false)\nON CONFLICT DO NOTHING;\n\n-- \u2500\u2500 qual regra vale para esta linha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A de maior prioridade cujos crit\u00E9rios TODOS casam. Um crit\u00E9rio ausente n\u00E3o\n-- restringe: uma regra sem crit\u00E9rio de servi\u00E7o vale para qualquer servi\u00E7o \u2014 \u00E9\n-- assim que \"10% para a Marta\" funciona sem listar os cem servi\u00E7os dela.\n-- Nenhuma casando, vale a `is_default`, e nenhuma padr\u00E3o significa que aquele\n-- servi\u00E7o n\u00E3o comissiona.\nCREATE OR REPLACE FUNCTION public.plg_financial_commission_rule_for_item(p_item_id uuid, p_person_id uuid)\n RETURNS uuid\n LANGUAGE plpgsql\n STABLE\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_item record;\n v_rule uuid;\nBEGIN\n SELECT i.id, i.tenant_id, i.unit_id, i.product_id, i.order_id, i.invoice_id,\n coalesce(o.created_at, i.created_at) AS happened_at,\n p.category_id\n INTO v_item\n FROM public.items i\n LEFT JOIN public.orders o ON o.id = coalesce(i.order_id,\n (SELECT f2.order_id FROM public.plg_financial_invoices f2 WHERE f2.id = i.invoice_id))\n LEFT JOIN public.products p ON p.id = i.product_id\n WHERE i.id = p_item_id;\n IF v_item.id IS NULL THEN RETURN NULL; END IF;\n\n SELECT r.id INTO v_rule\n FROM public.plg_financial_commission_rules r\n WHERE r.tenant_id = v_item.tenant_id\n AND r.is_active\n AND (r.unit_id IS NULL OR r.unit_id = v_item.unit_id)\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_financial_commission_rule_criteria c\n WHERE c.rule_id = r.id\n AND NOT CASE c.criteria_type\n WHEN 'service' THEN\n v_item.product_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'category' THEN\n v_item.category_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'professional' THEN\n p_person_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'unit' THEN\n v_item.unit_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'weekday' THEN\n (extract(dow FROM v_item.happened_at))::int IN (\n SELECT (jsonb_array_elements_text(coalesce(c.criteria_value -> 'days', '[]'::jsonb)))::int)\n -- Os crit\u00E9rios que dependem de cadastros de OUTROS m\u00F3dulos (grupo,\n -- profiss\u00E3o) e o de forma de pagamento \u2014 que s\u00F3 se conhece quando o\n -- pagamento acontece, e n\u00E3o quando a linha nasce \u2014 ainda n\u00E3o\n -- restringem. Deixar passar \u00E9 o comportamento seguro: comissiona a\n -- mais, e algu\u00E9m v\u00EA. Recusar em sil\u00EAncio n\u00E3o comissiona e ningu\u00E9m v\u00EA.\n ELSE true\n END)\n ORDER BY r.priority DESC, r.is_default ASC, r.created_at\n LIMIT 1;\n\n IF v_rule IS NOT NULL THEN RETURN v_rule; END IF;\n\n SELECT r.id INTO v_rule FROM public.plg_financial_commission_rules r\n WHERE r.tenant_id = v_item.tenant_id AND r.is_active AND r.is_default\n ORDER BY r.priority DESC LIMIT 1;\n RETURN v_rule;\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_financial_commission_rule_for_item(uuid, uuid) IS\n 'A regra de maior prioridade cujos crit\u00E9rios todos casam; sen\u00E3o a padr\u00E3o da casa. Crit\u00E9rio ausente n\u00E3o restringe (009).';\n\n-- \u2500\u2500 apurar um per\u00EDodo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commission_generate(\n p_period_start date,\n p_period_end date,\n p_unit_id uuid DEFAULT NULL\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_gen uuid;\n v_row record;\n v_rule uuid;\n v_fn record;\n v_base numeric;\n v_amount numeric;\n v_count int := 0;\n v_total numeric := 0;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_commission_generate: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.commissions.manage', p_unit_id) THEN\n RAISE EXCEPTION 'financial_commission_generate: exige financial.commissions.manage' USING ERRCODE = '42501';\n END IF;\n\n INSERT INTO public.plg_financial_commission_generations\n (tenant_id, unit_id, period_start, period_end, status, generated_by_user_id)\n VALUES (v_tenant, p_unit_id, p_period_start, p_period_end, 'draft', auth.uid())\n RETURNING id INTO v_gen;\n\n -- S\u00F3 linha de fatura N\u00C3O cancelada: comiss\u00E3o sai de venda que existe. E o\n -- per\u00EDodo \u00E9 o da COMPET\u00CANCIA da fatura, n\u00E3o o da gera\u00E7\u00E3o \u2014 reprocessar\n -- fevereiro em mar\u00E7o n\u00E3o pode mudar de m\u00EAs.\n -- O participante mora na linha do PEDIDO: \u00E9 l\u00E1 que se sabe quem prestou, e\n -- se sabe na hora da presta\u00E7\u00E3o. A fatura chega pelo pedido, que \u00E9 o elo que\n -- `items_one_parent` deixa existir. S\u00F3 fatura n\u00E3o cancelada \u2014 comiss\u00E3o sai de\n -- venda que existe \u2014 e a compet\u00EAncia \u00E9 a DELA: reapurar fevereiro em mar\u00E7o\n -- n\u00E3o pode mudar de m\u00EAs.\n FOR v_row IN\n SELECT i.id AS item_id, i.tenant_id, i.unit_id, i.total, i.discount,\n part.person_id, part.share, part.role\n FROM public.items i\n JOIN public.orders o ON o.id = i.order_id\n JOIN public.plg_financial_invoices f ON f.order_id = o.id AND f.cancelled_at IS NULL\n JOIN public.plg_financial_item_participants part ON part.item_id = i.id\n WHERE i.tenant_id = v_tenant\n AND (p_unit_id IS NULL OR i.unit_id = p_unit_id)\n AND f.competence_date BETWEEN p_period_start AND p_period_end\n LOOP\n v_rule := public.plg_financial_commission_rule_for_item(v_row.item_id, v_row.person_id);\n CONTINUE WHEN v_rule IS NULL;\n\n -- UMA REGRA SEM FUN\u00C7\u00C3O PAGA QUEM EXECUTOU.\n --\n -- A regra diz QUANTO; quem diz A QUEM \u00E9 a fun\u00E7\u00E3o, e \u00E9 nela que o la\u00E7o\n -- abaixo itera \u2014 sem fun\u00E7\u00E3o ele n\u00E3o roda nenhuma vez e a regra n\u00E3o paga\n -- ningu\u00E9m. A tela cadastra a REGRA e n\u00E3o existe tela para fun\u00E7\u00E3o, ent\u00E3o\n -- toda regra criada pela interface era, por constru\u00E7\u00E3o, incapaz de pagar.\n --\n -- A semeadura \u00E9 aqui, na APURA\u00C7\u00C3O, e n\u00E3o num gatilho de cadastro: no\n -- AFTER INSERT da regra as fun\u00E7\u00F5es ainda n\u00E3o existem, e o default entraria\n -- por cima de quem estava prestes a declarar as suas.\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_commission_functions f\n WHERE f.rule_id = v_rule) THEN\n INSERT INTO public.plg_financial_commission_functions\n (tenant_id, rule_id, function_name, account_type, value_type, value_amount,\n calculation_base, display_order, is_active)\n SELECT r.tenant_id, r.id, coalesce(nullif(r.name, ''), 'Executor'),\n 'professional_executor', r.value_type, r.value, 'net', 0, true\n FROM public.plg_financial_commission_rules r\n WHERE r.id = v_rule;\n END IF;\n\n FOR v_fn IN\n SELECT * FROM public.plg_financial_commission_functions\n WHERE rule_id = v_rule AND is_active ORDER BY display_order\n LOOP\n -- `professional_executor` paga quem fez; os que nomeiam pagam o nomeado.\n -- `select_on_launch` espera algu\u00E9m dizer, e n\u00E3o se apura sozinho.\n CONTINUE WHEN v_fn.account_type = 'select_on_launch';\n\n v_base := CASE v_fn.calculation_base\n WHEN 'total' THEN coalesce(v_row.total, 0)\n ELSE coalesce(v_row.total, 0) - coalesce(v_row.discount, 0)\n END * coalesce(v_row.share, 1);\n\n v_amount := round(CASE v_fn.value_type\n WHEN 'fixed' THEN v_fn.value_amount\n ELSE v_base * (v_fn.value_amount / 100)\n END, 2);\n CONTINUE WHEN v_amount <= 0;\n\n INSERT INTO public.plg_financial_commission_entries\n (tenant_id, unit_id, generation_id, item_id, person_id, rule_id, function_id,\n base_amount, rate, amount, share, role, status)\n VALUES\n (v_tenant, v_row.unit_id, v_gen, v_row.item_id,\n CASE WHEN v_fn.account_type = 'professional_executor' THEN v_row.person_id ELSE v_fn.person_id END,\n v_rule, v_fn.id, round(v_base, 2), v_fn.value_amount, v_amount,\n coalesce(v_row.share, 1), v_row.role, 'pending')\n -- Reprocessar n\u00E3o paga duas vezes: recalcula o que j\u00E1 existe. O\n -- `generation_id` N\u00C3O \u00E9 sobrescrito \u2014 o lan\u00E7amento pertence ao lote que\n -- o produziu, e rec\u00E1lculo muda o VALOR, n\u00E3o a proced\u00EAncia. Troc\u00E1-lo\n -- esvaziaria o lote que algu\u00E9m j\u00E1 conferiu, e a confer\u00EAncia do m\u00EAs\n -- viraria uma pergunta sobre qual reapura\u00E7\u00E3o olhar.\n ON CONFLICT (item_id, person_id, function_id) DO UPDATE\n SET base_amount = EXCLUDED.base_amount, rate = EXCLUDED.rate, amount = EXCLUDED.amount,\n updated_at = now()\n WHERE public.plg_financial_commission_entries.status = 'pending';\n\n v_count := v_count + 1;\n v_total := v_total + v_amount;\n END LOOP;\n END LOOP;\n\n UPDATE public.plg_financial_commission_generations\n SET entry_count = v_count, total_amount = v_total, updated_at = now()\n WHERE id = v_gen;\n\n RETURN jsonb_build_object('generation_id', v_gen, 'period_start', p_period_start,\n 'period_end', p_period_end, 'entries', v_count, 'total', v_total);\nEND $$;\n\nREVOKE ALL ON FUNCTION public.financial_commission_generate(date, date, uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.financial_commission_generate(date, date, uuid) TO authenticated;\n\n-- \u2500\u2500 o que a tela e o relat\u00F3rio leem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commissions\nWITH (security_invoker = 'true') AS\nSELECT e.id, e.tenant_id, e.unit_id, e.generation_id, e.item_id, e.person_id,\n pe.name AS person_name,\n i.name AS item_name,\n i.order_id,\n f.id AS invoice_id,\n f.number AS invoice_number,\n f.competence_date,\n r.name AS rule_name,\n fn.function_name,\n e.base_amount, e.rate, e.amount, e.share, e.role, e.status, e.payable_invoice_id,\n e.created_at, e.updated_at\n FROM public.plg_financial_commission_entries e\n JOIN public.items i ON i.id = e.item_id\n -- A linha do lan\u00E7amento \u00E9 a do PEDIDO, e uma linha tem um pai s\u00F3: a fatura\n -- vem pelo pedido. `coalesce` cobre tamb\u00E9m o caso de a comiss\u00E3o vir de uma\n -- linha que j\u00E1 \u00E9 da fatura (uma despesa lan\u00E7ada \u00E0 m\u00E3o que comissiona).\n LEFT JOIN public.plg_financial_invoices f\n ON f.id = coalesce(i.invoice_id, (SELECT f2.id FROM public.plg_financial_invoices f2\n WHERE f2.order_id = i.order_id AND f2.cancelled_at IS NULL\n ORDER BY f2.created_at LIMIT 1))\n LEFT JOIN public.people pe ON pe.id = e.person_id\n LEFT JOIN public.plg_financial_commission_rules r ON r.id = e.rule_id\n LEFT JOIN public.plg_financial_commission_functions fn ON fn.id = e.function_id;\n\nCOMMENT ON VIEW public.v_financial_commissions IS\n 'A comiss\u00E3o com a venda que a originou: linha, fatura, regra e fun\u00E7\u00E3o. \u00C9 o que responde \"esta comiss\u00E3o saiu de qual atendimento\" (009).';\n\nGRANT SELECT ON public.v_financial_commissions TO authenticated;\nGRANT SELECT ON public.v_financial_commissions TO service_role;\n";
|
|
11
|
+
export declare const MIGRATION_010_THE_INVOICE_RAISED_FROM_A_SALE_GETS_ITS_OWN_LINES = "-- 010_the_invoice_raised_from_a_sale_gets_its_own_lines.sql\n--\n-- Uma fatura levantada a partir de uma venda N\u00C3O TINHA LINHAS. S\u00F3 um texto de\n-- resumo em `description` \u2014 \"Corte, Escova\" \u2014 e nada mais.\n--\n-- Isso n\u00E3o incomodava enquanto ningu\u00E9m lia a fatura por linha. Agora tr\u00EAs\n-- coisas leem, e as tr\u00EAs encontravam vazio na maioria das faturas:\n--\n-- \u00B7 `plg_financial_invoice_items` pendura plano de contas e centro de custo\n-- NA LINHA, e a apura\u00E7\u00E3o cont\u00E1bil entra pela fatura;\n-- \u00B7 `plg_financial_invoice_item_taxes` (007) guarda CBS e IBS por linha;\n-- \u00B7 a comiss\u00E3o (009) apura por linha \u2014 e devolveu ZERO lan\u00E7amento num\n-- cen\u00E1rio com duas linhas e dois profissionais. Foi assim que apareceu.\n--\n-- POR QUE COPIAR, e n\u00E3o apontar. A primeira tentativa foi carimbar\n-- `items.invoice_id` na linha do pedido, e o banco recusou:\n-- `items_one_parent` exige `num_nonnulls(order_id, invoice_id,\n-- fulfillment_id, quote_id) = 1`. Uma linha tem UM pai, e isso \u00E9 desenho, n\u00E3o\n-- obst\u00E1culo \u2014 a linha do pedido \u00E9 o que foi VENDIDO, a linha da fatura \u00E9 o que\n-- foi COBRADO, e as duas divergem no mundo real: fatura-se parcialmente,\n-- agrupa-se, corrige-se pre\u00E7o.\n--\n-- `item_associations` existe exatamente para isso \u2014 `from_item_id` (a venda)\n-- \u2192 `to_item_id` (a cobran\u00E7a), com quantidade. A tabela estava l\u00E1 e ningu\u00E9m a\n-- alimentava; `v_financial_invoice_lines` j\u00E1 l\u00EA por ela.\n--\n-- Ent\u00E3o: a fatura ganha as linhas dela, associadas \u00E0s do pedido. Faturar tudo\n-- \u00E9 o caso simples de um modelo que sabe fazer mais.\n\n-- \u2500\u2500 as duas pontes passam a compor as linhas da fatura \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_copy_order_lines_to_invoice(p_order_id uuid, p_invoice_id uuid)\n RETURNS integer\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE v_n integer := 0; v_row record; v_new uuid;\nBEGIN\n -- J\u00E1 composta: n\u00E3o duplica. Reexecutar uma ponte idempotente \u00E9 o caminho\n -- normal de reparo, e ele n\u00E3o pode cobrar duas vezes.\n IF EXISTS (SELECT 1 FROM public.items WHERE invoice_id = p_invoice_id) THEN\n RETURN 0;\n END IF;\n\n FOR v_row IN\n SELECT * FROM public.items WHERE order_id = p_order_id ORDER BY sort_order, created_at\n LOOP\n INSERT INTO public.items\n (tenant_id, unit_id, invoice_id, product_id, name, description,\n quantity, unit_price, discount, total, sort_order, metadata)\n VALUES\n (v_row.tenant_id, v_row.unit_id, p_invoice_id, v_row.product_id,\n v_row.name, v_row.description, v_row.quantity, v_row.unit_price,\n v_row.discount, v_row.total, v_row.sort_order, coalesce(v_row.metadata, '{}'::jsonb))\n RETURNING id INTO v_new;\n\n -- O elo entre o que foi vendido e o que foi cobrado. \u00C9 por aqui que\n -- v_financial_invoice_lines volta da cobran\u00E7a para a venda.\n INSERT INTO public.item_associations (tenant_id, unit_id, from_item_id, to_item_id, quantity)\n VALUES (v_row.tenant_id, v_row.unit_id, v_row.id, v_new, v_row.quantity);\n\n v_n := v_n + 1;\n END LOOP;\n\n RETURN v_n;\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_financial_copy_order_lines_to_invoice(uuid, uuid) IS\n 'Comp\u00F5e as linhas da fatura a partir das do pedido, com a associa\u00E7\u00E3o venda\u2192cobran\u00E7a. Uma linha tem um pai s\u00F3 (items_one_parent), ent\u00E3o cobrar \u00E9 copiar (010).';\n\n-- A ponte da loja e da agenda.\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order_internal(\n p_order_id uuid,\n p_due_date date DEFAULT NULL::date,\n p_status text DEFAULT NULL::text,\n p_installments integer DEFAULT 1,\n p_direction text DEFAULT 'credit'::text\n) RETURNS uuid\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_invoice_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 v_status text;\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 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\n -- Idempotente, e ainda assim carimba: um pedido faturado ANTES da 010 tem\n -- fatura e n\u00E3o tem o elo, e reexecutar \u00E9 o caminho natural de reparo.\n PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice_id);\n RETURN p_order_id;\n END IF;\n IF EXISTS (SELECT 1 FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill'\n AND status <> 'cancelled') 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.items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n v_status := v_order.status;\n IF p_status IS NOT NULL AND EXISTS (\n SELECT 1 FROM public.order_kinds k\n WHERE k.kind = v_order.kind\n AND (k.statuses IS NULL OR p_status = ANY (k.statuses))\n ) THEN\n v_status := p_status;\n END IF;\n\n UPDATE public.orders SET\n status = v_status,\n stage = 'invoiced',\n direction = p_direction,\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', 'orderKind', v_order.kind),\n auth.uid())\n RETURNING id INTO v_invoice_id;\n\n PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, 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\n (tenant_id, unit_id, invoice_id, number, due_date, amount)\n VALUES (v_order.tenant_id, v_order.unit_id, v_invoice_id, i,\n (v_due + ((i - 1) || ' months')::interval)::date, v_amt);\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\n\n-- A ponte da comanda e do atendimento.\nCREATE OR REPLACE FUNCTION public.order_raise_receivable(\n p_order_id uuid,\n p_due_date date DEFAULT NULL\n) RETURNS uuid\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_invoice_id uuid;\n v_number text;\n v_summary text;\n v_total numeric;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RETURN NULL; END IF;\n\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\n PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice_id);\n RETURN v_invoice_id;\n END IF;\n\n v_total := round(coalesce(v_order.total, 0), 2);\n IF v_total <= 0 THEN RETURN NULL; END IF;\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.items WHERE order_id = p_order_id;\n\n v_number := public.plg_financial_next_invoice_number(v_order.tenant_id, 'receivable');\n\n INSERT INTO public.plg_financial_invoices\n (tenant_id, unit_id, direction, counterparty_person_id, order_id, number,\n competence_date, issue_date, total, description, metadata, created_by)\n VALUES\n (v_order.tenant_id, v_order.unit_id, 'receivable', 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', 'order_raise_receivable', 'orderKind', v_order.kind),\n auth.uid())\n RETURNING id INTO v_invoice_id;\n\n PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice_id);\n\n INSERT INTO public.plg_financial_invoice_installments\n (tenant_id, unit_id, invoice_id, number, due_date, amount)\n VALUES (v_order.tenant_id, v_order.unit_id, v_invoice_id, 1,\n coalesce(p_due_date, current_date), v_total);\n\n UPDATE public.orders SET\n stage = 'invoiced',\n direction = 'credit',\n metadata = coalesce(metadata, '{}'::jsonb)\n || jsonb_build_object('invoiceNumber', v_number, 'installmentCount', 1),\n updated_at = now()\n WHERE id = p_order_id;\n\n RETURN v_invoice_id;\nEND $$;\n\n-- \u2500\u2500 reparo: s\u00F3 o que ainda pode ser composto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A primeira vers\u00E3o deste reparo fazia duas coisas que o pr\u00F3prio banco recusou,\n-- e recusou com raz\u00E3o:\n--\n-- CARIMBAVA `items.invoice_id` NA LINHA DO PEDIDO. \u00C9 exatamente o que o\n-- cabe\u00E7alho deste arquivo diz que n\u00E3o se faz \u2014 `items_one_parent` quer UM pai\n-- por linha. Escrevi o desenho certo em cima e o errado embaixo.\n--\n-- COMPUNHA LINHAS EM FATURA J\u00C1 EMITIDA. `trg_invoice_line_immutable` levantou\n-- 23514 num pool real: uma fatura emitida \u00E9 imut\u00E1vel (PRD 04). Voltar atr\u00E1s\n-- para lhe dar linhas \u00E9 reescrever documento fechado \u2014 e um livro que se pode\n-- reescrever n\u00E3o prova nada.\n--\n-- Ent\u00E3o o reparo alcan\u00E7a s\u00F3 a fatura AINDA N\u00C3O EMITIDA. A emitida antiga segue\n-- sem linhas pr\u00F3prias e continua leg\u00EDvel: `v_financial_receipt` (017) cai para\n-- as linhas do pedido, e `v_financial_invoice_lines` l\u00EA pela associa\u00E7\u00E3o. O que\n-- ela n\u00E3o ter\u00E1 \u00E9 apura\u00E7\u00E3o retroativa de comiss\u00E3o \u2014 e isso \u00E9 consequ\u00EAncia de\n-- fechar documento, n\u00E3o defeito deste arquivo.\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN\n SELECT f.id AS invoice_id, f.order_id\n FROM public.plg_financial_invoices f\n WHERE f.order_id IS NOT NULL\n AND f.cancelled_at IS NULL\n AND f.issue_date IS NULL\n AND NOT EXISTS (SELECT 1 FROM public.items i WHERE i.invoice_id = f.id)\n LOOP\n PERFORM public.plg_financial_copy_order_lines_to_invoice(r.order_id, r.invoice_id);\n END LOOP;\nEND $$;\n";
|
|
12
|
+
export declare const MIGRATION_011_A_NEW_TENANT_CAN_TAKE_MONEY_ON_THE_FIRST_DAY = "-- 011_a_new_tenant_can_take_money_on_the_first_day.sql\n--\n-- Um tenant novo n\u00E3o consegue receber. Medido no cluster, e cada linha \u00E9 uma\n-- casa que n\u00E3o pode trabalhar:\n--\n-- clinica-teste 0 formas de pagamento, 0 contas \u2014 nada\n-- joias-do-vini 7 formas de pagamento, 0 CONTAS \u2014 n\u00E3o recebe nada\n-- espaco-renova-rio 3 contas, sem cofre e sem conta de cart\u00E3o\n-- nenhum tenant plano de contas\n--\n-- \u00C9 da\u00ED que v\u00EAm dois erros que parecem bugs isolados e s\u00E3o o mesmo buraco:\n--\n-- \"method credit_card must settle in a card_receivable_credit account\" \u2014\n-- a regra est\u00E1 certa, e n\u00E3o existe conta desse tipo para oferecer.\n--\n-- abrir caixa exige um cofre `cash_physical` com unidade, e n\u00E3o h\u00E1 cofre.\n--\n-- O QUE \u00C9 O M\u00CDNIMO, e por que exatamente isto. N\u00E3o \u00E9 um plano de contas de\n-- contador \u2014 \u00E9 o conjunto sem o qual as telas que j\u00E1 existem n\u00E3o funcionam:\n-- uma gaveta para o dinheiro vivo, uma conta para o banco, e duas contas de\n-- receb\u00EDvel de cart\u00E3o, porque cr\u00E9dito e d\u00E9bito liquidam em prazos diferentes e\n-- mistur\u00E1-los \u00E9 perder a concilia\u00E7\u00E3o antes de come\u00E7ar.\n--\n-- O plano de contas nasce RASO e em portugu\u00EAs, com os grupos que qualquer\n-- prestador de servi\u00E7o usa. Um contador vai reorganiz\u00E1-lo; o ponto \u00E9 que a\n-- linha da fatura tenha onde ser classificada no primeiro dia, em vez de o\n-- campo abrir vazio e a classifica\u00E7\u00E3o nunca acontecer.\n--\n-- \u00C9 O PLUGIN QUE SE PENDURA, e n\u00E3o o `create_tenant` que sabe de finan\u00E7as. A\n-- casca do tenant n\u00E3o deve conhecer conta de cart\u00E3o; quem sabe o que um tenant\n-- financeiro precisa \u00E9 o Financeiro. Da\u00ED o gatilho vir daqui.\n\nCREATE OR REPLACE FUNCTION public.financial_seed_tenant(p_tenant uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_unit uuid;\n v_made int := 0;\n v_chart int := 0;\nBEGIN\n IF p_tenant IS NULL THEN RETURN jsonb_build_object('seeded', false, 'reason', 'no tenant'); END IF;\n\n -- A unidade da sede. O cofre PRECISA de uma: financial_cash_session_open\n -- recusa cofre sem unidade, e um cofre que n\u00E3o abre caixa n\u00E3o serve.\n SELECT u.id INTO v_unit FROM app.units u\n WHERE u.tenant_id = p_tenant AND u.active\n ORDER BY (u.kind = 'hq') DESC, u.created_at\n LIMIT 1;\n\n -- \u2500\u2500 as contas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 -- `code` \u00E9 o que torna a semeadura idempotente: rodar de novo n\u00E3o cria a\n -- segunda gaveta.\n INSERT INTO public.plg_financial_accounts (tenant_id, unit_id, kind, code, name, account_type, is_active, settlement_days, metadata)\n SELECT p_tenant, v_unit, s.kind, s.code, s.name, s.account_type, true, s.settlement_days,\n jsonb_build_object('seeded', true)\n FROM (VALUES\n ('cash_physical', 'cofre', 'Caixa (dinheiro)', 'caixa_fisico', 0),\n ('bank', 'banco', 'Conta banc\u00E1ria', 'bank_account', 0),\n -- Cr\u00E9dito e d\u00E9bito separados de prop\u00F3sito: liquidam em prazos\n -- diferentes, e uma conta s\u00F3 faria a concilia\u00E7\u00E3o nascer errada.\n ('card_receivable_credit', 'cartao-cred', 'Receb\u00EDveis de cr\u00E9dito', 'cartao_credito_recebimento', 30),\n ('card_receivable_debit', 'cartao-deb', 'Receb\u00EDveis de d\u00E9bito', 'cartao_debito_recebimento', 1)\n ) AS s(kind, code, name, account_type, settlement_days)\n WHERE NOT EXISTS (\n SELECT 1 FROM public.plg_financial_accounts a\n WHERE a.tenant_id = p_tenant AND a.code = s.code);\n GET DIAGNOSTICS v_made = ROW_COUNT;\n\n -- O cofre adota a unidade quando ainda n\u00E3o tem uma. O gatilho roda no INSERT\n -- do tenant, e `create_tenant` s\u00F3 cria a unidade DEPOIS \u2014 ent\u00E3o na primeira\n -- passada n\u00E3o h\u00E1 sede para apontar, e um cofre sem unidade n\u00E3o abre caixa.\n -- Idempotente n\u00E3o pode significar \"erra uma vez e congela\": a segunda\n -- chamada conserta em vez de n\u00E3o fazer nada.\n IF v_unit IS NOT NULL THEN\n UPDATE public.plg_financial_accounts\n SET unit_id = v_unit, updated_at = now()\n WHERE tenant_id = p_tenant AND unit_id IS NULL AND kind = 'cash_physical';\n END IF;\n\n -- \u2500\u2500 o plano de contas, raso \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 INSERT INTO public.plg_financial_chart_of_accounts (tenant_id, unit_id, code, name, node_type, account_type, is_active)\n SELECT p_tenant, NULL, s.code, s.name, 'leaf', s.account_type, true\n FROM (VALUES\n ('3.01', 'Receita de servi\u00E7os', 'revenue'),\n ('3.02', 'Receita de produtos', 'revenue'),\n ('4.01', 'Custo dos produtos', 'expense'),\n ('4.02', 'Comiss\u00F5es', 'expense'),\n ('4.03', 'Folha e encargos', 'expense'),\n ('4.04', 'Aluguel e condom\u00EDnio', 'expense'),\n ('4.05', '\u00C1gua, luz e telefone', 'expense'),\n ('4.06', 'Marketing', 'expense'),\n ('4.07', 'Taxas de cart\u00E3o', 'expense'),\n ('4.08', 'Impostos', 'expense'),\n ('4.99', 'Outras despesas', 'expense')\n ) AS s(code, name, account_type)\n WHERE NOT EXISTS (\n SELECT 1 FROM public.plg_financial_chart_of_accounts c\n WHERE c.tenant_id = p_tenant AND c.code = s.code);\n GET DIAGNOSTICS v_chart = ROW_COUNT;\n\n -- \u2500\u2500 um centro de custo, para o campo n\u00E3o abrir vazio \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n INSERT INTO public.plg_financial_cost_centers (tenant_id, unit_id, code, name, is_active)\n SELECT p_tenant, NULL, 'GERAL', 'Geral', true\n WHERE NOT EXISTS (\n SELECT 1 FROM public.plg_financial_cost_centers c\n WHERE c.tenant_id = p_tenant AND c.code = 'GERAL');\n\n RETURN jsonb_build_object('seeded', true, 'tenant_id', p_tenant, 'unit_id', v_unit,\n 'accounts', v_made, 'chart_accounts', v_chart);\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_seed_tenant(uuid) IS\n 'O m\u00EDnimo para um tenant receber dinheiro no primeiro dia: cofre, banco, receb\u00EDveis de cr\u00E9dito e de d\u00E9bito, plano de contas raso e um centro de custo. Idempotente por `code` (011).';\n\n-- \u2500\u2500 o Financeiro se pendura na cria\u00E7\u00E3o do tenant \u2500\u2500\u2500\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_financial_seed_new_tenant()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nBEGIN\n -- Nunca derruba a cria\u00E7\u00E3o do tenant: uma casa sem conta de cart\u00E3o \u00E9\n -- repar\u00E1vel com uma chamada; uma casa que n\u00E3o nasceu, n\u00E3o.\n BEGIN\n PERFORM public.financial_seed_tenant(NEW.id);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'financial_seed_tenant falhou para o tenant %: %', NEW.id, SQLERRM;\n END;\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS tenants_financial_seed ON public.tenants;\nCREATE TRIGGER tenants_financial_seed\n AFTER INSERT ON public.tenants\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_financial_seed_new_tenant();\n\n-- \u2500\u2500 e as casas que j\u00E1 nasceram sem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN SELECT t.id FROM public.tenants t LOOP\n BEGIN\n PERFORM public.financial_seed_tenant(r.id);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'backfill financial_seed_tenant % : %', r.id, SQLERRM;\n END;\n END LOOP;\nEND $$;\n\nREVOKE ALL ON FUNCTION public.financial_seed_tenant(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.financial_seed_tenant(uuid) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_seed_tenant(uuid) TO service_role;\n";
|
|
13
|
+
export declare const MIGRATION_012_THE_COMMISSIONS_BECOME_ONE_BILL_PER_PERSON = "-- 012_the_commissions_become_one_bill_per_person.sql\n--\n-- A apura\u00E7\u00E3o (009) produz N lan\u00E7amentos por pessoa \u2014 um por linha atendida. No\n-- fim do m\u00EAs a Ana tem quarenta, e ningu\u00E9m paga quarenta vezes. Falta o elo\n-- entre \"apurado\" e \"o dinheiro saiu\": o border\u00F4.\n--\n-- O QUE ELE FAZ. Junta os lan\u00E7amentos de um lote por PESSOA e levanta uma\n-- conta a pagar para cada uma, com o valor somado, e carimba em cada\n-- lan\u00E7amento qual conta o cobre. Depois disso a comiss\u00E3o vive no mesmo lugar\n-- que o aluguel e a conta de luz \u2014 a tela de contas a pagar, o extrato, o\n-- fluxo de caixa \u2014 em vez de num relat\u00F3rio paralelo que ningu\u00E9m concilia.\n--\n-- POR QUE UMA CONTA POR PESSOA, e n\u00E3o uma por lan\u00E7amento. Porque \u00E9 assim que\n-- se paga: um Pix para a Ana, n\u00E3o quarenta. E n\u00E3o uma s\u00F3 para todo mundo,\n-- porque cada profissional recebe em separado, em datas que podem divergir, e\n-- uma conta \u00FAnica n\u00E3o teria a quem ser paga.\n--\n-- A CLASSIFICA\u00C7\u00C3O VEM JUNTO. `financial_create_payable` aceita conta cont\u00E1bil,\n-- e a semeadura (011) cria \"4.02 Comiss\u00F5es\" em toda casa nova. Uma despesa de\n-- comiss\u00E3o que nasce sem classifica\u00E7\u00E3o \u00E9 uma linha a mais para algu\u00E9m\n-- classificar \u00E0 m\u00E3o no fechamento \u2014 e o DRE fica errado at\u00E9 l\u00E1.\n--\n-- APROVAR ANTES DE PAGAR. O border\u00F4 s\u00F3 recolhe o que est\u00E1 `approved`: apurar\n-- n\u00E3o \u00E9 autorizar. Quem apura pode ser quem confere; quem manda pagar \u00E9 outra\n-- decis\u00E3o, e o lan\u00E7amento carrega os dois estados de prop\u00F3sito.\n\nCREATE OR REPLACE FUNCTION public.financial_commission_approve(p_generation_id uuid)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_n int;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_commission_approve: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.commissions.manage', NULL) THEN\n RAISE EXCEPTION 'financial_commission_approve: exige financial.commissions.manage' USING ERRCODE = '42501';\n END IF;\n\n UPDATE public.plg_financial_commission_entries\n SET status = 'approved', updated_at = now()\n WHERE tenant_id = v_tenant AND generation_id = p_generation_id AND status = 'pending';\n GET DIAGNOSTICS v_n = ROW_COUNT;\n\n UPDATE public.plg_financial_commission_generations\n SET status = 'approved', updated_at = now()\n WHERE tenant_id = v_tenant AND id = p_generation_id;\n\n RETURN jsonb_build_object('generation_id', p_generation_id, 'approved', v_n);\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_commission_approve(uuid) IS\n 'Autoriza os lan\u00E7amentos de um lote. Apurar n\u00E3o \u00E9 autorizar: s\u00E3o duas decis\u00F5es e dois estados (012).';\n\n-- \u2500\u2500 o border\u00F4 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commission_pay_run(\n p_generation_id uuid,\n p_due_date date DEFAULT NULL\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_chart uuid;\n v_row record;\n v_payable uuid;\n v_bills int := 0;\n v_total numeric := 0;\n v_person text;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_commission_pay_run: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.commissions.manage', NULL) THEN\n RAISE EXCEPTION 'financial_commission_pay_run: exige financial.commissions.manage' USING ERRCODE = '42501';\n END IF;\n\n -- \"4.02 Comiss\u00F5es\" da semeadura (011). Ausente numa casa antiga: a conta a\n -- pagar nasce sem classifica\u00E7\u00E3o em vez de o border\u00F4 falhar \u2014 pagar a pessoa\n -- importa mais do que o DRE sair pronto.\n SELECT id INTO v_chart FROM public.plg_financial_chart_of_accounts\n WHERE tenant_id = v_tenant AND code = '4.02' LIMIT 1;\n\n FOR v_row IN\n SELECT e.person_id, e.unit_id, sum(e.amount) AS total, count(*) AS n\n FROM public.plg_financial_commission_entries e\n WHERE e.tenant_id = v_tenant\n AND e.generation_id = p_generation_id\n AND e.status = 'approved'\n AND e.payable_invoice_id IS NULL\n GROUP BY e.person_id, e.unit_id\n HAVING sum(e.amount) > 0\n LOOP\n SELECT name INTO v_person FROM public.people WHERE id = v_row.person_id;\n\n -- Uma conta por pessoa, e a chave de idempot\u00EAncia \u00E9 lote+pessoa: rodar o\n -- border\u00F4 duas vezes n\u00E3o levanta a segunda conta.\n -- Devolve jsonb, n\u00E3o uuid: o id da conta vem em `invoice_id`.\n v_payable := (public.financial_create_payable(\n 'Comiss\u00F5es \u00B7 ' || coalesce(v_person, 'profissional') || ' \u00B7 ' || v_row.n || ' lan\u00E7amento(s)',\n round(v_row.total, 2),\n coalesce(p_due_date, current_date),\n v_row.person_id,\n v_row.unit_id,\n v_chart,\n NULL,\n NULL,\n NULL,\n 'commission-run:' || p_generation_id::text || ':' || v_row.person_id::text\n ) ->> 'invoice_id')::uuid;\n\n UPDATE public.plg_financial_commission_entries\n SET payable_invoice_id = v_payable, updated_at = now()\n WHERE tenant_id = v_tenant AND generation_id = p_generation_id\n AND person_id = v_row.person_id AND status = 'approved'\n AND payable_invoice_id IS NULL;\n\n v_bills := v_bills + 1;\n v_total := v_total + v_row.total;\n END LOOP;\n\n RETURN jsonb_build_object('generation_id', p_generation_id, 'payables', v_bills,\n 'total', round(v_total, 2), 'due_date', coalesce(p_due_date, current_date));\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_commission_pay_run(uuid, date) IS\n 'Junta os lan\u00E7amentos aprovados de um lote por pessoa e levanta UMA conta a pagar para cada. \u00C9 o elo entre \"apurado\" e \"o dinheiro saiu\" (012).';\n\nREVOKE ALL ON FUNCTION public.financial_commission_approve(uuid) FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.financial_commission_pay_run(uuid, date) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.financial_commission_approve(uuid) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_commission_pay_run(uuid, date) TO authenticated;\n\n-- \u2500\u2500 o que a folha do m\u00EAs mostra \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_commission_payroll\nWITH (security_invoker = 'true') AS\nSELECT e.tenant_id,\n e.unit_id,\n e.generation_id,\n g.period_start,\n g.period_end,\n e.person_id,\n p.name AS person_name,\n count(*) AS entry_count,\n sum(e.amount)::numeric(14,2) AS total_amount,\n count(*) FILTER (WHERE e.status = 'pending')::int AS pending_count,\n count(*) FILTER (WHERE e.status = 'approved')::int AS approved_count,\n count(*) FILTER (WHERE e.status = 'paid')::int AS paid_count,\n -- Uma conta a pagar por pessoa, ent\u00E3o `min` \u00E9 ela mesma.\n min(e.payable_invoice_id::text)::uuid AS payable_invoice_id\n FROM public.plg_financial_commission_entries e\n LEFT JOIN public.plg_financial_commission_generations g ON g.id = e.generation_id\n LEFT JOIN public.people p ON p.id = e.person_id\n WHERE e.status <> 'cancelled'\n GROUP BY e.tenant_id, e.unit_id, e.generation_id, g.period_start, g.period_end, e.person_id, p.name;\n\nCOMMENT ON VIEW public.v_financial_commission_payroll IS\n 'A folha de comiss\u00F5es do per\u00EDodo: uma linha por pessoa, com quantos lan\u00E7amentos, quanto, em que estado, e a conta a pagar que a cobre (012).';\n\nGRANT SELECT ON public.v_financial_commission_payroll TO authenticated;\nGRANT SELECT ON public.v_financial_commission_payroll TO service_role;\n";
|
|
14
|
+
export declare const MIGRATION_013_A_DISCOUNT_HAS_A_REASON_AND_SOMEONE_WHO_ALLOWED_IT = "-- 013_a_discount_has_a_reason_and_someone_who_allowed_it.sql\n--\n-- Hoje qualquer pessoa com acesso \u00E0 venda desconta qualquer valor, sem motivo\n-- e sem rastro. `items.discount` e `orders.discount` s\u00E3o n\u00FAmeros que aparecem\n-- e ningu\u00E9m sabe quem os p\u00F4s l\u00E1 nem por qu\u00EA.\n--\n-- \u00C9 a fraude mais barata que um sistema de varejo permite, e o dano nem precisa\n-- de m\u00E1-f\u00E9: no fim do m\u00EAs a margem caiu e n\u00E3o h\u00E1 a quem perguntar. O V1 tem\n-- tr\u00EAs tabelas para isso (`discount_reasons`, `discount_users`,\n-- `order_discounts`) e n\u00F3s t\u00EDnhamos zero.\n--\n-- TR\u00CAS PE\u00C7AS, e cada uma responde uma pergunta:\n--\n-- POR QU\u00CA o motivo vem de um cat\u00E1logo, n\u00E3o de um campo livre. \"Cliente\n-- antigo\" e \"cliente antigo \" s\u00E3o o mesmo desconto e dois\n-- relat\u00F3rios; e um motivo cadastrado \u00E9 o que permite perguntar\n-- \"quanto custou a pol\u00EDtica de fidelidade este m\u00EAs\".\n--\n-- QUEM PODE o teto \u00E9 por PESSOA e por motivo. A recep\u00E7\u00E3o desconta 5%; a\n-- gerente, 20%; a dona, o que quiser. Sem teto, \"quem pode\n-- descontar\" \u00E9 a mesma pergunta que \"quem tem acesso \u00E0 tela\".\n--\n-- QUEM DEU o registro, com quem pediu, quem autorizou e quando. Acima do\n-- teto exige uma segunda pessoa \u2014 segrega\u00E7\u00E3o de fun\u00E7\u00E3o, que \u00E9 o\n-- que separa um controle de uma boa inten\u00E7\u00E3o.\n--\n-- O QUE ESTE ARQUIVO N\u00C3O FAZ: bloquear a venda. A trava vive no fluxo que\n-- concede (a tela e a RPC de desconto), n\u00E3o num gatilho sobre `items` \u2014 um\n-- gatilho recusando `UPDATE items SET discount` derrubaria importa\u00E7\u00E3o,\n-- migra\u00E7\u00E3o e corre\u00E7\u00E3o de dados, que s\u00E3o caminhos leg\u00EDtimos e sem tela.\n\n-- \u2500\u2500 por qu\u00EA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_discount_reasons (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n code text NOT NULL,\n name text NOT NULL,\n -- O teto do MOTIVO, independente de quem concede: \"amostra gr\u00E1tis\" pode\n -- ser 100% e \"cortesia\" 10%, mesmo para a dona.\n max_percent numeric(6,3),\n requires_approval boolean DEFAULT false NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_discount_reasons'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_discount_reasons ADD CONSTRAINT plg_financial_discount_reasons_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_reasons_tenant_code_key') THEN\n ALTER TABLE ONLY public.plg_financial_discount_reasons ADD CONSTRAINT plg_financial_discount_reasons_tenant_code_key UNIQUE (tenant_id, code);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_reasons_pct_check') THEN\n ALTER TABLE ONLY public.plg_financial_discount_reasons ADD CONSTRAINT plg_financial_discount_reasons_pct_check\n CHECK (max_percent IS NULL OR (max_percent >= 0 AND max_percent <= 100));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_reasons_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_discount_reasons ADD CONSTRAINT plg_financial_discount_reasons_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_discount_reasons_tenant_idx\n ON public.plg_financial_discount_reasons USING btree (tenant_id) WHERE is_active;\n\nDROP TRIGGER IF EXISTS plg_financial_discount_reasons_updated_at ON public.plg_financial_discount_reasons;\nCREATE TRIGGER plg_financial_discount_reasons_updated_at\n BEFORE UPDATE ON public.plg_financial_discount_reasons\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_discount_reasons ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_discount_reasons FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_discount_reasons_authz_select ON public.plg_financial_discount_reasons;\nCREATE POLICY plg_financial_discount_reasons_authz_select ON public.plg_financial_discount_reasons\n -- Um cat\u00E1logo de motivos \u00E9 o que a tela de venda oferece numa lista; ler\n -- exige estar na casa e ter leitura do Financeiro. CONCEDER \u00E9 outra coisa e\n -- mora no verbo, com teto.\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_discount_reasons_authz_insert ON public.plg_financial_discount_reasons;\nCREATE POLICY plg_financial_discount_reasons_authz_insert ON public.plg_financial_discount_reasons\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.create', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_discount_reasons_authz_update ON public.plg_financial_discount_reasons;\nCREATE POLICY plg_financial_discount_reasons_authz_update ON public.plg_financial_discount_reasons\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_discount_reasons_authz_delete ON public.plg_financial_discount_reasons;\nCREATE POLICY plg_financial_discount_reasons_authz_delete ON public.plg_financial_discount_reasons\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.delete', unit_id)));\n\nGRANT ALL ON TABLE public.plg_financial_discount_reasons TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_discount_reasons TO service_role;\n\n-- \u2500\u2500 quem pode, e at\u00E9 quanto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_discount_authorities (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n user_id uuid NOT NULL,\n -- Nulo = vale para qualquer motivo. Um teto por motivo \u00E9 a exce\u00E7\u00E3o, n\u00E3o a\n -- regra, e obrigar uma linha por motivo faria a configura\u00E7\u00E3o invi\u00E1vel.\n reason_id uuid,\n max_percent numeric(6,3) DEFAULT 0 NOT NULL,\n max_amount numeric(14,2),\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_discount_authorities'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_discount_authorities ADD CONSTRAINT plg_financial_discount_authorities_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_authorities_pct_check') THEN\n ALTER TABLE ONLY public.plg_financial_discount_authorities ADD CONSTRAINT plg_financial_discount_authorities_pct_check\n CHECK (max_percent >= 0 AND max_percent <= 100);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_authorities_reason_fk') THEN\n ALTER TABLE ONLY public.plg_financial_discount_authorities ADD CONSTRAINT plg_financial_discount_authorities_reason_fk\n FOREIGN KEY (reason_id) REFERENCES public.plg_financial_discount_reasons(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_authorities_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_discount_authorities ADD CONSTRAINT plg_financial_discount_authorities_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_discount_authorities_user_idx\n ON public.plg_financial_discount_authorities USING btree (tenant_id, user_id) WHERE is_active;\n\nDROP TRIGGER IF EXISTS plg_financial_discount_authorities_updated_at ON public.plg_financial_discount_authorities;\nCREATE TRIGGER plg_financial_discount_authorities_updated_at\n BEFORE UPDATE ON public.plg_financial_discount_authorities\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_discount_authorities ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_discount_authorities FORCE ROW LEVEL SECURITY;\n\n-- Ver o pr\u00F3prio teto \u00E9 leg\u00EDtimo \u2014 a tela precisa saber at\u00E9 quanto oferecer.\n-- MUDAR o teto \u00E9 de quem administra: um vendedor que edita o pr\u00F3prio limite\n-- n\u00E3o tem limite.\nDROP POLICY IF EXISTS plg_financial_discount_authorities_authz_select ON public.plg_financial_discount_authorities;\nCREATE POLICY plg_financial_discount_authorities_authz_select ON public.plg_financial_discount_authorities\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (user_id = (SELECT auth.uid())\n OR (SELECT app.has_permission('financial.manage', unit_id))));\n\nDROP POLICY IF EXISTS plg_financial_discount_authorities_authz_insert ON public.plg_financial_discount_authorities;\nCREATE POLICY plg_financial_discount_authorities_authz_insert ON public.plg_financial_discount_authorities\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.manage', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_discount_authorities_authz_update ON public.plg_financial_discount_authorities;\nCREATE POLICY plg_financial_discount_authorities_authz_update ON public.plg_financial_discount_authorities\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.manage', unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.manage', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_discount_authorities_authz_delete ON public.plg_financial_discount_authorities;\nCREATE POLICY plg_financial_discount_authorities_authz_delete ON public.plg_financial_discount_authorities\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.manage', unit_id)));\n\nGRANT ALL ON TABLE public.plg_financial_discount_authorities TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_discount_authorities TO service_role;\n\n-- \u2500\u2500 quem deu, e com o aval de quem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_discount_grants (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n order_id uuid,\n item_id uuid,\n reason_id uuid,\n percent numeric(6,3) DEFAULT 0 NOT NULL,\n amount numeric(14,2) DEFAULT 0 NOT NULL,\n base_amount numeric(14,2) DEFAULT 0 NOT NULL,\n notes text,\n requested_by_user_id uuid,\n -- Nulo enquanto o desconto est\u00E1 dentro do teto de quem pediu: n\u00E3o houve\n -- segunda pessoa porque n\u00E3o precisou.\n approved_by_user_id uuid,\n approved_at timestamp with time zone,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_discount_grants'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_discount_grants ADD CONSTRAINT plg_financial_discount_grants_pkey PRIMARY KEY (id);\n END IF;\n -- Um desconto \u00E9 sobre uma venda ou sobre uma linha, n\u00E3o sobre nada.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_grants_target_check') THEN\n ALTER TABLE ONLY public.plg_financial_discount_grants ADD CONSTRAINT plg_financial_discount_grants_target_check\n CHECK (order_id IS NOT NULL OR item_id IS NOT NULL);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_grants_order_fk') THEN\n ALTER TABLE ONLY public.plg_financial_discount_grants ADD CONSTRAINT plg_financial_discount_grants_order_fk\n FOREIGN KEY (order_id) REFERENCES public.orders(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_grants_item_fk') THEN\n ALTER TABLE ONLY public.plg_financial_discount_grants ADD CONSTRAINT plg_financial_discount_grants_item_fk\n FOREIGN KEY (item_id) REFERENCES public.items(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_grants_reason_fk') THEN\n ALTER TABLE ONLY public.plg_financial_discount_grants ADD CONSTRAINT plg_financial_discount_grants_reason_fk\n FOREIGN KEY (reason_id) REFERENCES public.plg_financial_discount_reasons(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_discount_grants_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_discount_grants ADD CONSTRAINT plg_financial_discount_grants_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_discount_grants_order_idx\n ON public.plg_financial_discount_grants USING btree (order_id);\nCREATE INDEX IF NOT EXISTS plg_financial_discount_grants_tenant_created_idx\n ON public.plg_financial_discount_grants USING btree (tenant_id, created_at DESC);\n\nDROP TRIGGER IF EXISTS plg_financial_discount_grants_updated_at ON public.plg_financial_discount_grants;\nCREATE TRIGGER plg_financial_discount_grants_updated_at\n BEFORE UPDATE ON public.plg_financial_discount_grants\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_discount_grants ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_discount_grants FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_discount_grants_authz_select ON public.plg_financial_discount_grants;\nCREATE POLICY plg_financial_discount_grants_authz_select ON public.plg_financial_discount_grants\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', unit_id)));\n\n-- Sem INSERT/UPDATE/DELETE: o registro nasce pelo verbo, que \u00E9 onde o teto \u00E9\n-- conferido. Uma policy de INSERT aqui seria a porta pela qual se registra um\n-- desconto que ningu\u00E9m autorizou.\n\nGRANT SELECT ON TABLE public.plg_financial_discount_grants TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_discount_grants TO service_role;\n\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_discount_reasons', 'financial.discount_reason', 'financial', false, 'enforce', ARRAY[]::text[], false, 'unit_optional', false),\n ('public', 'plg_financial_discount_authorities', 'financial.discount_authority', 'financial', false, 'enforce', ARRAY[]::text[], true, 'unit_optional', false),\n ('public', 'plg_financial_discount_grants', 'financial.discount_grant', 'financial', false, 'enforce', ARRAY[]::text[], true, 'unit_optional', false)\nON CONFLICT DO NOTHING;\n\nINSERT INTO app.permissions (key, plugin, description)\nVALUES ('financial.discount.approve', 'financial',\n 'Autorizar desconto acima do teto de quem pediu. Segrega\u00E7\u00E3o de fun\u00E7\u00E3o: quem pede n\u00E3o aprova o pr\u00F3prio pedido.')\nON CONFLICT (key) DO NOTHING;\n\n-- \u2500\u2500 o verbo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_grant_discount(\n p_order_id uuid,\n p_percent numeric,\n p_reason_id uuid DEFAULT NULL,\n p_item_id uuid DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_approver_user_id uuid DEFAULT NULL\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_actor uuid := auth.uid();\n v_unit uuid;\n v_base numeric;\n v_ceiling numeric;\n v_reason record;\n v_amount numeric;\n v_grant uuid;\n v_approved uuid;\nBEGIN\n IF v_tenant IS NULL OR v_actor IS NULL THEN\n RAISE EXCEPTION 'financial_grant_discount: sem sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF p_percent IS NULL OR p_percent <= 0 OR p_percent > 100 THEN\n RAISE EXCEPTION 'financial_grant_discount: percentual tem de estar entre 0 e 100' USING ERRCODE = '22023';\n END IF;\n\n SELECT o.unit_id, o.total INTO v_unit, v_base FROM public.orders o\n WHERE o.id = p_order_id AND o.tenant_id = v_tenant;\n IF v_base IS NULL THEN\n RAISE EXCEPTION 'financial_grant_discount: pedido % n\u00E3o encontrado', p_order_id USING ERRCODE = 'P0002';\n END IF;\n\n IF p_item_id IS NOT NULL THEN\n SELECT i.total INTO v_base FROM public.items i WHERE i.id = p_item_id AND i.tenant_id = v_tenant;\n END IF;\n\n -- O teto do motivo vale para todo mundo, inclusive para a dona.\n IF p_reason_id IS NOT NULL THEN\n SELECT * INTO v_reason FROM public.plg_financial_discount_reasons\n WHERE id = p_reason_id AND tenant_id = v_tenant AND is_active;\n IF v_reason.id IS NULL THEN\n RAISE EXCEPTION 'financial_grant_discount: motivo inv\u00E1lido' USING ERRCODE = '22023';\n END IF;\n IF v_reason.max_percent IS NOT NULL AND p_percent > v_reason.max_percent THEN\n RAISE EXCEPTION 'financial_grant_discount: % excede o teto do motivo (%%%)', p_percent, v_reason.max_percent\n USING ERRCODE = '23514';\n END IF;\n END IF;\n\n -- O teto de QUEM PEDE. Sem linha de autoridade o teto \u00E9 zero: n\u00E3o ter sido\n -- configurado n\u00E3o \u00E9 permiss\u00E3o para descontar o que quiser.\n SELECT max(a.max_percent) INTO v_ceiling\n FROM public.plg_financial_discount_authorities a\n WHERE a.tenant_id = v_tenant AND a.user_id = v_actor AND a.is_active\n AND (a.reason_id IS NULL OR a.reason_id = p_reason_id)\n AND (a.unit_id IS NULL OR a.unit_id = v_unit);\n v_ceiling := coalesce(v_ceiling, 0);\n\n IF p_percent > v_ceiling THEN\n -- Acima do teto exige uma SEGUNDA pessoa, e ela n\u00E3o pode ser a primeira:\n -- aprovar o pr\u00F3prio pedido \u00E9 o mesmo que n\u00E3o ter teto.\n IF p_approver_user_id IS NULL THEN\n RAISE EXCEPTION 'financial_grant_discount: % excede seu teto de %%% \u2014 precisa de aprova\u00E7\u00E3o', p_percent, v_ceiling\n USING ERRCODE = '42501',\n HINT = 'Pe\u00E7a a quem tem financial.discount.approve.';\n END IF;\n IF p_approver_user_id = v_actor THEN\n RAISE EXCEPTION 'financial_grant_discount: quem pede n\u00E3o aprova o pr\u00F3prio desconto' USING ERRCODE = '42501';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM app.memberships m\n WHERE m.tenant_id = v_tenant AND m.user_id = p_approver_user_id AND m.active) THEN\n RAISE EXCEPTION 'financial_grant_discount: aprovador n\u00E3o \u00E9 da casa' USING ERRCODE = '42501';\n END IF;\n v_approved := p_approver_user_id;\n END IF;\n\n v_amount := round(coalesce(v_base, 0) * (p_percent / 100), 2);\n\n INSERT INTO public.plg_financial_discount_grants\n (tenant_id, unit_id, order_id, item_id, reason_id, percent, amount, base_amount,\n notes, requested_by_user_id, approved_by_user_id, approved_at)\n VALUES\n (v_tenant, v_unit, p_order_id, p_item_id, p_reason_id, p_percent, v_amount, coalesce(v_base, 0),\n p_notes, v_actor, v_approved, CASE WHEN v_approved IS NOT NULL THEN now() END)\n RETURNING id INTO v_grant;\n\n RETURN jsonb_build_object('grant_id', v_grant, 'percent', p_percent, 'amount', v_amount,\n 'base_amount', v_base, 'required_approval', v_approved IS NOT NULL);\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_grant_discount(uuid, numeric, uuid, uuid, text, uuid) IS\n 'Registra um desconto com motivo, teto de quem pede e, acima do teto, o aval de uma SEGUNDA pessoa (013).';\n\nREVOKE ALL ON FUNCTION public.financial_grant_discount(uuid, numeric, uuid, uuid, text, uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.financial_grant_discount(uuid, numeric, uuid, uuid, text, uuid) TO authenticated;\n\n-- \u2500\u2500 o que o fechamento do m\u00EAs pergunta \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_discounts_given\nWITH (security_invoker = 'true') AS\nSELECT g.id, g.tenant_id, g.unit_id, g.order_id, g.item_id,\n o.reference_number AS order_number,\n r.code AS reason_code, r.name AS reason_name,\n g.percent, g.amount, g.base_amount,\n g.requested_by_user_id, g.approved_by_user_id, g.approved_at,\n (g.approved_by_user_id IS NOT NULL) AS needed_approval,\n g.notes, g.created_at\n FROM public.plg_financial_discount_grants g\n LEFT JOIN public.orders o ON o.id = g.order_id\n LEFT JOIN public.plg_financial_discount_reasons r ON r.id = g.reason_id;\n\nCOMMENT ON VIEW public.v_financial_discounts_given IS\n 'Quanto a casa deu de desconto, por motivo e por quem \u2014 a pergunta \"por que a margem caiu\" com resposta (013).';\n\nGRANT SELECT ON public.v_financial_discounts_given TO authenticated;\nGRANT SELECT ON public.v_financial_discounts_given TO service_role;\n";
|
|
15
|
+
export declare const MIGRATION_014_THE_BILL_THAT_COMES_BACK_EVERY_MONTH = "-- 014_the_bill_that_comes_back_every_month.sql\n--\n-- Aluguel, contabilidade, internet, assinatura de software: contas que chegam\n-- todo m\u00EAs, com o mesmo valor e o mesmo dono. Hoje algu\u00E9m digita cada uma, doze\n-- vezes por ano, e a que for esquecida s\u00F3 aparece quando o fornecedor liga.\n--\n-- O MODELO \u00C9 UM MOLDE, e n\u00E3o doze contas criadas de uma vez.\n--\n-- Doze contas adiantadas poluem o fluxo de caixa com previs\u00E3o que ningu\u00E9m\n-- revisou, e mudar o valor do aluguel em mar\u00E7o obrigaria a corrigir nove.\n-- O molde guarda a REGRA; a conta nasce quando o m\u00EAs chega, com o valor que\n-- o molde tem naquele momento.\n--\n-- QUEM DISPARA. Uma fun\u00E7\u00E3o que gera \"o que vence at\u00E9 tal data\", chamada por\n-- quem quiser \u2014 um cron, a tela ao abrir, uma rotina noturna. N\u00E3o um gatilho\n-- de tempo, que o banco n\u00E3o tem, nem um agendador escondido: gerar \u00E9 um ato\n-- com data, e quem o chamou fica no lote.\n--\n-- IDEMPOTENTE POR COMPET\u00CANCIA. A chave \u00E9 molde + m\u00EAs. Rodar duas vezes no\n-- mesmo dia, ou o cron rodar de novo depois de uma falha, n\u00E3o cria a segunda\n-- conta de aluguel de mar\u00E7o \u2014 que \u00E9 o erro que faria algu\u00E9m pagar duas vezes.\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_recurring_bills (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n description text NOT NULL,\n -- `payable` (aluguel) ou `receivable` (mensalidade, assinatura). O mesmo\n -- molde serve os dois: o que muda \u00E9 a dire\u00E7\u00E3o do t\u00EDtulo que ele levanta.\n direction text DEFAULT 'payable'::text NOT NULL,\n counterparty_person_id uuid,\n amount numeric(14,2) NOT NULL,\n chart_account_id uuid,\n cost_center_id uuid,\n -- Mensal \u00E9 o caso de 95% das contas recorrentes de um SMB. Semanal e anual\n -- entram pelo mesmo campo quando algu\u00E9m precisar.\n frequency text DEFAULT 'monthly'::text NOT NULL,\n -- O dia do vencimento. 31 num m\u00EAs de 30 cai no \u00FAltimo dia, e n\u00E3o no dia 1\n -- do m\u00EAs seguinte \u2014 quem escreveu 31 quis dizer \"no fim do m\u00EAs\".\n due_day integer DEFAULT 1 NOT NULL,\n starts_on date DEFAULT current_date NOT NULL,\n -- Nulo = sem fim previsto, que \u00E9 o normal de um aluguel.\n ends_on date,\n is_active boolean DEFAULT true NOT NULL,\n notes text,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_recurring_bills'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_recurring_bills ADD CONSTRAINT plg_financial_recurring_bills_pkey PRIMARY KEY (id);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_recurring_bills_direction_check') THEN\n ALTER TABLE ONLY public.plg_financial_recurring_bills ADD CONSTRAINT plg_financial_recurring_bills_direction_check\n CHECK (direction = ANY (ARRAY['payable', 'receivable']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_recurring_bills_frequency_check') THEN\n ALTER TABLE ONLY public.plg_financial_recurring_bills ADD CONSTRAINT plg_financial_recurring_bills_frequency_check\n CHECK (frequency = ANY (ARRAY['weekly', 'monthly', 'quarterly', 'yearly']));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_recurring_bills_due_day_check') THEN\n ALTER TABLE ONLY public.plg_financial_recurring_bills ADD CONSTRAINT plg_financial_recurring_bills_due_day_check\n CHECK (due_day >= 1 AND due_day <= 31);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_recurring_bills_amount_check') THEN\n ALTER TABLE ONLY public.plg_financial_recurring_bills ADD CONSTRAINT plg_financial_recurring_bills_amount_check\n CHECK (amount > 0);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_recurring_bills_person_fk') THEN\n ALTER TABLE ONLY public.plg_financial_recurring_bills ADD CONSTRAINT plg_financial_recurring_bills_person_fk\n FOREIGN KEY (counterparty_person_id) REFERENCES public.people(id) ON DELETE SET NULL;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_recurring_bills_tenant_id_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_recurring_bills ADD CONSTRAINT plg_financial_recurring_bills_tenant_id_fkey\n FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_recurring_bills_tenant_idx\n ON public.plg_financial_recurring_bills USING btree (tenant_id) WHERE is_active;\n\nDROP TRIGGER IF EXISTS plg_financial_recurring_bills_updated_at ON public.plg_financial_recurring_bills;\nCREATE TRIGGER plg_financial_recurring_bills_updated_at\n BEFORE UPDATE ON public.plg_financial_recurring_bills\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_recurring_bills ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_recurring_bills FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_recurring_bills_authz_select ON public.plg_financial_recurring_bills;\nCREATE POLICY plg_financial_recurring_bills_authz_select ON public.plg_financial_recurring_bills\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.read', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_recurring_bills_authz_insert ON public.plg_financial_recurring_bills;\nCREATE POLICY plg_financial_recurring_bills_authz_insert ON public.plg_financial_recurring_bills\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.create', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_recurring_bills_authz_update ON public.plg_financial_recurring_bills;\nCREATE POLICY plg_financial_recurring_bills_authz_update ON public.plg_financial_recurring_bills\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', unit_id)))\n WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.edit', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_recurring_bills_authz_delete ON public.plg_financial_recurring_bills;\nCREATE POLICY plg_financial_recurring_bills_authz_delete ON public.plg_financial_recurring_bills\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_permission('financial.delete', unit_id)));\n\nGRANT ALL ON TABLE public.plg_financial_recurring_bills TO authenticated;\nGRANT ALL ON TABLE public.plg_financial_recurring_bills TO service_role;\n\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_recurring_bills', 'financial.recurring_bill', 'financial', false, 'enforce', ARRAY[]::text[], false, 'unit_optional', false)\nON CONFLICT DO NOTHING;\n\nCOMMENT ON TABLE public.plg_financial_recurring_bills IS\n 'O molde de uma conta que volta: guarda a REGRA, e o t\u00EDtulo nasce quando o m\u00EAs chega \u2014 n\u00E3o doze de uma vez (014).';\n\n-- \u2500\u2500 gerar o que vence at\u00E9 uma data \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_recurring_run(p_through date DEFAULT NULL)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_through date := coalesce(p_through, (date_trunc('month', current_date) + interval '1 month - 1 day')::date);\n v_bill record;\n v_month date;\n v_due date;\n v_key text;\n v_made int := 0;\n v_total numeric := 0;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_recurring_run: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.create', NULL) THEN\n RAISE EXCEPTION 'financial_recurring_run: exige financial.create' USING ERRCODE = '42501';\n END IF;\n\n FOR v_bill IN\n SELECT * FROM public.plg_financial_recurring_bills\n WHERE tenant_id = v_tenant AND is_active\n AND starts_on <= v_through\n AND (ends_on IS NULL OR ends_on >= starts_on)\n LOOP\n v_month := date_trunc('month', greatest(v_bill.starts_on, date_trunc('month', v_through)::date))::date;\n\n WHILE v_month <= v_through LOOP\n -- O dia 31 num m\u00EAs de 30 cai no \u00DALTIMO dia, n\u00E3o no dia 1 do m\u00EAs\n -- seguinte: quem escreveu 31 quis dizer \"no fim do m\u00EAs\".\n v_due := least(\n (v_month + ((v_bill.due_day - 1) || ' days')::interval)::date,\n (v_month + interval '1 month - 1 day')::date);\n\n EXIT WHEN v_bill.ends_on IS NOT NULL AND v_due > v_bill.ends_on;\n\n -- molde + compet\u00EAncia: rodar de novo n\u00E3o cria o segundo aluguel de mar\u00E7o.\n v_key := 'recurring:' || v_bill.id::text || ':' || to_char(v_month, 'YYYY-MM');\n\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_financial_invoices f\n WHERE f.tenant_id = v_tenant\n AND f.metadata ->> 'recurring_key' = v_key\n AND f.cancelled_at IS NULL\n ) THEN\n PERFORM public.financial_create_invoice(\n v_bill.direction,\n v_bill.amount,\n v_bill.counterparty_person_id,\n NULL,\n v_bill.unit_id,\n jsonb_build_array(jsonb_build_object('number', 1, 'due_date', v_due, 'amount', v_bill.amount)),\n v_month,\n v_bill.description,\n v_bill.notes,\n jsonb_build_object('source', 'financial_recurring_run',\n 'recurring_bill_id', v_bill.id,\n 'recurring_key', v_key),\n v_key\n );\n v_made := v_made + 1;\n v_total := v_total + v_bill.amount;\n END IF;\n\n v_month := (v_month + CASE v_bill.frequency\n WHEN 'weekly' THEN interval '7 days'\n WHEN 'quarterly' THEN interval '3 months'\n WHEN 'yearly' THEN interval '1 year'\n ELSE interval '1 month'\n END)::date;\n END LOOP;\n END LOOP;\n\n RETURN jsonb_build_object('through', v_through, 'created', v_made, 'total', round(v_total, 2));\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_recurring_run(date) IS\n 'Levanta os t\u00EDtulos das contas recorrentes que vencem at\u00E9 a data. Idempotente por molde + compet\u00EAncia: rodar duas vezes n\u00E3o cria a segunda conta do mesmo m\u00EAs (014).';\n\nREVOKE ALL ON FUNCTION public.financial_recurring_run(date) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.financial_recurring_run(date) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_recurring_run(date) TO service_role;\n";
|
|
16
|
+
export declare const MIGRATION_015_THE_SALE_KNOWS_WHAT_IT_COST = "-- 015_the_sale_knows_what_it_cost.sql\n--\n-- O Financeiro sabe o que ENTROU. N\u00E3o sabe o que aquilo CUSTOU \u2014 e faturamento\n-- sem custo \u00E9 o n\u00FAmero que faz uma casa crescer quebrando.\n--\n-- As duas metades j\u00E1 existem e nunca se falaram:\n--\n-- `plg_inventory_stock_movements` guarda `unit_cost`, `total_cost`, e\n-- `source_item_type`/`source_item_id` apontando para a linha do pedido que\n-- consumiu o estoque. O elo estava constru\u00EDdo.\n--\n-- `plg_financial_commission_entries` (009) guarda quanto daquela linha foi\n-- para quem a executou.\n--\n-- Faltava somar. Esta migration n\u00E3o cria tabela nenhuma: cria a VISTA que\n-- responde \"esta venda deu lucro?\", que \u00E9 a pergunta que nenhuma tela do\n-- m\u00F3dulo respondia.\n--\n-- MARGEM = RECEITA \u2212 CMV \u2212 COMISS\u00C3O, por linha.\n--\n-- Receita \u00E9 a linha faturada, l\u00EDquida de desconto \u2014 o que a casa realmente\n-- cobrou, n\u00E3o o de tabela.\n-- CMV \u00E9 o custo do estoque que aquela linha consumiu, do raz\u00E3o do invent\u00E1rio.\n-- Comiss\u00E3o \u00E9 o que a linha pagou a quem a executou.\n--\n-- POR QUE UMA VIEW, E N\u00C3O UMA TABELA DE CUSTO. Um custo copiado para o\n-- Financeiro \u00E9 um segundo lugar onde o custo mora, e dois lugares divergem: uma\n-- corre\u00E7\u00E3o de invent\u00E1rio n\u00E3o voltaria atr\u00E1s. A vista l\u00EA o raz\u00E3o, sempre atual.\n--\n-- SERVI\u00C7O N\u00C3O TEM CMV, e isso \u00E9 resposta, n\u00E3o lacuna. Um corte de cabelo n\u00E3o\n-- consome estoque, ent\u00E3o o custo \u00E9 zero e a margem \u00E9 receita menos comiss\u00E3o \u2014\n-- que \u00E9 exatamente a conta de um sal\u00E3o.\n--\n-- O QUE ELA N\u00C3O FAZ: custo de m\u00E3o de obra fixa, rateio de aluguel, energia.\n-- Isso \u00E9 DRE, e o DRE l\u00EA o plano de contas \u2014 outra pergunta, outro caminho.\n\n-- O INVENT\u00C1RIO PODE N\u00C3O ESTAR INSTALADO, e o Financeiro n\u00E3o pode depender dele\n-- para existir. Um sal\u00E3o que s\u00F3 vende servi\u00E7o nunca ter\u00E1 raz\u00E3o de estoque \u2014 e a\n-- corrente aplica o financeiro ANTES do invent\u00E1rio, ent\u00E3o a tabela n\u00E3o existe\n-- nem no momento em que esta migration roda.\n--\n-- A primeira tentativa foi criar a vista em duas formas conforme a tabela\n-- existisse. Ela aplica e mente: no db-from-zero a vista nasce com CMV zero\n-- FIXO, e o invent\u00E1rio chegando depois n\u00E3o a corrige \u2014 o custo ficaria zero\n-- para sempre, que \u00E9 a pior resposta poss\u00EDvel numa conta de margem.\n--\n-- O custo passa a ser resolvido em TEMPO DE EXECU\u00C7\u00C3O. SQL din\u00E2mico dentro da\n-- fun\u00E7\u00E3o resolve o nome quando algu\u00E9m pergunta, n\u00E3o quando o arquivo roda:\n-- sem invent\u00E1rio devolve zero, e no dia em que ele entra a mesma fun\u00E7\u00E3o passa a\n-- ler o raz\u00E3o, sem republicar nada.\nCREATE OR REPLACE FUNCTION public.plg_financial_item_cogs(p_item_id uuid)\n RETURNS numeric\n LANGUAGE plpgsql\n STABLE\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE v_cogs numeric;\nBEGIN\n IF to_regclass('public.plg_inventory_stock_movements') IS NULL THEN\n RETURN 0;\n END IF;\n -- `out` e n\u00E3o estornado: devolver ao estoque n\u00E3o \u00E9 custo da venda.\n EXECUTE $q$\n SELECT coalesce(sum(coalesce(m.total_cost, m.unit_cost * m.quantity, 0)), 0)\n FROM public.plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'\n AND m.source_item_id = $1\n AND m.kind = 'out'\n AND m.reverses_movement_id IS NULL\n $q$ INTO v_cogs USING p_item_id;\n RETURN coalesce(v_cogs, 0);\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_financial_item_cogs(uuid) IS\n 'O custo do estoque que esta linha consumiu, do raz\u00E3o do invent\u00E1rio. Resolve o nome em tempo de execu\u00E7\u00E3o: sem invent\u00E1rio devolve zero, e passa a ler sozinha no dia em que ele entra (015).';\n\nCREATE OR REPLACE VIEW public.v_financial_line_margin\nWITH (security_invoker = 'true') AS\nWITH comm AS (\n SELECT e.item_id, sum(e.amount) AS commission\n FROM public.plg_financial_commission_entries e\n WHERE e.status <> 'cancelled'\n GROUP BY e.item_id\n)\nSELECT i.id AS item_id,\n i.tenant_id,\n i.unit_id,\n i.order_id,\n f.id AS invoice_id,\n f.number AS invoice_number,\n f.competence_date,\n i.product_id,\n i.name AS item_name,\n i.quantity,\n -- O que a casa cobrou de verdade, l\u00EDquido de desconto.\n (coalesce(i.total, 0) - coalesce(i.discount, 0))::numeric(14,2) AS revenue,\n public.plg_financial_item_cogs(i.id)::numeric(14,2) AS cogs,\n coalesce(k.commission, 0)::numeric(14,2) AS commission,\n (coalesce(i.total, 0) - coalesce(i.discount, 0)\n - public.plg_financial_item_cogs(i.id) - coalesce(k.commission, 0))::numeric(14,2) AS margin,\n -- Percentual s\u00F3 quando h\u00E1 receita: dividir por zero numa cortesia\n -- devolveria NULL e a tela mostraria um tra\u00E7o onde deveria dizer 0.\n CASE WHEN (coalesce(i.total, 0) - coalesce(i.discount, 0)) > 0\n THEN round(((coalesce(i.total, 0) - coalesce(i.discount, 0)\n - public.plg_financial_item_cogs(i.id) - coalesce(k.commission, 0))\n / (coalesce(i.total, 0) - coalesce(i.discount, 0))) * 100, 2)\n ELSE 0\n END AS margin_percent\n FROM public.items i\n -- A linha do PEDIDO \u00E9 a que consome estoque e a que tem participante; a\n -- fatura chega por ela (items_one_parent: uma linha tem um pai s\u00F3).\n LEFT JOIN public.plg_financial_invoices f\n ON f.order_id = i.order_id AND f.cancelled_at IS NULL\n LEFT JOIN comm k ON k.item_id = i.id\n WHERE i.order_id IS NOT NULL;\n\nCOMMENT ON VIEW public.v_financial_line_margin IS\n 'Receita \u2212 CMV \u2212 comiss\u00E3o, por linha vendida. O CMV vem do raz\u00E3o do invent\u00E1rio pelo elo source_item_id, que j\u00E1 existia e ningu\u00E9m somava. Servi\u00E7o tem CMV zero, e isso \u00E9 resposta (015).';\n\nGRANT SELECT ON public.v_financial_line_margin TO authenticated;\nGRANT SELECT ON public.v_financial_line_margin TO service_role;\n\n-- \u2500\u2500 e a mesma pergunta no tamanho em que o dono a faz \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_margin_by_month\nWITH (security_invoker = 'true') AS\nSELECT tenant_id,\n unit_id,\n date_trunc('month', coalesce(competence_date, current_date))::date AS competence_month,\n count(*)::int AS lines,\n sum(revenue)::numeric(14,2) AS revenue,\n sum(cogs)::numeric(14,2) AS cogs,\n sum(commission)::numeric(14,2) AS commission,\n sum(margin)::numeric(14,2) AS margin,\n CASE WHEN sum(revenue) > 0\n THEN round((sum(margin) / sum(revenue)) * 100, 2) ELSE 0 END AS margin_percent\n FROM public.v_financial_line_margin\n GROUP BY tenant_id, unit_id, date_trunc('month', coalesce(competence_date, current_date))::date;\n\nCOMMENT ON VIEW public.v_financial_margin_by_month IS\n 'A margem do m\u00EAs: receita, CMV, comiss\u00E3o e o que sobrou. A pergunta que o dono faz, no tamanho em que ele a faz (015).';\n\nGRANT SELECT ON public.v_financial_margin_by_month TO authenticated;\nGRANT SELECT ON public.v_financial_margin_by_month TO service_role;\n";
|
|
17
|
+
export declare const MIGRATION_016_THE_OVERDUE_BILL_GETS_A_LETTER = "-- 016_the_overdue_bill_gets_a_letter.sql\n--\n-- Uma parcela vence e nada acontece. Ningu\u00E9m \u00E9 avisado, nada \u00E9 escalado, e a\n-- inadimpl\u00EAncia s\u00F3 aparece quando algu\u00E9m abre a lista e olha \u2014 o que \u00E9 o mesmo\n-- que dizer: quando j\u00E1 \u00E9 tarde.\n--\n-- `chain.json` diz, sobre o plugin de notifica\u00E7\u00F5es: \"a perna de cobran\u00E7a do\n-- financeiro (\u2026) \u00E9 este motor com outro template, ent\u00E3o ele tem de estar em\n-- todo cluster para esses fluxos existirem\". O motor est\u00E1 l\u00E1 desde ent\u00E3o e a\n-- perna nunca foi ligada.\n--\n-- DUAS PE\u00C7AS, e a primeira vale sozinha:\n--\n-- A IDADE DA D\u00CDVIDA. Um saldo em aberto n\u00E3o \u00E9 um n\u00FAmero, s\u00E3o faixas: o que\n-- vence amanh\u00E3, o que venceu ontem, o que venceu h\u00E1 noventa dias. A primeira\n-- se cobra com um lembrete, a \u00FAltima com uma decis\u00E3o. Uma lista sem faixas\n-- trata as tr\u00EAs igual, que \u00E9 como se perde a primeira e se insiste na\n-- terceira.\n--\n-- O AVISO, por faixa, com deduplica\u00E7\u00E3o. Cobrar a mesma pessoa pela mesma\n-- parcela todo dia \u00E9 como n\u00E3o cobrar: ela para de ler. A chave de dedupe \u00E9\n-- parcela + faixa, ent\u00E3o cada degrau avisa UMA vez, e o pr\u00F3ximo degrau \u00E9 um\n-- evento novo porque a d\u00EDvida mudou de idade.\n--\n-- E COBRAR N\u00C3O PODE DERRUBAR NADA: se o motor de notifica\u00E7\u00F5es n\u00E3o estiver\n-- instalado, a idade continua sendo calculada e o aviso simplesmente n\u00E3o sai.\n-- A resolu\u00E7\u00E3o \u00E9 em tempo de execu\u00E7\u00E3o, como no CMV (015) \u2014 a corrente aplica o\n-- financeiro antes do resto, e um financeiro que exige o mundo inteiro para\n-- existir n\u00E3o serve a quem vende s\u00F3 servi\u00E7o.\n\n-- \u2500\u2500 a idade da d\u00EDvida \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_aging\nWITH (security_invoker = 'true') AS\nSELECT b.tenant_id,\n b.unit_id,\n b.installment_id,\n b.invoice_id,\n b.invoice_number,\n b.direction,\n b.counterparty_person_id,\n p.name AS counterparty_name,\n b.due_date,\n b.amount,\n b.paid,\n b.balance,\n (current_date - b.due_date)::int AS days_overdue,\n -- As faixas que um cobrador usa, e n\u00E3o intervalos redondos por est\u00E9tica:\n -- `current` ainda n\u00E3o venceu; `d1_15` \u00E9 esquecimento; `d16_30` \u00E9 aperto;\n -- `d31_60` j\u00E1 pede conversa; acima disso \u00E9 decis\u00E3o.\n CASE\n WHEN b.due_date >= current_date THEN 'current'\n WHEN current_date - b.due_date <= 15 THEN 'd1_15'\n WHEN current_date - b.due_date <= 30 THEN 'd16_30'\n WHEN current_date - b.due_date <= 60 THEN 'd31_60'\n WHEN current_date - b.due_date <= 90 THEN 'd61_90'\n ELSE 'd90_plus'\n END AS bucket\n FROM public.v_financial_installment_balances b\n LEFT JOIN public.people p ON p.id = b.counterparty_person_id\n WHERE b.status <> 'cancelled'\n AND b.balance > 0;\n\nCOMMENT ON VIEW public.v_financial_aging IS\n 'O saldo em aberto por IDADE, n\u00E3o por total: o que vence amanh\u00E3 se cobra com um lembrete e o que venceu h\u00E1 noventa dias com uma decis\u00E3o (016).';\n\nGRANT SELECT ON public.v_financial_aging TO authenticated;\nGRANT SELECT ON public.v_financial_aging TO service_role;\n\n-- \u2500\u2500 o aviso \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_dunning_run(p_bucket text DEFAULT NULL)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_row record;\n v_sent int := 0;\n v_skip int := 0;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_dunning_run: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.read', NULL) THEN\n RAISE EXCEPTION 'financial_dunning_run: exige financial.read' USING ERRCODE = '42501';\n END IF;\n\n -- Sem motor instalado a idade continua valendo; o aviso \u00E9 que n\u00E3o sai.\n IF to_regprocedure('public.notifications_enqueue(uuid, text, uuid, jsonb, text, timestamptz, text, text, uuid, uuid)') IS NULL THEN\n RETURN jsonb_build_object('sent', 0, 'skipped', 0,\n 'note', 'notifications engine not installed - aging still available');\n END IF;\n\n FOR v_row IN\n SELECT * FROM public.v_financial_aging a\n WHERE a.tenant_id = v_tenant\n -- S\u00F3 o que a CASA tem a receber. Uma conta a pagar vencida \u00E9 problema\n -- da casa com o fornecedor, e mandar cobran\u00E7a ao fornecedor por ela \u00E9\n -- o oposto do que se quer.\n AND a.direction = 'receivable'\n AND a.bucket <> 'current'\n AND (p_bucket IS NULL OR a.bucket = p_bucket)\n AND a.counterparty_person_id IS NOT NULL\n LOOP\n BEGIN\n -- parcela + faixa: cada degrau avisa UMA vez. Cobrar todo dia \u00E9 como n\u00E3o\n -- cobrar \u2014 a pessoa para de ler.\n EXECUTE format(\n 'SELECT public.notifications_enqueue(%L::uuid, %L, %L::uuid, %L::jsonb, %L)',\n v_tenant,\n 'financial.dunning.' || v_row.bucket,\n v_row.counterparty_person_id,\n jsonb_build_object(\n 'invoice_number', v_row.invoice_number,\n 'due_date', v_row.due_date,\n 'amount', v_row.balance,\n 'days_overdue', v_row.days_overdue,\n 'bucket', v_row.bucket),\n 'dunning:' || v_row.installment_id::text || ':' || v_row.bucket);\n v_sent := v_sent + 1;\n EXCEPTION WHEN OTHERS THEN\n -- Um template ausente ou um canal bloqueado n\u00E3o derruba a r\u00E9gua inteira:\n -- avisa quem d\u00E1, conta quem n\u00E3o deu.\n v_skip := v_skip + 1;\n END;\n END LOOP;\n\n RETURN jsonb_build_object('sent', v_sent, 'skipped', v_skip, 'bucket', p_bucket);\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_dunning_run(text) IS\n 'Enfileira o aviso de cobran\u00E7a por faixa de atraso, deduplicado por parcela + faixa. Sem o motor de notifica\u00E7\u00F5es a idade continua valendo e o aviso n\u00E3o sai (016).';\n\nREVOKE ALL ON FUNCTION public.financial_dunning_run(text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.financial_dunning_run(text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_dunning_run(text) TO service_role;\n\n-- \u2500\u2500 e o resumo que a tela mostra \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_aging_summary\nWITH (security_invoker = 'true') AS\nSELECT tenant_id, unit_id, direction, bucket,\n count(*)::int AS installments,\n sum(balance)::numeric(14,2) AS balance\n FROM public.v_financial_aging\n GROUP BY tenant_id, unit_id, direction, bucket;\n\nCOMMENT ON VIEW public.v_financial_aging_summary IS\n 'Quanto h\u00E1 em cada faixa de atraso \u2014 a foto que abre a conversa sobre inadimpl\u00EAncia (016).';\n\nGRANT SELECT ON public.v_financial_aging_summary TO authenticated;\nGRANT SELECT ON public.v_financial_aging_summary TO service_role;\n";
|
|
18
|
+
export declare const MIGRATION_017_THE_RECEIPT_IS_A_QUERY_NOT_A_LAYOUT = "-- 017_the_receipt_is_a_query_not_a_layout.sql\n--\n-- Imprimir fatura e recibo \u00E9 pedido de tela, e a tela \u00E9 a parte f\u00E1cil. A parte\n-- que erra \u00E9 a REUNI\u00C3O DOS DADOS: quem emitiu (com CNPJ e endere\u00E7o), quem\n-- pagou, o que foi comprado, quanto de imposto, o que j\u00E1 foi pago e quanto\n-- falta. Hoje isso \u00E9 sete consultas espalhadas, e cada tela que imprime junta\n-- de um jeito \u2014 tr\u00EAs telas, tr\u00EAs recibos diferentes da mesma venda.\n--\n-- Ent\u00E3o o recibo \u00E9 uma CONSULTA, e o layout fica com quem desenha. HTML,\n-- cupom de 58mm, PDF, e-mail: todos leem a mesma linha e nenhum inventa um\n-- total pr\u00F3prio.\n--\n-- POR QUE JSON PARA AS LINHAS E OS PAGAMENTOS. Um recibo \u00E9 UM documento com N\n-- itens; devolv\u00EA-lo como N linhas obrigaria toda tela a reagrupar, e \u00E9 no\n-- reagrupamento que os totais divergem. Agregado uma vez, aqui, onde h\u00E1 um\n-- lugar s\u00F3 para consertar.\n--\n-- O RECIBO N\u00C3O \u00C9 DOCUMENTO FISCAL. \u00C9 o comprovante que o cliente leva; a nota\n-- \u00E9 `plg_financial_nfe`, com numera\u00E7\u00E3o, s\u00E9rie e emiss\u00E3o pr\u00F3prias. Confundir os\n-- dois \u00E9 como imprimir um cupom e achar que declarou.\n\nCREATE OR REPLACE VIEW public.v_financial_receipt\nWITH (security_invoker = 'true') AS\nSELECT f.id AS invoice_id,\n f.tenant_id,\n f.unit_id,\n f.number AS invoice_number,\n f.direction,\n f.issue_date,\n f.competence_date,\n f.total,\n f.description,\n f.order_id,\n o.reference_number AS order_number,\n\n -- Quem emitiu: sem CNPJ um recibo \u00E9 um papel, e o cliente que pede \"com\n -- CNPJ\" est\u00E1 pedindo exatamente esta linha.\n e.id AS issuer_id,\n e.legal_name AS issuer_legal_name,\n e.trade_name AS issuer_trade_name,\n e.document_number AS issuer_document,\n e.tax_regime AS issuer_tax_regime,\n e.city AS issuer_city,\n e.state AS issuer_state,\n\n -- Quem pagou.\n p.id AS counterparty_id,\n p.name AS counterparty_name,\n p.email AS counterparty_email,\n p.phone AS counterparty_phone,\n\n -- O que foi comprado. Da linha da FATURA quando ela existe (\u00E9 o que foi\n -- cobrado), caindo para a do pedido quando n\u00E3o \u2014 uma fatura antiga pode\n -- n\u00E3o ter linhas pr\u00F3prias, e um recibo sem itens \u00E9 pior que um recibo\n -- com os itens da venda.\n coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'name', i.name, 'quantity', i.quantity,\n 'unit_price', i.unit_price, 'discount', i.discount, 'total', i.total)\n ORDER BY i.sort_order)\n FROM public.items i WHERE i.invoice_id = f.id\n ), (\n SELECT jsonb_agg(jsonb_build_object(\n 'name', i.name, 'quantity', i.quantity,\n 'unit_price', i.unit_price, 'discount', i.discount, 'total', i.total)\n ORDER BY i.sort_order)\n FROM public.items i WHERE i.order_id = f.order_id\n ), '[]'::jsonb) AS lines,\n\n -- Os tributos, por tributo. Vazio at\u00E9 algu\u00E9m apurar, e vazio \u00E9 honesto:\n -- imprimir \"impostos: R$ 0,00\" quando ningu\u00E9m calculou \u00E9 pior.\n coalesce((\n SELECT jsonb_agg(jsonb_build_object('tax', t.tax, 'base', t.base_amount,\n 'rate', t.rate, 'amount', t.amount)\n ORDER BY t.tax)\n FROM public.plg_financial_invoice_item_taxes t\n JOIN public.items i ON i.id = t.item_id\n WHERE i.invoice_id = f.id\n ), '[]'::jsonb) AS taxes,\n\n -- As parcelas, com o que cada uma j\u00E1 recebeu.\n coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'number', b.number, 'due_date', b.due_date,\n 'amount', b.amount, 'paid', b.paid, 'status', b.status)\n ORDER BY b.number)\n FROM public.v_financial_installment_balances b\n WHERE b.invoice_id = f.id\n ), '[]'::jsonb) AS installments,\n\n -- E como o dinheiro entrou, que \u00E9 o que faz um recibo ser recibo.\n coalesce((\n SELECT jsonb_agg(jsonb_build_object(\n 'date', m.payment_date, 'amount', m.paid_amount,\n 'method', m.method, 'card_brand', m.card_brand,\n 'installments', m.card_installments)\n ORDER BY m.payment_date)\n FROM public.plg_financial_movements m\n WHERE m.financial_invoice_id = f.id\n AND m.movement_kind = 'payment'\n AND m.status = 'paid'\n AND m.reversed_by_id IS NULL\n ), '[]'::jsonb) AS payments,\n\n (SELECT coalesce(sum(b.paid), 0) FROM public.v_financial_installment_balances b\n WHERE b.invoice_id = f.id)::numeric(14,2) AS total_paid,\n (SELECT coalesce(sum(b.balance), 0) FROM public.v_financial_installment_balances b\n WHERE b.invoice_id = f.id)::numeric(14,2) AS total_due\n\n FROM public.plg_financial_invoices f\n LEFT JOIN public.orders o ON o.id = f.order_id\n LEFT JOIN public.company_legal_entities e ON e.id = f.company_legal_entity_id\n LEFT JOIN public.people p ON p.id = f.counterparty_person_id\n WHERE f.cancelled_at IS NULL;\n\nCOMMENT ON VIEW public.v_financial_receipt IS\n 'Tudo o que um recibo precisa, numa linha: emitente com CNPJ, cliente, itens, tributos, parcelas e pagamentos. O layout fica com quem desenha; o total \u00E9 calculado UMA vez (017).';\n\nGRANT SELECT ON public.v_financial_receipt TO authenticated;\nGRANT SELECT ON public.v_financial_receipt TO service_role;\n";
|
|
19
|
+
export declare const MIGRATION_018_THE_CAIXA_SHOWS_WHAT_IT_SHOULD_HAVE = "-- 018_the_caixa_shows_what_it_should_have.sql\n--\n-- A tela de fechamento mostrava uma diferen\u00E7a INVENTADA:\n--\n-- difference = contado \u2212 saldo_de_abertura\n--\n-- Um caixa que abriu com 100, vendeu 500 em dinheiro e tem 600 na gaveta est\u00E1\n-- CERTO, e a tela anunciava \"sobra de 500\". A conta de quebra \u00E9 contra o\n-- ESPERADO \u2014 abertura mais o que entrou menos o que saiu \u2014, e o esperado a tela\n-- n\u00E3o tinha como saber: `expected_cash` s\u00F3 \u00E9 gravado no fechamento, pela RPC.\n--\n-- Ou seja: o operador conferia contra um n\u00FAmero, confirmava, e o sistema\n-- gravava outro. A tela mentia justamente no momento em que algu\u00E9m assume\n-- responsabilidade por dinheiro contado.\n--\n-- Esta migration n\u00E3o muda o fechamento \u2014 a RPC j\u00E1 apura certo, contra\n-- `v_financial_account_balances`, e lan\u00E7a a quebra em conta de falta/sobra.\n-- Ela d\u00E1 \u00E0 TELA o mesmo n\u00FAmero, ANTES de fechar.\n--\n-- E POR FORMA DE PAGAMENTO, n\u00E3o s\u00F3 dinheiro e cart\u00E3o. O fechamento resume em\n-- dois baldes e o Pix n\u00E3o est\u00E1 em nenhum: quem recebe Pix pelo caixa fecha o\n-- turno sem ver o que recebeu. Aqui a quebra por forma \u00E9 uma linha por forma,\n-- ent\u00E3o acrescentar uma n\u00E3o exige mexer em balde nenhum.\nCREATE OR REPLACE VIEW public.v_financial_cash_session_expected\nWITH (security_invoker = 'true') AS\nSELECT s.id AS session_id,\n s.tenant_id,\n s.unit_id,\n s.account_id,\n s.bank_account_id,\n s.status,\n s.opened_at,\n s.user_id,\n s.opened_by_name,\n s.opening_balance,\n\n -- O esperado em dinheiro \u00E9 o SALDO DA CONTA do caixa, que \u00E9 exatamente o\n -- que a RPC de fechamento l\u00EA. Um segundo jeito de calcular seria um\n -- segundo n\u00FAmero para divergir.\n round(coalesce((SELECT b.balance FROM public.v_financial_account_balances b\n WHERE b.account_id = s.account_id), 0), 2) AS expected_cash,\n\n -- E o que passou pelo caixa, por forma. `payment` liquidado e n\u00E3o\n -- estornado: \u00E9 o que o turno de fato recebeu.\n coalesce((\n SELECT jsonb_object_agg(x.method, x.total)\n FROM (SELECT m.method, round(sum(m.paid_amount), 2) AS total\n FROM public.plg_financial_movements m\n WHERE m.cash_session_id = s.id\n AND m.movement_kind = 'payment'\n AND m.status = 'paid'\n AND m.reversed_by_id IS NULL\n GROUP BY m.method) x\n ), '{}'::jsonb) AS received_by_method,\n\n (SELECT count(*) FROM public.plg_financial_movements m\n WHERE m.cash_session_id = s.id\n AND m.movement_kind = 'payment'\n AND m.status = 'paid'\n AND m.reversed_by_id IS NULL)::int AS payment_count\n FROM public.plg_financial_cash_register_sessions s;\n\nCOMMENT ON VIEW public.v_financial_cash_session_expected IS\n 'O que o caixa DEVERIA ter, do mesmo lugar que a RPC de fechamento l\u00EA, e o recebido por forma de pagamento. Existe para a tela conferir contra o n\u00FAmero real em vez de contra o saldo de abertura (018).';\n\nGRANT SELECT ON public.v_financial_cash_session_expected TO authenticated;\nGRANT SELECT ON public.v_financial_cash_session_expected TO service_role;\n";
|
|
20
|
+
export declare const MIGRATION_019_WHO_DID_IT_IS_ALREADY_KNOWN = "-- 019_who_did_it_is_already_known.sql\n--\n-- `plg_financial_item_participants` (008) diz QUEM executou cada linha, e a\n-- apura\u00E7\u00E3o de comiss\u00E3o (009) l\u00EA exatamente essa tabela. Fora as fixtures de\n-- teste, NADA NUNCA ESCREVEU NELA.\n--\n-- O efeito \u00E9 que o motor de comiss\u00E3o n\u00E3o podia apurar nada em opera\u00E7\u00E3o real: a\n-- tela de Comiss\u00F5es diz, com honestidade, \"sem participante na linha n\u00E3o h\u00E1 a\n-- quem pagar\" \u2014 e n\u00E3o havia caminho nenhum para que existisse participante. Seis\n-- tabelas, uma RPC e duas vistas ficavam inalcan\u00E7\u00E1veis por falta de um elo.\n--\n-- E O ELO J\u00C1 EXISTIA. O agendamento nomeia o profissional (`assignee_id`), e as\n-- linhas da venda saem desse mesmo agendamento. Quem executou o servi\u00E7o n\u00E3o \u00E9\n-- uma informa\u00E7\u00E3o nova a ser digitada \u2014 \u00E9 uma que a casa j\u00E1 tem no momento em que\n-- marca o hor\u00E1rio.\n--\n-- Ent\u00E3o o participante \u00E9 DERIVADO, n\u00E3o cadastrado:\n--\n-- uma linha de pedido cuja venda veio de um agendamento recebe o profissional\n-- daquele agendamento como executor, com fra\u00E7\u00E3o 1.\n--\n-- POR QUE `ON CONFLICT DO NOTHING`, E N\u00C3O UPDATE. A fra\u00E7\u00E3o \u00E9 edit\u00E1vel: um\n-- atendimento a quatro m\u00E3os divide 0,6/0,4, e isso \u00E9 decis\u00E3o de quem fecha a\n-- comanda. Uma vez que algu\u00E9m tocou no rateio, o gatilho n\u00E3o pode desfazer \u2014\n-- ele semeia o caso comum e sai do caminho.\n--\n-- DOIS SENTIDOS, porque a ordem varia: a linha pode nascer depois do\n-- agendamento (o de sempre) ou o profissional ser trocado depois de a linha\n-- existir (remarca\u00E7\u00E3o). Um gatilho s\u00F3 cobriria metade.\n\n-- \u2500\u2500 a linha nasce: herda o profissional do agendamento \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_financial_seed_item_participant()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE v_person uuid; v_unit uuid;\nBEGIN\n IF NEW.order_id IS NULL THEN RETURN NEW; END IF;\n\n SELECT a.assignee_id, a.unit_id INTO v_person, v_unit\n FROM public.appointments a\n WHERE a.order_id = NEW.order_id\n AND a.assignee_id IS NOT NULL\n ORDER BY a.created_at\n LIMIT 1;\n\n IF v_person IS NULL THEN RETURN NEW; END IF;\n\n INSERT INTO public.plg_financial_item_participants\n (tenant_id, unit_id, item_id, person_id, role, share)\n VALUES (NEW.tenant_id, coalesce(NEW.unit_id, v_unit), NEW.id, v_person, 'executor', 1)\n ON CONFLICT (item_id, person_id, role) DO NOTHING;\n\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS items_financial_seed_participant ON public.items;\nCREATE TRIGGER items_financial_seed_participant\n AFTER INSERT ON public.items\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_financial_seed_item_participant();\n\n-- \u2500\u2500 o profissional muda: as linhas daquele pedido acompanham \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.trg_financial_seed_participants_of_appointment()\n RETURNS trigger\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nBEGIN\n IF NEW.order_id IS NULL OR NEW.assignee_id IS NULL THEN RETURN NEW; END IF;\n\n INSERT INTO public.plg_financial_item_participants\n (tenant_id, unit_id, item_id, person_id, role, share)\n SELECT i.tenant_id, coalesce(i.unit_id, NEW.unit_id), i.id, NEW.assignee_id, 'executor', 1\n FROM public.items i\n WHERE i.order_id = NEW.order_id\n ON CONFLICT (item_id, person_id, role) DO NOTHING;\n\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS appointments_financial_seed_participants ON public.appointments;\nCREATE TRIGGER appointments_financial_seed_participants\n AFTER INSERT OR UPDATE OF assignee_id, order_id ON public.appointments\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_financial_seed_participants_of_appointment();\n\nCOMMENT ON FUNCTION public.trg_financial_seed_item_participant() IS\n 'Deriva o executor da linha do profissional do agendamento. Sem isto a apura\u00E7\u00E3o de comiss\u00E3o nunca tinha a quem pagar (019).';\n\n-- \u2500\u2500 e o que j\u00E1 foi vendido antes deste arquivo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nINSERT INTO public.plg_financial_item_participants\n (tenant_id, unit_id, item_id, person_id, role, share)\nSELECT i.tenant_id, coalesce(i.unit_id, a.unit_id), i.id, a.assignee_id, 'executor', 1\n FROM public.items i\n JOIN public.appointments a ON a.order_id = i.order_id AND a.assignee_id IS NOT NULL\n WHERE i.order_id IS NOT NULL\nON CONFLICT (item_id, person_id, role) DO NOTHING;\n";
|
|
21
|
+
export declare const MIGRATION_020_A_RULE_WITH_NOBODY_TO_PAY_PAYS_THE_EXECUTOR = "-- 020_a_rule_with_nobody_to_pay_pays_the_executor.sql\n--\n-- A semeadura da fun\u00E7\u00E3o default vive na APURA\u00C7\u00C3O (009): no AFTER INSERT da\n-- regra as fun\u00E7\u00F5es ainda n\u00E3o existem, e um gatilho de cadastro entrava por\n-- cima de quem estava prestes a declarar as suas \u2014 foi assim que a su\u00EDte 008,\n-- que cadastra regra E fun\u00E7\u00F5es, passou a apurar tr\u00EAs lan\u00E7amentos onde espera\n-- dois.\n--\n-- Aqui fica s\u00F3 o que a apura\u00E7\u00E3o n\u00E3o alcan\u00E7a sozinha: derrubar o gatilho que a\n-- primeira vers\u00E3o deste arquivo criou, e dar fun\u00E7\u00E3o \u00E0s regras que j\u00E1 foram\n-- cadastradas sem nenhuma.\nDROP TRIGGER IF EXISTS commission_rules_default_function ON public.plg_financial_commission_rules;\nDROP FUNCTION IF EXISTS public.trg_financial_default_commission_function();\n\nINSERT INTO public.plg_financial_commission_functions\n (tenant_id, rule_id, function_name, account_type, value_type, value_amount,\n calculation_base, display_order, is_active)\nSELECT r.tenant_id, r.id, coalesce(nullif(r.name, ''), 'Executor'),\n 'professional_executor', r.value_type, r.value, 'net', 0, true\n FROM public.plg_financial_commission_rules r\n WHERE NOT EXISTS (SELECT 1 FROM public.plg_financial_commission_functions f\n WHERE f.rule_id = r.id);\n";
|
|
22
|
+
export declare const MIGRATION_021_THE_MOST_SPECIFIC_RULE_WINS = "-- 021_the_most_specific_rule_wins.sql\n--\n-- O V1 escreve a regra de resolu\u00E7\u00E3o no subt\u00EDtulo da pr\u00F3pria tela:\n--\n-- \"A regra mais espec\u00EDfica (mais crit\u00E9rios casados) vence; a padr\u00E3o apanha o\n-- resto\"\n--\n-- e, no campo Prioridade, a legenda que completa a frase:\n--\n-- \"Desempate quando duas regras casam o mesmo n\u00FAmero de crit\u00E9rios (maior\n-- vence)\"\n--\n-- Ou seja: ESPECIFICIDADE primeiro, prioridade s\u00F3 como desempate.\n--\n-- A 009 faz `ORDER BY r.priority DESC` \u2014 a prioridade \u00C9 a ordena\u00E7\u00E3o. A\n-- diferen\u00E7a n\u00E3o \u00E9 acad\u00EAmica: com a padr\u00E3o em prioridade 99 (que \u00E9 o que algu\u00E9m\n-- faz quando acha que prioridade significa \"import\u00E2ncia\") e \"Ultrassom 15%\" em\n-- 10, TODO atendimento de ultrassom comissiona 10% pela padr\u00E3o, e a regra\n-- espec\u00EDfica que a casa escreveu nunca pega. Ningu\u00E9m descobre at\u00E9 o\n-- profissional reclamar do valor \u2014 e a\u00ED o dinheiro j\u00E1 saiu errado por um m\u00EAs.\n--\n-- Como TODOS os crit\u00E9rios de uma regra t\u00EAm de casar para ela se qualificar (\u00E9 o\n-- `NOT EXISTS` de um crit\u00E9rio que falha), a contagem de crit\u00E9rios da regra \u00C9 a\n-- contagem de crit\u00E9rios casados. A especificidade sai de um `count(*)`, sem\n-- reavaliar nada.\n--\n-- E a padr\u00E3o n\u00E3o precisa de desempate pr\u00F3prio: ela n\u00E3o tem crit\u00E9rio, ent\u00E3o\n-- pontua zero e cai naturalmente para o fim \u2014 que \u00E9 a defini\u00E7\u00E3o de \"apanha o\n-- resto\". O `is_default ASC` continua ali s\u00F3 para o caso de duas regras sem\n-- crit\u00E9rio nenhum.\n--\n-- \u2500\u2500 E `time_range` passa a restringir \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Dos nove crit\u00E9rios, quatro ca\u00EDam num `ELSE true`. Tr\u00EAs deles t\u00EAm motivo que\n-- continua valendo: `profession` e `professional_group` n\u00E3o t\u00EAm cadastro no V2\n-- para onde apontar, e `payment_method_type` s\u00F3 se conhece quando o pagamento\n-- acontece \u2014 e n\u00E3o quando a linha nasce, que \u00E9 quando esta fun\u00E7\u00E3o roda.\n--\n-- `time_range` n\u00E3o tinha motivo nenhum: a hora est\u00E1 em `happened_at`, do lado\n-- do dia da semana que j\u00E1 restringe. \"S\u00E1bado \u00E0 noite paga mais\" era metade\n-- verdade \u2014 o s\u00E1bado pegava, a noite n\u00E3o.\n--\n-- A hora \u00E9 lida como o dia da semana j\u00E1 \u00E9: no fuso da sess\u00E3o. Coer\u00EAncia com o\n-- crit\u00E9rio vizinho vale mais aqui do que exatid\u00E3o isolada; o dia em que o\n-- `weekday` ganhar fuso de tenant, os dois ganham juntos.\nCREATE OR REPLACE FUNCTION public.plg_financial_commission_rule_for_item(p_item_id uuid, p_person_id uuid)\n RETURNS uuid\n LANGUAGE plpgsql\n STABLE\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_item record;\n v_rule uuid;\nBEGIN\n SELECT i.id, i.tenant_id, i.unit_id, i.product_id, i.order_id, i.invoice_id,\n coalesce(o.created_at, i.created_at) AS happened_at,\n p.category_id\n INTO v_item\n FROM public.items i\n LEFT JOIN public.orders o ON o.id = coalesce(i.order_id,\n (SELECT f2.order_id FROM public.plg_financial_invoices f2 WHERE f2.id = i.invoice_id))\n LEFT JOIN public.products p ON p.id = i.product_id\n WHERE i.id = p_item_id;\n IF v_item.id IS NULL THEN RETURN NULL; END IF;\n\n SELECT r.id INTO v_rule\n FROM public.plg_financial_commission_rules r\n WHERE r.tenant_id = v_item.tenant_id\n AND r.is_active\n AND (r.unit_id IS NULL OR r.unit_id = v_item.unit_id)\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_financial_commission_rule_criteria c\n WHERE c.rule_id = r.id\n AND NOT CASE c.criteria_type\n WHEN 'service' THEN\n v_item.product_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'category' THEN\n v_item.category_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'professional' THEN\n p_person_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'unit' THEN\n v_item.unit_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'weekday' THEN\n (extract(dow FROM v_item.happened_at))::int IN (\n SELECT (jsonb_array_elements_text(coalesce(c.criteria_value -> 'days', '[]'::jsonb)))::int)\n -- Uma faixa que termina antes de come\u00E7ar atravessa a meia-noite\n -- (\"22:00 \u00E0s 02:00\"), e \u00E9 assim que se escreve o adicional\n -- noturno. Tratada como duas: at\u00E9 o fim do dia, e do come\u00E7o dele.\n WHEN 'time_range' THEN\n EXISTS (\n SELECT 1\n FROM jsonb_array_elements(coalesce(c.criteria_value -> 'ranges', '[]'::jsonb)) rg\n WHERE (rg ->> 'from') IS NOT NULL AND (rg ->> 'to') IS NOT NULL\n AND CASE\n WHEN (rg ->> 'from')::time <= (rg ->> 'to')::time THEN\n v_item.happened_at::time >= (rg ->> 'from')::time\n AND v_item.happened_at::time <= (rg ->> 'to')::time\n ELSE\n v_item.happened_at::time >= (rg ->> 'from')::time\n OR v_item.happened_at::time <= (rg ->> 'to')::time\n END)\n -- Sem cadastro no V2 (`profession`, `professional_group`) ou\n -- desconhecido no nascimento da linha (`payment_method_type`).\n -- Deixar passar comissiona a mais e algu\u00E9m v\u00EA; recusar em sil\u00EAncio\n -- n\u00E3o comissiona e ningu\u00E9m v\u00EA. A TELA diz quais s\u00E3o estes.\n ELSE true\n END)\n -- A especificidade primeiro, como no V1. Todos os crit\u00E9rios da regra casam\n -- (sen\u00E3o ela nem chega aqui), logo cont\u00E1-los \u00E9 contar os que casaram.\n ORDER BY (SELECT count(*) FROM public.plg_financial_commission_rule_criteria c2\n WHERE c2.rule_id = r.id) DESC,\n r.priority DESC,\n r.is_default ASC,\n r.created_at\n LIMIT 1;\n\n IF v_rule IS NOT NULL THEN RETURN v_rule; END IF;\n\n SELECT r.id INTO v_rule FROM public.plg_financial_commission_rules r\n WHERE r.tenant_id = v_item.tenant_id AND r.is_active AND r.is_default\n ORDER BY r.priority DESC LIMIT 1;\n RETURN v_rule;\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_financial_commission_rule_for_item(uuid, uuid) IS\n 'A regra MAIS ESPEC\u00CDFICA (mais crit\u00E9rios casados) cujos crit\u00E9rios todos casam; prioridade s\u00F3 desempata; sen\u00E3o a padr\u00E3o da casa. Crit\u00E9rio ausente n\u00E3o restringe (021).';\n";
|
|
23
|
+
export declare const MIGRATION_022_AN_UNKNOWN_ATTRIBUTE_DOES_NOT_MATCH = "-- 022_an_unknown_attribute_does_not_match.sql\n--\n-- Uma linha chamada \"Hidrata\u00E7\u00E3o\" estava comissionando pela regra \"Colora\u00E7\u00E3o\n-- 15%\". N\u00E3o \u00E9 erro de digita\u00E7\u00E3o: \u00E9 `NULL IN (...)`.\n--\n-- As linhas que nascem da agenda n\u00E3o t\u00EAm `product_id` \u2014 carregam s\u00F3 o nome do\n-- servi\u00E7o. Sem produto n\u00E3o h\u00E1 categoria, ent\u00E3o `v_item.category_id` \u00E9 NULL, e a\n-- compara\u00E7\u00E3o do crit\u00E9rio vira:\n--\n-- NULL::text IN ('91d80fff-\u2026') --> NULL (n\u00E3o FALSE)\n-- NOT NULL --> NULL\n--\n-- e um `WHERE \u2026 AND NULL` n\u00E3o devolve linha. O `NOT EXISTS` que procura um\n-- crit\u00E9rio REPROVADO n\u00E3o acha nenhum, e conclui que a regra passou em todos.\n--\n-- O efeito \u00E9 o pior poss\u00EDvel numa conta de comiss\u00E3o: **um atributo\n-- desconhecido faz TODO crit\u00E9rio passar**. A linha sem produto casa com toda\n-- regra de categoria e toda regra de servi\u00E7o da casa, e como a 021 ordena por\n-- especificidade, quem a captura \u00E9 justamente a regra mais espec\u00EDfica que\n-- existir \u2014 a que menos tem a ver com ela. Quanto melhor configurada a casa,\n-- mais errado o resultado.\n--\n-- O certo \u00E9 o oposto e \u00E9 o que qualquer pessoa diria em voz alta: se eu n\u00E3o sei\n-- a categoria da linha, ela N\u00C3O casa com uma regra que exige categoria. Ela cai\n-- para uma regra sem aquele crit\u00E9rio, ou para a padr\u00E3o \u2014 que \u00E9 a regra que\n-- existe exatamente para isso.\n--\n-- Um atributo desconhecido REPROVA o crit\u00E9rio que depende dele. Nada de\n-- `coalesce` para um valor sentinela: o que se quer dizer \u00E9 \"n\u00E3o sei\", e n\u00E3o \"\u00E9\n-- vazio\".\n--\n-- N\u00C3O CONFUNDIR COM CRIT\u00C9RIO AUSENTE. Uma regra que n\u00E3o declara crit\u00E9rio de\n-- categoria continua valendo para qualquer categoria \u2014 isso \u00E9 a regra sendo\n-- ampla, e continua igual. O que muda \u00E9 a LINHA que n\u00E3o sabe a sua categoria\n-- diante de uma regra que exige uma.\nCREATE OR REPLACE FUNCTION public.plg_financial_commission_rule_for_item(p_item_id uuid, p_person_id uuid)\n RETURNS uuid\n LANGUAGE plpgsql\n STABLE\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_item record;\n v_rule uuid;\nBEGIN\n SELECT i.id, i.tenant_id, i.unit_id, i.product_id, i.order_id, i.invoice_id,\n coalesce(o.created_at, i.created_at) AS happened_at,\n p.category_id\n INTO v_item\n FROM public.items i\n LEFT JOIN public.orders o ON o.id = coalesce(i.order_id,\n (SELECT f2.order_id FROM public.plg_financial_invoices f2 WHERE f2.id = i.invoice_id))\n LEFT JOIN public.products p ON p.id = i.product_id\n WHERE i.id = p_item_id;\n IF v_item.id IS NULL THEN RETURN NULL; END IF;\n\n SELECT r.id INTO v_rule\n FROM public.plg_financial_commission_rules r\n WHERE r.tenant_id = v_item.tenant_id\n AND r.is_active\n AND (r.unit_id IS NULL OR r.unit_id = v_item.unit_id)\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_financial_commission_rule_criteria c\n WHERE c.rule_id = r.id\n -- `coalesce(\u2026, false)`: o CASE pode devolver NULL quando o atributo da\n -- linha \u00E9 desconhecido, e `NOT NULL` n\u00E3o reprova nada. Aqui,\n -- desconhecido reprova.\n AND NOT coalesce(CASE c.criteria_type\n WHEN 'service' THEN\n v_item.product_id IS NOT NULL AND v_item.product_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'category' THEN\n v_item.category_id IS NOT NULL AND v_item.category_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'professional' THEN\n p_person_id IS NOT NULL AND p_person_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'unit' THEN\n v_item.unit_id IS NOT NULL AND v_item.unit_id::text IN (\n SELECT jsonb_array_elements_text(coalesce(c.criteria_value -> 'ids', '[]'::jsonb)))\n WHEN 'weekday' THEN\n v_item.happened_at IS NOT NULL\n AND (extract(dow FROM v_item.happened_at))::int IN (\n SELECT (jsonb_array_elements_text(coalesce(c.criteria_value -> 'days', '[]'::jsonb)))::int)\n -- Uma faixa que termina antes de come\u00E7ar atravessa a meia-noite\n -- (\"22:00 \u00E0s 02:00\"), e \u00E9 assim que se escreve o adicional noturno.\n WHEN 'time_range' THEN\n v_item.happened_at IS NOT NULL AND EXISTS (\n SELECT 1\n FROM jsonb_array_elements(coalesce(c.criteria_value -> 'ranges', '[]'::jsonb)) rg\n WHERE (rg ->> 'from') IS NOT NULL AND (rg ->> 'to') IS NOT NULL\n AND CASE\n WHEN (rg ->> 'from')::time <= (rg ->> 'to')::time THEN\n v_item.happened_at::time >= (rg ->> 'from')::time\n AND v_item.happened_at::time <= (rg ->> 'to')::time\n ELSE\n v_item.happened_at::time >= (rg ->> 'from')::time\n OR v_item.happened_at::time <= (rg ->> 'to')::time\n END)\n -- Sem cadastro no V2 (`profession`, `professional_group`) ou\n -- desconhecido no nascimento da linha (`payment_method_type`).\n ELSE true\n END, false))\n -- Especificidade primeiro, prioridade s\u00F3 desempata (021).\n ORDER BY (SELECT count(*) FROM public.plg_financial_commission_rule_criteria c2\n WHERE c2.rule_id = r.id) DESC,\n r.priority DESC,\n r.is_default ASC,\n r.created_at\n LIMIT 1;\n\n IF v_rule IS NOT NULL THEN RETURN v_rule; END IF;\n\n SELECT r.id INTO v_rule FROM public.plg_financial_commission_rules r\n WHERE r.tenant_id = v_item.tenant_id AND r.is_active AND r.is_default\n ORDER BY r.priority DESC LIMIT 1;\n RETURN v_rule;\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_financial_commission_rule_for_item(uuid, uuid) IS\n 'A regra MAIS ESPEC\u00CDFICA cujos crit\u00E9rios todos casam; prioridade desempata; sen\u00E3o a padr\u00E3o. Atributo desconhecido na linha REPROVA o crit\u00E9rio que depende dele \u2014 uma linha sem produto n\u00E3o casa com regra de categoria (022).';\n";
|
|
24
|
+
export declare const MIGRATION_023_REAPURAR_NAO_CRIA_UM_LOTE_ORFAO = "-- 023_reapurar_nao_cria_um_lote_orfao.sql\n--\n-- Apurar o mesmo per\u00EDodo duas vezes criava DOIS lotes, e o segundo nascia\n-- mentindo: cabe\u00E7alho dizendo \"5 lan\u00E7amentos \u00B7 R$ 237,00\" com ZERO lan\u00E7amentos\n-- apontando para ele.\n--\n-- A apura\u00E7\u00E3o \u00E9 idempotente por linha + pessoa + fun\u00E7\u00E3o (009) \u2014 reapurar n\u00E3o\n-- duplica lan\u00E7amento, e isso est\u00E1 certo. Mas o LOTE era criado antes, sempre\n-- novo, e os lan\u00E7amentos que j\u00E1 existiam continuavam presos ao lote da primeira\n-- vez. O contador do lote conta o que a rodada ENCONTROU; o v\u00EDnculo, s\u00F3 o que\n-- ela CRIOU. As duas contas divergem j\u00E1 na segunda execu\u00E7\u00E3o.\n--\n-- E o estrago n\u00E3o \u00E9 cosm\u00E9tico, porque os dois passos seguintes trabalham por\n-- `generation_id`:\n--\n-- `financial_commission_approve` aprova os lan\u00E7amentos DAQUELE lote \u2014 zero\n-- deles \u2014 mas marca o lote como `approved` assim mesmo. A tela avan\u00E7a.\n-- `financial_commission_pay_run` recolhe o que est\u00E1 `approved` naquele lote \u2014\n-- nada \u2014 e devolve \"0 contas a pagar \u00B7 R$ 0,00\", sem erro nenhum.\n--\n-- Ou seja: quem apurasse duas vezes (o gesto mais natural do mundo, porque a\n-- primeira vez veio antes de a \u00FAltima venda fechar) n\u00E3o conseguia mais pagar\n-- comiss\u00E3o nenhuma, e a tela n\u00E3o dizia por qu\u00EA. S\u00F3 o sil\u00EAncio de um border\u00F4\n-- vazio.\n--\n-- O CONSERTO: um per\u00EDodo aberto tem UM lote. Se j\u00E1 existe um lote `draft` para\n-- o mesmo tenant, unidade e per\u00EDodo, a apura\u00E7\u00E3o REUSA \u2014 e adota os lan\u00E7amentos\n-- pendentes daquele per\u00EDodo que ficaram em lotes `draft` anteriores, que \u00E9 o\n-- que o operador entende por \"reapurar\".\n--\n-- O que N\u00C3O se mexe: lote aprovado ou pago \u00E9 hist\u00F3ria, e hist\u00F3ria n\u00E3o se\n-- reescreve. Reapurar depois de aprovar abre um lote novo de verdade, para o\n-- que sobrou.\nCREATE OR REPLACE FUNCTION public.plg_financial_commission_open_generation(\n p_tenant uuid, p_unit_id uuid, p_period_start date, p_period_end date\n) RETURNS uuid\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE v_gen uuid;\nBEGIN\n -- `IS NOT DISTINCT FROM`: a unidade pode ser NULL (a casa toda), e `= NULL`\n -- nunca casa \u2014 era assim que um segundo lote nascia mesmo com esta busca.\n SELECT id INTO v_gen\n FROM public.plg_financial_commission_generations\n WHERE tenant_id = p_tenant\n AND unit_id IS NOT DISTINCT FROM p_unit_id\n AND period_start = p_period_start\n AND period_end = p_period_end\n AND status = 'draft'\n ORDER BY created_at\n LIMIT 1;\n\n IF v_gen IS NULL THEN\n INSERT INTO public.plg_financial_commission_generations\n (tenant_id, unit_id, period_start, period_end, status, generated_by_user_id)\n VALUES (p_tenant, p_unit_id, p_period_start, p_period_end, 'draft', auth.uid())\n RETURNING id INTO v_gen;\n END IF;\n\n -- Adota o que ficou para tr\u00E1s: lan\u00E7amento ainda pendente, de um lote que\n -- tamb\u00E9m era rascunho do mesmo per\u00EDodo. Sem isto o lote reusado continuaria\n -- contando lan\u00E7amentos que n\u00E3o s\u00E3o dele.\n UPDATE public.plg_financial_commission_entries e\n SET generation_id = v_gen, updated_at = now()\n FROM public.plg_financial_commission_generations g\n WHERE e.generation_id = g.id\n AND e.tenant_id = p_tenant\n AND e.status = 'pending'\n AND e.generation_id <> v_gen\n AND g.status = 'draft'\n AND g.period_start = p_period_start\n AND g.period_end = p_period_end\n AND g.unit_id IS NOT DISTINCT FROM p_unit_id;\n\n -- E os rascunhos que ficaram vazios n\u00E3o s\u00E3o hist\u00F3ria de nada: some com eles,\n -- sen\u00E3o a lista de lotes vira uma pilha de cabe\u00E7alhos zerados.\n DELETE FROM public.plg_financial_commission_generations g\n WHERE g.tenant_id = p_tenant\n AND g.status = 'draft'\n AND g.id <> v_gen\n AND g.period_start = p_period_start\n AND g.period_end = p_period_end\n AND g.unit_id IS NOT DISTINCT FROM p_unit_id\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_commission_entries e\n WHERE e.generation_id = g.id);\n\n RETURN v_gen;\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_financial_commission_open_generation(uuid, uuid, date, date) IS\n 'O lote ABERTO do per\u00EDodo: reusa o rascunho que j\u00E1 existe, adota os lan\u00E7amentos pendentes que ficaram em rascunhos irm\u00E3os e recolhe os vazios. Um per\u00EDodo aberto tem um lote (023).';\n\nREVOKE ALL ON FUNCTION public.plg_financial_commission_open_generation(uuid, uuid, date, date) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.plg_financial_commission_open_generation(uuid, uuid, date, date) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_financial_commission_open_generation(uuid, uuid, date, date) TO service_role;\n\n-- \u2500\u2500 e a apura\u00E7\u00E3o passa a pedir o lote em vez de criar um \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nDO $$\nDECLARE v_src text;\nBEGIN\n SELECT pg_get_functiondef(p.oid) INTO v_src\n FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public' AND p.proname = 'financial_commission_generate'\n LIMIT 1;\n\n IF v_src IS NULL THEN\n RAISE EXCEPTION '023: financial_commission_generate n\u00E3o existe \u2014 a 009 tem de vir antes';\n END IF;\n\n -- Troca cir\u00FArgica do INSERT do lote pela chamada que o resolve. Reescrever a\n -- fun\u00E7\u00E3o inteira aqui a congelaria: qualquer corre\u00E7\u00E3o futura na 009 seria\n -- desfeita por este arquivo ao ser replicado.\n v_src := replace(v_src,\n 'INSERT INTO public.plg_financial_commission_generations\n (tenant_id, unit_id, period_start, period_end, status, generated_by_user_id)\n VALUES (v_tenant, p_unit_id, p_period_start, p_period_end, ''draft'', auth.uid())\n RETURNING id INTO v_gen;',\n 'v_gen := public.plg_financial_commission_open_generation(\n v_tenant, p_unit_id, p_period_start, p_period_end);');\n\n IF position('plg_financial_commission_open_generation' in v_src) = 0 THEN\n RAISE EXCEPTION '023: n\u00E3o encontrei o INSERT do lote em financial_commission_generate \u2014 a 009 mudou de forma';\n END IF;\n\n EXECUTE v_src;\nEND $$;\n";
|
|
25
|
+
export declare const MIGRATION_024_O_BORDERO_PAGA_O_QUE_ELE_SOMA = "-- 024_o_bordero_paga_o_que_ele_soma.sql\n--\n-- O border\u00F4 dizia \"4 contas \u00B7 R$ 237,00\" e levantava 3 contas somando R$ 141,00.\n-- A conta de uma profissional saiu R$ 24,00 carregando dois lan\u00E7amentos de\n-- R$ 24,00 e R$ 96,00 \u2014 R$ 96,00 que ningu\u00E9m ia pagar, e que some do relat\u00F3rio\n-- porque o lan\u00E7amento CONSTA como faturado.\n--\n-- S\u00E3o tr\u00EAs lugares com gr\u00E3os diferentes dentro da mesma fun\u00E7\u00E3o (012):\n--\n-- o la\u00E7o agrupa por (pessoa, unidade)\n-- a chave de idempot\u00EAncia \u00E9 lote + pessoa\n-- o UPDATE que vincula filtra por lote + pessoa \u2190 sem unidade\n--\n-- Para quem atende numa unidade s\u00F3 os tr\u00EAs coincidem, e por isso os testes\n-- passavam. Para quem atende em duas:\n--\n-- 1\u00AA volta (pessoa, unidade A): soma 24, cria a conta com a chave\n-- `run:lote:pessoa`, e o UPDATE \u2014 que n\u00E3o filtra unidade \u2014 vincula os DOIS\n-- lan\u00E7amentos, o de A e o de B.\n-- 2\u00AA volta (pessoa, unidade B): soma 96, chama a cria\u00E7\u00E3o com A MESMA chave,\n-- recebe de volta a conta de R$ 24,00 sem alter\u00E1-la, e n\u00E3o acha mais nada\n-- para vincular. O contador soma assim mesmo.\n--\n-- Da\u00ED o relat\u00F3rio certo e o dinheiro errado: `v_total` acumula o que o la\u00E7o\n-- somou, e o banco guarda o que a primeira volta gravou.\n--\n-- O CONSERTO \u00E9 escolher UM gr\u00E3o e us\u00E1-lo nos tr\u00EAs lugares. Fica **pessoa +\n-- unidade**: a despesa \u00E9 da unidade que a gerou, \u00E9 ela que resolve o emitente\n-- da conta (007), e juntar unidades numa conta s\u00F3 esconderia de qual delas o\n-- custo saiu. Quem atende numa unidade s\u00F3 continua vendo uma conta por pessoa,\n-- que \u00E9 o que a 012 promete e o que os testes cobram.\n-- \u2500\u2500 e o lote ganha o estado que faltava \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `status` ia s\u00F3 at\u00E9 `approved`: um lote faturado ficava indistingu\u00EDvel de um\n-- lote apenas autorizado, e a tela n\u00E3o tinha como parar de oferecer \"gerar\n-- contas a pagar\" para quem j\u00E1 as tinha. O border\u00F4 \u00E9 uma transi\u00E7\u00E3o de verdade,\n-- ent\u00E3o ela existe.\nDO $$ BEGIN\n ALTER TABLE public.plg_financial_commission_generations\n DROP CONSTRAINT IF EXISTS plg_financial_commission_generations_status_check;\n ALTER TABLE public.plg_financial_commission_generations\n ADD CONSTRAINT plg_financial_commission_generations_status_check\n CHECK (status = ANY (ARRAY['draft', 'approved', 'paid', 'cancelled']));\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.financial_commission_pay_run(\n p_generation_id uuid,\n p_due_date date DEFAULT NULL\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_chart uuid;\n v_row record;\n v_payable uuid;\n v_bills int := 0;\n v_total numeric := 0;\n v_person text;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_commission_pay_run: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.commissions.manage', NULL) THEN\n RAISE EXCEPTION 'financial_commission_pay_run: exige financial.commissions.manage' USING ERRCODE = '42501';\n END IF;\n\n SELECT id INTO v_chart FROM public.plg_financial_chart_of_accounts\n WHERE tenant_id = v_tenant AND code = '4.02' LIMIT 1;\n\n FOR v_row IN\n SELECT e.person_id, e.unit_id, sum(e.amount) AS total, count(*) AS n\n FROM public.plg_financial_commission_entries e\n WHERE e.tenant_id = v_tenant\n AND e.generation_id = p_generation_id\n AND e.status = 'approved'\n AND e.payable_invoice_id IS NULL\n GROUP BY e.person_id, e.unit_id\n HAVING sum(e.amount) > 0\n LOOP\n SELECT name INTO v_person FROM public.people WHERE id = v_row.person_id;\n\n -- A chave carrega a UNIDADE, como o la\u00E7o. Sem ela a segunda unidade da\n -- mesma pessoa batia na conta da primeira e voltava sem alterar valor.\n -- `coalesce` porque unidade nula \u00E9 um grupo leg\u00EDtimo (casa sem filial) e\n -- concatenar NULL apagaria a chave inteira.\n v_payable := (public.financial_create_payable(\n 'Comiss\u00F5es \u00B7 ' || coalesce(v_person, 'profissional') || ' \u00B7 ' || v_row.n || ' lan\u00E7amento(s)',\n round(v_row.total, 2),\n coalesce(p_due_date, current_date),\n v_row.person_id,\n v_row.unit_id,\n v_chart,\n NULL,\n NULL,\n NULL,\n 'commission-run:' || p_generation_id::text || ':' || v_row.person_id::text\n || ':' || coalesce(v_row.unit_id::text, 'sem-unidade')\n ) ->> 'invoice_id')::uuid;\n\n -- E vincula s\u00F3 o que ESTA conta soma. Sem o filtro de unidade a primeira\n -- volta levava os lan\u00E7amentos das outras unidades junto, e eles ficavam\n -- pendurados numa conta que n\u00E3o os somou.\n UPDATE public.plg_financial_commission_entries\n SET payable_invoice_id = v_payable, updated_at = now()\n WHERE tenant_id = v_tenant AND generation_id = p_generation_id\n AND person_id = v_row.person_id\n AND unit_id IS NOT DISTINCT FROM v_row.unit_id\n AND status = 'approved'\n AND payable_invoice_id IS NULL;\n\n v_bills := v_bills + 1;\n v_total := v_total + v_row.total;\n END LOOP;\n\n UPDATE public.plg_financial_commission_generations\n SET status = 'paid', updated_at = now()\n WHERE tenant_id = v_tenant AND id = p_generation_id;\n\n RETURN jsonb_build_object('generation_id', p_generation_id, 'payables', v_bills,\n 'total', round(v_total, 2), 'due_date', coalesce(p_due_date, current_date));\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_commission_pay_run(uuid, date) IS\n 'Junta os lan\u00E7amentos aprovados por pessoa E unidade e levanta UMA conta a pagar para cada grupo \u2014 o mesmo gr\u00E3o no la\u00E7o, na chave de idempot\u00EAncia e no v\u00EDnculo, sen\u00E3o a conta soma menos do que carrega (024).';\n";
|
|
26
|
+
export declare const MIGRATION_025_A_META_DO_MES_TEM_TRES_DEGRAUS = "-- 025_a_meta_do_mes_tem_tres_degraus.sql\n--\n-- O V1 acompanha meta mensal de faturamento por unidade, e n\u00E3o tem UMA meta:\n-- tem tr\u00EAs degraus no mesmo m\u00EAs \u2014 **Meta**, **Super** e **Hiper**. \u00C9 premia\u00E7\u00E3o\n-- escalonada, e quem porta isso com um \u00FAnico campo \"meta\" perde o modelo\n-- inteiro: some a diferen\u00E7a entre bater o alvo e super\u00E1-lo duas vezes, que \u00E9\n-- justamente o que a equipe persegue.\n--\n-- Um degrau \u00E9 um VALOR ALVO, n\u00E3o um percentual: o percentual \u00E9 o realizado\n-- sobre ele, e se calcula na leitura.\n--\n-- POR QUE OS TR\u00CAS S\u00C3O COLUNAS, e n\u00E3o linhas numa tabela de degraus. S\u00E3o\n-- exatamente tr\u00EAs, fixos, e a casa fala deles pelo nome \u2014 \"bateu o Super\" \u00E9 uma\n-- frase que se diz. Uma tabela de degraus arbitr\u00E1rios daria flexibilidade que\n-- ningu\u00E9m pediu e obrigaria toda leitura a um pivot para desenhar a linha do\n-- m\u00EAs. Se um dia forem quatro, a\u00ED sim vira tabela.\n--\n-- E O REALIZADO N\u00C3O SE GUARDA. Ele \u00E9 o que o livro j\u00E1 sabe: faturamento\n-- reconhecido no m\u00EAs. Copi\u00E1-lo para c\u00E1 criaria um segundo lugar onde o\n-- faturamento mora, e dois lugares divergem no primeiro estorno. A vista l\u00EA as\n-- faturas, sempre atual.\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_goals (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n -- A meta \u00E9 SEMPRE de uma unidade: \"a meta da casa\" com tr\u00EAs filiais n\u00E3o\n -- quer dizer nada para quem trabalha numa delas.\n unit_id uuid NOT NULL,\n year integer NOT NULL,\n month integer NOT NULL,\n target_amount numeric(14,2) DEFAULT 0 NOT NULL,\n super_amount numeric(14,2) DEFAULT 0 NOT NULL,\n hyper_amount numeric(14,2) DEFAULT 0 NOT NULL,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nDO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.plg_financial_goals'::regclass AND contype = 'p') THEN\n ALTER TABLE ONLY public.plg_financial_goals ADD CONSTRAINT plg_financial_goals_pkey PRIMARY KEY (id);\n END IF;\n -- Um m\u00EAs tem UMA meta por unidade. Duas seriam duas respostas para \"bati?\".\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_goals_unit_month_key') THEN\n ALTER TABLE ONLY public.plg_financial_goals\n ADD CONSTRAINT plg_financial_goals_unit_month_key UNIQUE (tenant_id, unit_id, year, month);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_goals_month_check') THEN\n ALTER TABLE ONLY public.plg_financial_goals\n ADD CONSTRAINT plg_financial_goals_month_check CHECK (month BETWEEN 1 AND 12);\n END IF;\n -- Os degraus SOBEM. Um Super abaixo da Meta n\u00E3o \u00E9 um alvo mais dif\u00EDcil, \u00E9 um\n -- erro de digita\u00E7\u00E3o \u2014 e um que s\u00F3 apareceria no fim do m\u00EAs, na hora de pagar\n -- pr\u00EAmio.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_goals_ladder_check') THEN\n ALTER TABLE ONLY public.plg_financial_goals\n ADD CONSTRAINT plg_financial_goals_ladder_check\n CHECK (super_amount = 0 OR super_amount >= target_amount);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_goals_ladder2_check') THEN\n ALTER TABLE ONLY public.plg_financial_goals\n ADD CONSTRAINT plg_financial_goals_ladder2_check\n CHECK (hyper_amount = 0 OR hyper_amount >= super_amount);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_goals_tenant_fkey') THEN\n ALTER TABLE ONLY public.plg_financial_goals\n ADD CONSTRAINT plg_financial_goals_tenant_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_financial_goals_unit_fk') THEN\n ALTER TABLE ONLY public.plg_financial_goals\n ADD CONSTRAINT plg_financial_goals_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE CASCADE;\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS plg_financial_goals_tenant_year_idx\n ON public.plg_financial_goals USING btree (tenant_id, year, unit_id);\n\nDROP TRIGGER IF EXISTS plg_financial_goals_updated_at ON public.plg_financial_goals;\nCREATE TRIGGER plg_financial_goals_updated_at\n BEFORE UPDATE ON public.plg_financial_goals\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nALTER TABLE public.plg_financial_goals ENABLE ROW LEVEL SECURITY;\nALTER TABLE ONLY public.plg_financial_goals FORCE ROW LEVEL SECURITY;\n\nDROP POLICY IF EXISTS plg_financial_goals_authz_select ON public.plg_financial_goals;\nCREATE POLICY plg_financial_goals_authz_select ON public.plg_financial_goals\n FOR SELECT TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(unit_id))\n AND (SELECT app.has_permission('financial.read', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_goals_authz_insert ON public.plg_financial_goals;\nCREATE POLICY plg_financial_goals_authz_insert ON public.plg_financial_goals\n FOR INSERT TO authenticated WITH CHECK (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(unit_id))\n AND (SELECT app.has_permission('financial.create', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_goals_authz_update ON public.plg_financial_goals;\nCREATE POLICY plg_financial_goals_authz_update ON public.plg_financial_goals\n FOR UPDATE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(unit_id))\n AND (SELECT app.has_permission('financial.edit', unit_id)));\n\nDROP POLICY IF EXISTS plg_financial_goals_authz_delete ON public.plg_financial_goals;\nCREATE POLICY plg_financial_goals_authz_delete ON public.plg_financial_goals\n FOR DELETE TO authenticated USING (\n tenant_id = (SELECT app.current_tenant_id())\n AND (SELECT app.has_unit(unit_id))\n AND (SELECT app.has_permission('financial.delete', unit_id)));\n\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_goals TO authenticated;\nGRANT ALL ON public.plg_financial_goals TO service_role;\n\n-- Toda tabela `plg_financial_*` declara a sua authz no template, em modo\n-- `enforce` \u2014 a su\u00EDte 004 recusa a que ficar de fora, e recusou esta. O escopo \u00E9\n-- `unit` e n\u00E3o `tenant`: a meta \u00E9 sempre de UMA unidade, e quem enxerga uma\n-- filial n\u00E3o tem por que ver a meta da outra.\nINSERT INTO app.scaffold_registry\n (table_schema, table_name, resource_type, plugin, owner_scoping, mode, previous_policies, audit_hook, scope, distributable)\nVALUES\n ('public', 'plg_financial_goals', 'financial.goal', 'financial', false, 'enforce', ARRAY[]::text[], false, 'unit', false)\nON CONFLICT DO NOTHING;\n\nCOMMENT ON TABLE public.plg_financial_goals IS\n 'A meta mensal de faturamento de uma unidade, em tr\u00EAs degraus: Meta, Super e Hiper. O realizado N\u00C3O mora aqui \u2014 \u00E9 o que o livro j\u00E1 sabe (025).';\n\n-- \u2500\u2500 o ano inteiro, com o realizado ao lado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Doze linhas SEMPRE, m\u00EAs com meta ou sem. O V1 mostra o realizado mesmo onde\n-- ningu\u00E9m cadastrou alvo \u2014 foi assim que se viu Agosto com R$ 62.144,00\n-- realizados e os tr\u00EAs degraus zerados \u2014, e essa \u00E9 a informa\u00E7\u00E3o que faz algu\u00E9m\n-- cadastrar a meta que falta.\nCREATE OR REPLACE VIEW public.v_financial_goal_year\nWITH (security_invoker = 'true') AS\nWITH meses AS (\n SELECT g.tenant_id, g.unit_id, y.year, m.month\n FROM (SELECT DISTINCT tenant_id, unit_id FROM public.plg_financial_goals) g\n CROSS JOIN generate_series(1, 12) AS m(month)\n CROSS JOIN (SELECT DISTINCT year FROM public.plg_financial_goals) y\n UNION\n -- E as unidades que ainda n\u00E3o t\u00EAm meta nenhuma tamb\u00E9m aparecem: uma tela de\n -- metas que s\u00F3 mostra quem j\u00E1 cadastrou n\u00E3o ajuda quem nunca cadastrou.\n SELECT u.tenant_id, u.id, extract(year FROM current_date)::int, m.month\n FROM app.units u CROSS JOIN generate_series(1, 12) AS m(month)\n),\nrealizado AS (\n -- O faturamento reconhecido do m\u00EAs: fatura a receber, n\u00E3o cancelada, pela\n -- compet\u00EAncia. \u00C9 a mesma base do fechamento (006) \u2014 a meta tem de ser medida\n -- com a r\u00E9gua que o livro usa, sen\u00E3o o m\u00EAs \"bate\" aqui e n\u00E3o bate l\u00E1.\n SELECT f.tenant_id, f.unit_id,\n extract(year FROM coalesce(f.competence_date, f.issue_date))::int AS year,\n extract(month FROM coalesce(f.competence_date, f.issue_date))::int AS month,\n sum(f.total)::numeric(14,2) AS total\n FROM public.plg_financial_invoices f\n WHERE f.direction = 'receivable' AND f.cancelled_at IS NULL\n GROUP BY 1, 2, 3, 4\n)\nSELECT ms.tenant_id,\n ms.unit_id,\n ms.year,\n ms.month,\n g.id AS goal_id,\n coalesce(g.target_amount, 0)::numeric(14,2) AS target_amount,\n coalesce(g.super_amount, 0)::numeric(14,2) AS super_amount,\n coalesce(g.hyper_amount, 0)::numeric(14,2) AS hyper_amount,\n coalesce(r.total, 0)::numeric(14,2) AS achieved_amount,\n -- Percentual s\u00F3 onde h\u00E1 alvo. Dividir por zero devolveria NULL e a tela\n -- mostraria um tra\u00E7o onde o V1 mostra 0,00%.\n CASE WHEN coalesce(g.target_amount, 0) > 0\n THEN round(coalesce(r.total, 0) / g.target_amount * 100, 2) ELSE 0 END AS target_percent,\n CASE WHEN coalesce(g.super_amount, 0) > 0\n THEN round(coalesce(r.total, 0) / g.super_amount * 100, 2) ELSE 0 END AS super_percent,\n CASE WHEN coalesce(g.hyper_amount, 0) > 0\n THEN round(coalesce(r.total, 0) / g.hyper_amount * 100, 2) ELSE 0 END AS hyper_percent,\n g.notes\n FROM meses ms\n LEFT JOIN public.plg_financial_goals g\n ON g.tenant_id = ms.tenant_id AND g.unit_id = ms.unit_id\n AND g.year = ms.year AND g.month = ms.month\n LEFT JOIN realizado r\n ON r.tenant_id = ms.tenant_id AND r.unit_id = ms.unit_id\n AND r.year = ms.year AND r.month = ms.month;\n\nCOMMENT ON VIEW public.v_financial_goal_year IS\n 'Os doze meses de uma unidade num ano, com os tr\u00EAs degraus e o realizado do livro ao lado. M\u00EAs sem meta aparece do mesmo jeito, com o realizado \u2014 \u00E9 o que faz algu\u00E9m cadastrar a meta que falta (025).';\n\nGRANT SELECT ON public.v_financial_goal_year TO authenticated;\nGRANT SELECT ON public.v_financial_goal_year TO service_role;\n";
|
|
27
|
+
export declare const MIGRATION_026_QUEM_FECHA_O_CAIXA_NAO_E_QUEM_CONFERE = "-- 026_quem_fecha_o_caixa_nao_e_quem_confere.sql\n--\n-- No V1: \"Recep\u00E7\u00E3o abre e fecha; ger\u00EAncia e financeiro conferem. A aba\n-- Confer\u00EAncia \u00E9 de quem aprova \u2014 n\u00E3o de quem opera.\" Cada fechamento pendente\n-- traz duas a\u00E7\u00F5es, **Aprovar** e **Rejeitar**.\n--\n-- \u00C9 separa\u00E7\u00E3o de fun\u00E7\u00F5es sobre dinheiro contado \u00E0 m\u00E3o, que \u00E9 o \u00FAnico controle\n-- que sobra quando a confer\u00EAncia \u00E9 de c\u00E9dula: quem conta n\u00E3o pode ser quem\n-- assevera que a conta est\u00E1 certa. O V2 tinha a tabela\n-- `plg_financial_cash_session_reviewers` \u2014 com `verdict`, `reviewer_id`,\n-- `reviewed_at` e `notes` \u2014 e NENHUMA linha de c\u00F3digo a usando. Faltava o\n-- verbo.\n--\n-- (A tela \"Conferentes de Caixa\" do V1 n\u00E3o se porta: ela quebra o m\u00F3dulo\n-- inteiro por um import que falta, e est\u00E1 documentada como stub. O que se porta\n-- \u00E9 a CONFER\u00CANCIA, que \u00E9 a aba de Caixas \u2014 o conceito, n\u00E3o o defeito.)\n\nCREATE OR REPLACE FUNCTION public.financial_cash_session_review(\n p_session_id uuid,\n p_verdict text,\n p_notes text DEFAULT NULL\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_session record;\n v_review uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_review: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF p_verdict NOT IN ('approved', 'rejected') THEN\n RAISE EXCEPTION 'financial_cash_session_review: veredito inv\u00E1lido (%)', p_verdict USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_session\n FROM public.plg_financial_cash_register_sessions\n WHERE id = p_session_id AND tenant_id = v_tenant;\n\n IF v_session.id IS NULL THEN\n RAISE EXCEPTION 'financial_cash_session_review: sess\u00E3o n\u00E3o encontrada' USING ERRCODE = 'P0002';\n END IF;\n\n -- Conferir \u00E9 sobre o que j\u00E1 foi contado: uma sess\u00E3o ABERTA ainda vai receber\n -- dinheiro, e aprovar o que ainda se move n\u00E3o quer dizer nada.\n IF v_session.status <> 'closed' THEN\n RAISE EXCEPTION 'financial_cash_session_review: s\u00F3 sess\u00E3o fechada se confere (status atual: %)', v_session.status\n USING ERRCODE = '22023';\n END IF;\n\n -- `financial.manage` na unidade da sess\u00E3o, e n\u00E3o `financial.read`: conferir \u00E9\n -- assinar embaixo. Quem s\u00F3 enxerga o caixa n\u00E3o o aprova.\n IF NOT app.has_permission('financial.manage', v_session.unit_id) THEN\n RAISE EXCEPTION 'financial_cash_session_review: exige financial.manage na unidade' USING ERRCODE = '42501';\n END IF;\n\n -- QUEM FECHOU N\u00C3O CONFERE. \u00C9 a regra inteira desta migration: sem isto, a\n -- separa\u00E7\u00E3o de fun\u00E7\u00F5es vira um bot\u00E3o que o pr\u00F3prio operador aperta.\n IF v_session.closed_by_user_id IS NOT NULL AND v_session.closed_by_user_id = auth.uid() THEN\n RAISE EXCEPTION 'financial_cash_session_review: quem fechou o caixa n\u00E3o pode conferi-lo'\n USING ERRCODE = '42501';\n END IF;\n\n INSERT INTO public.plg_financial_cash_session_reviewers\n (tenant_id, unit_id, session_id, reviewer_id, verdict, notes, reviewed_at)\n VALUES (v_tenant, v_session.unit_id, p_session_id, auth.uid(), p_verdict, p_notes, now())\n RETURNING id INTO v_review;\n\n RETURN jsonb_build_object('review_id', v_review, 'session_id', p_session_id, 'verdict', p_verdict);\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_cash_session_review(uuid, text, text) IS\n 'Aprova ou rejeita o fechamento de um caixa. S\u00F3 sess\u00E3o fechada, s\u00F3 com financial.manage na unidade, e NUNCA por quem fechou \u2014 conferir \u00E9 assinar embaixo (026).';\n\nREVOKE ALL ON FUNCTION public.financial_cash_session_review(uuid, text, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.financial_cash_session_review(uuid, text, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_cash_session_review(uuid, text, text) TO service_role;\n\n-- \u2500\u2500 a fila de confer\u00EAncia \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- O que espera aprova\u00E7\u00E3o \u00E9 o que fechou e ainda n\u00E3o tem veredito. O V1 p\u00F5e um\n-- contador nesta aba (20, na verifica\u00E7\u00E3o) porque \u00E9 uma FILA: enquanto houver\n-- n\u00FAmero ali, algu\u00E9m tem trabalho a fazer.\nCREATE OR REPLACE VIEW public.v_financial_cash_session_review\nWITH (security_invoker = 'true') AS\nSELECT s.id AS session_id,\n s.tenant_id,\n s.unit_id,\n s.bank_account_id,\n s.opened_at,\n s.closed_at,\n s.opened_by_name,\n s.closed_by_name,\n s.closed_by_user_id,\n s.opening_balance,\n s.closing_balance,\n s.difference,\n r.id AS review_id,\n r.verdict,\n r.reviewed_at,\n r.notes AS review_notes,\n p.name AS reviewer_name,\n -- `pending` \u00E9 o estado que a fila mostra; os outros s\u00E3o hist\u00F3ria.\n coalesce(r.verdict, 'pending') AS review_status\n FROM public.plg_financial_cash_register_sessions s\n LEFT JOIN LATERAL (\n -- O \u00DALTIMO veredito vale. Rejeitar e depois aprovar \u00E9 um caminho real \u2014\n -- a gaveta foi recontada \u2014 e a fila n\u00E3o pode ficar presa no primeiro \"n\u00E3o\".\n SELECT r2.* FROM public.plg_financial_cash_session_reviewers r2\n WHERE r2.session_id = s.id\n ORDER BY r2.reviewed_at DESC NULLS LAST, r2.created_at DESC\n LIMIT 1\n ) r ON true\n LEFT JOIN public.people p ON p.id = r.reviewer_id\n WHERE s.status = 'closed';\n\nCOMMENT ON VIEW public.v_financial_cash_session_review IS\n 'Os fechamentos de caixa e o seu veredito. Sem veredito \u00E9 `pending` \u2014 a fila de quem confere (026).';\n\nGRANT SELECT ON public.v_financial_cash_session_review TO authenticated;\nGRANT SELECT ON public.v_financial_cash_session_review TO service_role;\n";
|
|
28
|
+
export declare const MIGRATION_027_O_VALE_PRESENTE_NASCE_EM_LOTE_E_E_UM_PASSIVO = "-- 027_o_vale_presente_nasce_em_lote_e_e_um_passivo.sql\n--\n-- Um vale-presente \u00E9 a \u00FAnica coisa que o Financeiro vende antes de existir: a\n-- casa recebe hoje por um servi\u00E7o que vai prestar depois. Isso n\u00E3o \u00E9 receita, \u00E9\n-- PASSIVO \u2014 e \u00E9 por isso que a tela mora no Financeiro e n\u00E3o na loja.\n--\n-- O CART\u00C3O J\u00C1 EXISTE, e mora na loja: `plg_shop_gift_cards` guarda c\u00F3digo,\n-- valor de face, saldo, validade e status desde o baseline do @fayz-ai/shop.\n-- Esta migration n\u00E3o cria tabela nenhuma e n\u00E3o copia uma linha: o Financeiro\n-- L\u00CA a tabela da loja e escreve nela por uma porta pr\u00F3pria. Duplicar o cart\u00E3o\n-- daria dois lugares onde o saldo mora, e dois lugares divergem no primeiro\n-- resgate.\n--\n-- O QUE FALTAVA ERA A EMISS\u00C3O EM LOTE. O provider da loja cria um cart\u00E3o por\n-- vez, e n\u00E3o \u00E9 assim que se emite vale-presente: emite-se um LOTE uniforme \u2014\n-- quantidade \u00D7 valor unit\u00E1rio \u2014 e manda-se imprimir. Vinte cart\u00F5es de R$ 100\n-- s\u00E3o vinte chamadas no shop e uma s\u00F3 aqui, com o total do lote conferido antes\n-- de gravar.\n--\n-- A LOJA PODE N\u00C3O ESTAR INSTALADA. `packages/shop` \u00E9 opcional na corrente e o\n-- Financeiro \u00E9 aplicado ANTES dele \u2014 a tabela n\u00E3o existe nem no momento em que\n-- este arquivo roda. \u00C9 a mesma armadilha da 015: uma vista criada em duas\n-- formas conforme a tabela existisse APLICA e MENTE, porque o dia em que a loja\n-- chegasse n\u00E3o a corrigiria. Ent\u00E3o o nome se resolve em TEMPO DE EXECU\u00C7\u00C3O, aqui\n-- tamb\u00E9m: sem loja as fun\u00E7\u00F5es devolvem \"n\u00E3o instalado\" e a tela se explica; com\n-- loja, as mesmas fun\u00E7\u00F5es passam a ler, sem republicar nada.\n--\n-- VALOR N\u00C3O \u00C9 SALDO, e o tra\u00E7o n\u00E3o \u00E9 zero. Valor \u00E9 o de face, gravado na\n-- emiss\u00E3o e imut\u00E1vel. Saldo \u00E9 o que resta \u2014 e um cart\u00E3o que nunca foi ativado\n-- N\u00C3O TEM saldo: tem tra\u00E7o. Zero \u00E9 um cart\u00E3o gasto, que \u00E9 outra hist\u00F3ria e\n-- outra conversa com o cliente. A tabela da loja n\u00E3o sabe dizer a diferen\u00E7a\n-- (`balance` \u00E9 NOT NULL), ent\u00E3o quem l\u00EA \u00E9 que a diz: `balance_known`.\n\n-- \u2500\u2500 a loja est\u00E1 a\u00ED? \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Uma pergunta e n\u00E3o uma exce\u00E7\u00E3o: a tela precisa dizer \"instale a loja\" em vez\n-- de mostrar um erro de SQL a quem nunca ouviu falar de `plg_shop_gift_cards`.\nCREATE OR REPLACE FUNCTION public.financial_gift_cards_enabled()\n RETURNS boolean\n LANGUAGE sql\n STABLE\n SET search_path TO ''\nAS $$ SELECT to_regclass('public.plg_shop_gift_cards') IS NOT NULL $$;\n\nCOMMENT ON FUNCTION public.financial_gift_cards_enabled() IS\n 'A loja que guarda os vales-presente est\u00E1 instalada? Sem ela a tela do Financeiro se explica em vez de quebrar (027).';\n\n-- \u2500\u2500 a lista \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- SECURITY DEFINER porque a tabela \u00E9 de OUTRO plugin e a pol\u00EDtica dela \u00E9 a da\n-- loja; quem entra por esta porta \u00E9 filtrado aqui, pelo tenant da sess\u00E3o e pela\n-- permiss\u00E3o do Financeiro. Sem isso, ligar o Financeiro daria de brinde a\n-- leitura da loja a quem s\u00F3 tem `financial.read` \u2014 e o contr\u00E1rio tamb\u00E9m.\nCREATE OR REPLACE FUNCTION public.financial_gift_cards(\n p_search text DEFAULT NULL,\n p_state text DEFAULT NULL,\n p_min numeric DEFAULT NULL,\n p_max numeric DEFAULT NULL,\n p_from date DEFAULT NULL,\n p_to date DEFAULT NULL,\n p_limit integer DEFAULT 500\n) RETURNS TABLE (\n id uuid,\n code text,\n face_value numeric,\n balance numeric,\n balance_known boolean,\n state text,\n currency text,\n issued_at timestamptz,\n expires_at timestamptz,\n activated_at timestamptz,\n note text,\n customer_email text\n)\n LANGUAGE plpgsql\n STABLE\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_activated text;\n v_sql text;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_gift_cards: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.read', NULL) THEN\n RAISE EXCEPTION 'financial_gift_cards: exige financial.read' USING ERRCODE = '42501';\n END IF;\n IF to_regclass('public.plg_shop_gift_cards') IS NULL THEN\n RETURN; -- sem loja, nenhuma linha: quem chama j\u00E1 perguntou pelo _enabled\n END IF;\n\n -- QUANDO O CART\u00C3O FOI ATIVADO. A tabela da loja n\u00E3o tem essa coluna, e cri\u00E1-la\n -- daqui n\u00E3o \u00E9 op\u00E7\u00E3o: esta corrente roda antes da dela, ent\u00E3o o ALTER cairia no\n -- vazio e nunca mais voltaria. O melhor que a linha sabe dizer \u00E9 a \u00FAltima vez\n -- que mudou \u2014 aproxima\u00E7\u00E3o, e assumida. No dia em que a loja ganhar\n -- `activated_at` de verdade, esta mesma fun\u00E7\u00E3o passa a l\u00EA-la sem republicar\n -- nada, que \u00E9 o motivo de a express\u00E3o ser resolvida aqui e n\u00E3o estar fixa.\n IF EXISTS (SELECT 1 FROM information_schema.columns c\n WHERE c.table_schema = 'public' AND c.table_name = 'plg_shop_gift_cards'\n AND c.column_name = 'activated_at') THEN\n v_activated := 'g.activated_at';\n ELSE\n v_activated :=\n 'CASE WHEN g.status = ''disabled'' AND g.balance = 0 THEN NULL'\n || ' WHEN g.updated_at > g.created_at THEN g.updated_at'\n || ' ELSE g.created_at END';\n END IF;\n\n v_sql :=\n 'SELECT * FROM ('\n || 'SELECT r.id, r.code, r.initial_balance,'\n -- O tra\u00E7o vira NULL aqui, n\u00E3o na tela: assim quem quer que leia esta fun\u00E7\u00E3o\n -- \u2014 relat\u00F3rio, agente, planilha \u2014 recebe a mesma distin\u00E7\u00E3o.\n || 'CASE WHEN r.never THEN NULL ELSE r.balance END,'\n || '(NOT r.never),'\n -- O estado que a casa fala, e n\u00E3o o enum da loja. `issued` \u00E9 o cart\u00E3o\n -- impresso e ainda n\u00E3o vendido: o lote do V1 inteiro estava assim.\n || 'CASE WHEN r.never THEN ''issued'''\n || ' WHEN r.balance = 0 OR r.status = ''redeemed'' THEN ''redeemed'''\n || ' WHEN r.expires_at IS NOT NULL AND r.expires_at < now() THEN ''expired'''\n || ' WHEN r.status = ''disabled'' THEN ''revoked'''\n || ' ELSE ''active'' END,'\n || 'r.currency, r.created_at, r.expires_at, r.activated_at, r.note, r.customer_email '\n || 'FROM (SELECT g.id, g.code, g.initial_balance, g.balance, g.currency, g.status,'\n || 'g.created_at, g.expires_at, g.note, g.customer_email,'\n -- Emitido e nunca ativado: saldo zerado E desabilitado. \u00C9 exatamente o que a\n -- emiss\u00E3o abaixo grava, e a loja nunca cria essa combina\u00E7\u00E3o (ela nasce ativa\n -- com o saldo cheio) \u2014 ent\u00E3o a distin\u00E7\u00E3o n\u00E3o depende de adivinha\u00E7\u00E3o.\n || '(g.status = ''disabled'' AND g.balance = 0) AS never,'\n || v_activated || ' AS activated_at '\n || 'FROM public.plg_shop_gift_cards g '\n || 'WHERE g.tenant_id = $1'\n || ' AND ($2::text IS NULL OR g.code ILIKE ''%'' || $2 || ''%'')'\n || ' AND ($3::numeric IS NULL OR g.initial_balance >= $3)'\n || ' AND ($4::numeric IS NULL OR g.initial_balance <= $4)'\n || ' AND ($5::date IS NULL OR g.created_at::date >= $5)'\n || ' AND ($6::date IS NULL OR g.created_at::date <= $6)) r'\n || ') s(id, code, face_value, balance, balance_known, state, currency,'\n || ' issued_at, expires_at, activated_at, note, customer_email) '\n || 'WHERE ($7::text IS NULL OR s.state = $7) '\n || 'ORDER BY s.issued_at DESC, s.code '\n || 'LIMIT $8';\n\n RETURN QUERY EXECUTE v_sql\n USING v_tenant, p_search, p_min, p_max, p_from, p_to, p_state,\n greatest(1, least(coalesce(p_limit, 500), 2000));\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_gift_cards(text, text, numeric, numeric, date, date, integer) IS\n 'Os vales-presente do tenant, lidos da loja pela porta do Financeiro. Devolve valor de face e saldo como coisas SEPARADAS: saldo NULL \u00E9 \"nunca ativado\", zero \u00E9 \"gasto\" (027).';\n\n-- \u2500\u2500 a emiss\u00E3o em lote \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Um lote \u00E9 uniforme: uma quantidade e UM valor unit\u00E1rio. N\u00E3o existe emitir\n-- cart\u00F5es de valores diferentes numa tacada \u2014 quem precisa disso emite dois\n-- lotes, e os dois totais ficam separados, que \u00E9 como o dinheiro entrou.\n--\n-- O cart\u00E3o nasce DESABILITADO com saldo zero: impresso, ainda n\u00E3o vendido.\n-- Ningu\u00E9m recebeu nada por ele, ent\u00E3o ele ainda n\u00E3o \u00E9 passivo \u2014 vira, no dia em\n-- que for ativado. Emitir e ativar na mesma tacada faria o balan\u00E7o da casa\n-- crescer por causa de uma impress\u00E3o.\nCREATE OR REPLACE FUNCTION public.financial_issue_gift_cards(\n p_quantity integer,\n p_amount numeric,\n p_expires_at timestamptz DEFAULT NULL,\n p_note text DEFAULT NULL,\n p_currency text DEFAULT 'BRL'\n) RETURNS TABLE (code text)\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_code text;\n v_out text;\n i integer;\n a integer;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_issue_gift_cards: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.create', NULL) THEN\n RAISE EXCEPTION 'financial_issue_gift_cards: exige financial.create' USING ERRCODE = '42501';\n END IF;\n IF to_regclass('public.plg_shop_gift_cards') IS NULL THEN\n RAISE EXCEPTION 'financial_issue_gift_cards: os vales-presente vivem na loja, e ela n\u00E3o est\u00E1 instalada neste banco'\n USING ERRCODE = '0A000';\n END IF;\n IF coalesce(p_quantity, 0) < 1 OR p_quantity > 500 THEN\n RAISE EXCEPTION 'financial_issue_gift_cards: quantidade fora de 1..500 (%)', p_quantity\n USING ERRCODE = '22023';\n END IF;\n -- Um vale de zero n\u00E3o \u00E9 uma cortesia, \u00E9 um cart\u00E3o que n\u00E3o paga nada \u2014 e o\n -- cliente descobre isso no caixa.\n IF coalesce(p_amount, 0) <= 0 THEN\n RAISE EXCEPTION 'financial_issue_gift_cards: o valor unit\u00E1rio tem de ser maior que zero'\n USING ERRCODE = '22023';\n END IF;\n -- Uma validade que j\u00E1 passou emite um lote nascido vencido.\n IF p_expires_at IS NOT NULL AND p_expires_at <= now() THEN\n RAISE EXCEPTION 'financial_issue_gift_cards: a validade j\u00E1 passou' USING ERRCODE = '22023';\n END IF;\n\n FOR i IN 1..p_quantity LOOP\n v_out := NULL;\n -- Dezesseis d\u00EDgitos, como cart\u00E3o. O \u00EDndice \u00FAnico da loja \u00E9 (tenant, upper(code)),\n -- ent\u00E3o a colis\u00E3o \u00E9 recusada pelo banco e n\u00E3o por uma checagem que corre\n -- antes \u2014 e o retry \u00E9 o que resolve, n\u00E3o um SELECT anterior que mente sob\n -- concorr\u00EAncia.\n FOR a IN 1..25 LOOP\n v_code := lpad((floor(random() * 100000000))::bigint::text, 8, '0')\n || lpad((floor(random() * 100000000))::bigint::text, 8, '0');\n EXECUTE\n 'INSERT INTO public.plg_shop_gift_cards'\n || ' (tenant_id, code, initial_balance, balance, currency, status, expires_at, note)'\n || ' VALUES ($1, $2, $3, 0, $4, ''disabled'', $5, $6)'\n || ' ON CONFLICT DO NOTHING RETURNING code'\n INTO v_out\n USING v_tenant, v_code, p_amount, coalesce(p_currency, 'BRL'), p_expires_at, p_note;\n EXIT WHEN v_out IS NOT NULL;\n END LOOP;\n IF v_out IS NULL THEN\n RAISE EXCEPTION 'financial_issue_gift_cards: n\u00E3o consegui um c\u00F3digo livre em 25 tentativas'\n USING ERRCODE = '23505';\n END IF;\n code := v_out;\n RETURN NEXT;\n END LOOP;\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_issue_gift_cards(integer, numeric, timestamptz, text, text) IS\n 'Emite um LOTE uniforme de vales-presente na tabela da loja: quantidade \u00D7 valor unit\u00E1rio, validade opcional. Nascem impressos e n\u00E3o ativados \u2014 passivo s\u00F3 quando vendidos (027).';\n\n-- \u2500\u2500 a ativa\u00E7\u00E3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Aqui \u00E9 onde o dinheiro entra e o passivo nasce: o cart\u00E3o \u00E9 vendido, o saldo\n-- passa a valer o valor de face, e a partir de agora a casa DEVE aquele\n-- atendimento. \u00C9 tamb\u00E9m o \u00FAnico momento em que \"saldo\" deixa de ser um tra\u00E7o.\nCREATE OR REPLACE FUNCTION public.financial_activate_gift_card(p_id uuid)\n RETURNS void\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_n integer;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'financial_activate_gift_card: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('financial.edit', NULL) THEN\n RAISE EXCEPTION 'financial_activate_gift_card: exige financial.edit' USING ERRCODE = '42501';\n END IF;\n IF to_regclass('public.plg_shop_gift_cards') IS NULL THEN\n RAISE EXCEPTION 'financial_activate_gift_card: a loja n\u00E3o est\u00E1 instalada neste banco'\n USING ERRCODE = '0A000';\n END IF;\n\n -- S\u00F3 ativa o que ainda n\u00E3o foi ativado. Reativar um cart\u00E3o em uso rep\u00F5e o\n -- saldo gasto \u2014 seria imprimir dinheiro por um clique repetido.\n EXECUTE\n 'UPDATE public.plg_shop_gift_cards'\n || ' SET status = ''active'', balance = initial_balance, updated_at = now()'\n || ' WHERE id = $1 AND tenant_id = $2 AND status = ''disabled'' AND balance = 0'\n USING p_id, v_tenant;\n GET DIAGNOSTICS v_n = ROW_COUNT;\n IF v_n = 0 THEN\n RAISE EXCEPTION 'financial_activate_gift_card: cart\u00E3o inexistente ou j\u00E1 ativado'\n USING ERRCODE = '22023';\n END IF;\nEND $$;\n\nCOMMENT ON FUNCTION public.financial_activate_gift_card(uuid) IS\n 'Vende o vale impresso: saldo passa a valer o valor de face e o passivo nasce. Recusa reativar um cart\u00E3o em uso \u2014 repor saldo gasto \u00E9 imprimir dinheiro (027).';\n\nREVOKE ALL ON FUNCTION public.financial_gift_cards_enabled() FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.financial_gift_cards(text, text, numeric, numeric, date, date, integer) FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.financial_issue_gift_cards(integer, numeric, timestamptz, text, text) FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.financial_activate_gift_card(uuid) FROM PUBLIC;\n\nGRANT EXECUTE ON FUNCTION public.financial_gift_cards_enabled() TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_gift_cards(text, text, numeric, numeric, date, date, integer) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_issue_gift_cards(integer, numeric, timestamptz, text, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.financial_activate_gift_card(uuid) TO authenticated;\n\nGRANT EXECUTE ON FUNCTION public.financial_gift_cards_enabled() TO service_role;\nGRANT EXECUTE ON FUNCTION public.financial_gift_cards(text, text, numeric, numeric, date, date, integer) TO service_role;\nGRANT EXECUTE ON FUNCTION public.financial_issue_gift_cards(integer, numeric, timestamptz, text, text) TO service_role;\nGRANT EXECUTE ON FUNCTION public.financial_activate_gift_card(uuid) TO service_role;\n";
|
|
29
|
+
export declare const MIGRATION_028_O_CARTAO_TEM_COR = "-- 028_o_cartao_tem_cor.sql\n--\n-- Quem tem quatro cart\u00F5es n\u00E3o os distingue pelo nome que digitou seis meses\n-- atr\u00E1s: distingue pela COR e pelos \u00FAltimos d\u00EDgitos, que \u00E9 como o cart\u00E3o est\u00E1\n-- na carteira. Uma tela de despesa que s\u00F3 diz \"Cart\u00E3o Empresarial\" obriga a\n-- pessoa a lembrar qual \u00E9 \u2014 e a errar de vez em quando, o que no fim do m\u00EAs \u00E9\n-- uma fatura conciliada contra o cart\u00E3o errado.\n--\n-- A cor \u00E9 do CADASTRO da conta, n\u00E3o derivada do id: derivar d\u00E1 cores est\u00E1veis e\n-- sem sentido (o roxo do Nubank n\u00E3o sai de um hash), e o dono quer poder pintar\n-- o cart\u00E3o como ele \u00E9 de verdade.\n--\n-- `color` \u00E9 texto livre e n\u00E3o um enum: \u00E9 um valor de CSS que a tela pinta. Um\n-- enum aqui obrigaria uma migration por cor nova, e a paleta \u00E9 escolha de quem\n-- desenha, n\u00E3o do banco. NULL \u00E9 resposta leg\u00EDtima \u2014 cart\u00E3o sem cor \u00E9 cart\u00E3o\n-- sem cor, e a tela usa o cinza padr\u00E3o em vez de inventar uma.\nALTER TABLE public.plg_financial_accounts\n ADD COLUMN IF NOT EXISTS color text;\n\nCOMMENT ON COLUMN public.plg_financial_accounts.color IS\n 'A cor com que o cart\u00E3o aparece na tela \u2014 \u00E9 assim que quem tem quatro cart\u00F5es sabe qual \u00E9. Texto (valor CSS), nulo quando n\u00E3o escolheram (028).';\n";
|
|
30
|
+
export declare const MIGRATION_029_THE_KIOSK_SALE_IS_A_SALE_TOO = "-- ---------------------------------------------------------------------------\n-- 029_the_kiosk_sale_is_a_sale_too.sql\n--\n-- Um pedido fechado levanta seu receb\u00EDvel \u2014 n\u00E3o s\u00F3 o do sal\u00E3o e o da agenda.\n--\n-- O gate da 001 nomeava s\u00F3 `dine_in`; a 005 acrescentou `appointment` e\n-- `service_order`. Nenhuma das tr\u00EAs cobre o que o quiosque e a entrega fazem:\n-- um `takeout` fechado no totem ia para `completed` sem nada atr\u00E1s dele \u2014\n-- dinheiro cobrado na maquininha e nenhuma conta a receber nos livros. O mesmo\n-- valia para `delivery` e para a venda de balc\u00E3o do PDV.\n--\n-- ESTA MIGRATION \u00C9 UMA UNI\u00C3O, N\u00C3O UMA TROCA. Ela nasceu numa branch que s\u00F3\n-- conhecia o gate da 001 e, escrita como estava, teria removido `appointment`\n-- e `service_order` \u2014 desfazendo a 005 sem ru\u00EDdo, porque o gatilho engole a\n-- falha num WARNING e o pedido fecha calado. As seis esp\u00E9cies convivem: as\n-- tr\u00EAs da 001/005 mais `takeout`, `delivery` e `sale`.\n--\n-- SOBRE `sale`, que a 005 recusou de prop\u00F3sito (\"a loja fatura no checkout,\n-- pela extens\u00E3o dela\"): a recusa continua valendo para a loja e nada muda para\n-- ela. O pedido de loja NASCE fechado, e este gatilho \u00E9 `AFTER UPDATE OF\n-- status` \u2014 um pedido que j\u00E1 entra `completed` nunca o dispara. Quem `sale`\n-- alcan\u00E7a aqui \u00E9 a venda de balc\u00E3o do PDV, que abre e depois fecha. E se as\n-- duas portas se cruzarem, `order_raise_receivable` devolve a fatura que j\u00E1\n-- existe em vez de emitir a segunda.\n--\n-- A lista sai daqui e vira UMA fun\u00E7\u00E3o, porque ela tem dois leitores que n\u00E3o\n-- podem discordar: este gatilho, que decide quando levantar, e\n-- `v_orders_sem_invoice` (030), que audita quem fechou sem levantar. Se os dois\n-- guardassem a pr\u00F3pria c\u00F3pia, a que audita poderia ficar cega justamente para a\n-- esp\u00E9cie que a outra esqueceu \u2014 e foi exatamente essa cegueira que deixou o\n-- buraco do quiosque passar despercebido.\n--\n-- CUIDADO ao editar `order_raise_receivable` (010): recriar este gatilho junto\n-- com ela desfaz o alargamento, e a falha n\u00E3o aparece em lugar nenhum. Esse\n-- sil\u00EAncio \u00E9 de prop\u00F3sito (ver 001: o gar\u00E7om tem de conseguir fechar a mesa), e\n-- s\u00F3 \u00E9 seguro porque a 030 enxerga o estrago depois.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\n-- IMMUTABLE: \u00E9 uma constante com nome. O planejador dobra a chamada no plano,\n-- ent\u00E3o us\u00E1-la no WHERE de uma view n\u00E3o custa nada por linha.\nCREATE OR REPLACE FUNCTION public.order_selling_kinds()\nRETURNS text[]\nLANGUAGE sql\nIMMUTABLE\nPARALLEL SAFE\nAS $function$\n SELECT ARRAY['dine_in', 'takeout', 'delivery', 'sale', 'appointment', 'service_order']::text[];\n$function$;\n\nCOMMENT ON FUNCTION public.order_selling_kinds() IS\n 'As especies de pedido cuja CONCLUSAO levanta o recebivel (gatilho orders_raise_receivable_on_close, 001/005/029) e cuja conclusao sem levantar e um defeito (v_orders_sem_invoice, 030). Uma lista so, para que o gatilho e a auditoria nao possam discordar. `sale` esta dentro pela venda de balcao do PDV; o pedido de loja nasce fechado e nunca dispara um AFTER UPDATE.';\n\nREVOKE ALL ON FUNCTION public.order_selling_kinds() FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.order_selling_kinds() TO authenticated, service_role;\n\nCREATE OR REPLACE FUNCTION public.trg_order_raise_receivable_on_close()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = ''\nAS $function$\nBEGIN\n IF NEW.kind = ANY (public.order_selling_kinds())\n AND NEW.status = 'completed'\n AND coalesce(OLD.status, '') IS DISTINCT FROM 'completed' THEN\n -- Nunca deixa o fechamento falhar por causa da escritura\u00E7\u00E3o (001).\n BEGIN\n PERFORM public.order_raise_receivable(NEW.id);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'order_raise_receivable falhou para o pedido %: %', NEW.id, SQLERRM;\n END;\n END IF;\n RETURN NEW;\nEND $function$;\n\nCOMMENT ON FUNCTION public.trg_order_raise_receivable_on_close() IS\n 'A comanda, o atendimento, a ordem de servico, o balcao, o quiosque e a entrega levantam o recebivel ao concluir (001, 005, 029). A lista vive em order_selling_kinds().';\n\nDROP TRIGGER IF EXISTS orders_raise_receivable_on_close ON public.orders;\nCREATE TRIGGER orders_raise_receivable_on_close\n AFTER UPDATE OF status ON public.orders\n FOR EACH ROW EXECUTE FUNCTION public.trg_order_raise_receivable_on_close();\n\n-- \u2500\u2500 o que j\u00E1 fechou pelas esp\u00E9cies novas e nunca foi faturado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- S\u00F3 as tr\u00EAs que este arquivo acrescenta: a 005 j\u00E1 varreu `appointment` e\n-- `service_order`, e `dine_in` nunca esteve fora do gate.\nDO $$\nDECLARE r record; v_n integer := 0;\nBEGIN\n FOR r IN\n SELECT o.id FROM public.orders o\n WHERE o.kind IN ('takeout', 'delivery', 'sale')\n AND o.status = 'completed'\n AND coalesce(o.total, 0) > 0\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_invoices f\n WHERE f.order_id = o.id AND f.cancelled_at IS NULL)\n LOOP\n BEGIN\n PERFORM public.order_raise_receivable(r.id);\n v_n := v_n + 1;\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING 'backfill 029: order_raise_receivable falhou para %: %', r.id, SQLERRM;\n END;\n END LOOP;\n IF v_n > 0 THEN\n RAISE NOTICE '029: % venda(s) de balcao/quiosque/entrega faturadas com atraso', v_n;\n END IF;\nEND $$;\n";
|
|
31
|
+
export declare const MIGRATION_030_THE_CENSUS_CAN_SEE_THE_KIOSK = "-- ---------------------------------------------------------------------------\n-- 030_the_census_can_see_the_kiosk.sql\n--\n-- O censo enxerga as esp\u00E9cies que realmente vendem.\n--\n-- `v_orders_sem_invoice` responde \"quais pedidos fecharam sem levantar\n-- receb\u00EDvel\". A lista dela era `sale, purchase, course` \u2014 e essas tr\u00EAs n\u00E3o\n-- incluem NENHUMA das que o caixa, o quiosque e a entrega usam de verdade.\n-- Ent\u00E3o a view voltava zero linha para um pool com o defeito dentro, e zero\n-- linha l\u00EA como \"tudo certo\", n\u00E3o como \"n\u00E3o estou olhando\".\n--\n-- Isso importa mais que um furo de relat\u00F3rio por causa da escolha embutida no\n-- gatilho da 001: ele engole a falha num RAISE WARNING de prop\u00F3sito, para que a\n-- conta sempre feche com o cliente na frente. Essa escolha est\u00E1 certa \u2014 recusar\n-- o fechamento prenderia o cliente no sal\u00E3o \u2014, mas ela s\u00F3 \u00E9 SEGURA se algu\u00E9m\n-- rio abaixo tornar o sil\u00EAncio vis\u00EDvel. Esta view \u00E9 esse algu\u00E9m, e era cega\n-- exatamente para o caso que aconteceu: um `takeout` do totem que fechou sem\n-- fatura porque o gate ainda nomeava s\u00F3 `dine_in`.\n--\n-- A lista de esp\u00E9cies vem de `public.order_selling_kinds()` (029), a MESMA que\n-- o gatilho consulta para decidir quando levantar. \u00C9 o ponto todo: quem audita\n-- n\u00E3o pode ter uma c\u00F3pia pr\u00F3pria da regra que audita. Com a uni\u00E3o da 029 ela\n-- carrega tamb\u00E9m `appointment` e `service_order` \u2014 e \u00E9 o que se quer: a 005\n-- passou a faturar os dois ao concluir e fez o backfill, ent\u00E3o um atendimento\n-- conclu\u00EDdo sem fatura deixou de ser leg\u00EDtimo e passou a ser exatamente o tipo\n-- de sil\u00EAncio que esta view existe para mostrar.\n--\n-- `purchase` e `course` continuam: s\u00E3o documentos que tamb\u00E9m deveriam ter\n-- fatura, s\u00F3 n\u00E3o passam pelo gatilho de fechamento. A view alarga, n\u00E3o troca.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE VIEW public.v_orders_sem_invoice\nWITH (security_invoker = true) AS\nSELECT o.id AS order_id, o.tenant_id, o.unit_id, o.kind, o.channel, o.status,\n o.total, o.created_at\n FROM public.orders o\n WHERE (o.kind = ANY (public.order_selling_kinds()) OR o.kind IN ('purchase', 'course'))\n -- Um pedido ainda aberto n\u00E3o \u00E9 buraco: ele \u00E9 faturado quando fecha. O que\n -- n\u00E3o \u00E9 leg\u00EDtimo \u00E9 o que CONCLUIU e n\u00E3o levantou nada \u2014 a mesma distin\u00E7\u00E3o\n -- que o PRD 06 faz para o atendimento.\n AND o.status IN ('completed', 'invoiced', 'paid')\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_financial_invoices f\n WHERE f.order_id = o.id AND f.cancelled_at IS NULL);\n\nCOMMENT ON VIEW public.v_orders_sem_invoice IS\n 'Pedidos que FECHARAM e nao produziram fatura (aceite do PRD 02/04). Vazio e a barra. As especies vem de order_selling_kinds(), a mesma lista que o gatilho de fechamento consulta, mais purchase e course. Um pedido ainda aberto fica de fora: ele e faturado quando fecha.';\n\nGRANT SELECT ON public.v_orders_sem_invoice TO authenticated, service_role;\n";
|
|
41
32
|
export declare const MIGRATIONS: Array<{
|
|
42
33
|
id: string;
|
|
43
34
|
sql: string;
|