@fayz-ai/plugin-financial 0.8.0 → 0.8.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/locales/en.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAgerC,CAAA"}
1
+ {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/locales/en.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA0erC,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"pt-BR.d.ts","sourceRoot":"","sources":["../../src/locales/pt-BR.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAgevC,CAAA"}
1
+ {"version":3,"file":"pt-BR.d.ts","sourceRoot":"","sources":["../../src/locales/pt-BR.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA0evC,CAAA"}
@@ -2,13 +2,15 @@ export declare const MIGRATION_000_PLG_RENAME = "-- 000_plg_rename.sql \u2014 re
2
2
  export declare const MIGRATION_001_FINANCIAL_BASE = "-- Financial Plugin: Base Tables\n-- Pattern: uuid PK, tenant_id FK, timestamps, RLS via project_rls.sql auto-detection\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_payment_method_types (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n transaction_type text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n allowed_account_types jsonb DEFAULT '[]'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_payment_method_types ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_payment_method_types_tenant ON public.plg_financial_payment_method_types(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_payment_methods (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n payment_method_type_id uuid REFERENCES public.plg_financial_payment_method_types(id),\n is_active boolean NOT NULL DEFAULT true,\n discount_mode text,\n discount_value numeric(12,2) DEFAULT 0,\n interest_mode text,\n interest_value numeric(12,2) DEFAULT 0,\n min_installments integer DEFAULT 1,\n max_installments integer DEFAULT 1,\n service_filter_mode text DEFAULT 'all',\n service_filter_ids jsonb DEFAULT '[]'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_payment_methods ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_payment_methods_tenant ON public.plg_financial_payment_methods(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_bank_accounts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n account_type text NOT NULL DEFAULT 'bank_account',\n bank_name text,\n account_number text,\n agency_number text,\n current_balance numeric(14,2) NOT NULL DEFAULT 0,\n initial_balance numeric(14,2) NOT NULL DEFAULT 0,\n credit_limit numeric(14,2),\n due_day integer,\n closing_day integer,\n is_active boolean NOT NULL DEFAULT true,\n unit_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_bank_accounts ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_bank_accounts_tenant ON public.plg_financial_bank_accounts(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_cash_register_sessions (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n bank_account_id uuid NOT NULL REFERENCES public.plg_financial_bank_accounts(id),\n status text NOT NULL DEFAULT 'open',\n opened_at timestamptz NOT NULL DEFAULT now(),\n opened_by_user_id uuid,\n opened_by_name text,\n opening_balance numeric(14,2) NOT NULL,\n closed_at timestamptz,\n closed_by_user_id uuid,\n closed_by_name text,\n closing_balance numeric(14,2),\n expected_balance numeric(14,2),\n difference numeric(14,2),\n notes text,\n unit_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_cash_register_sessions ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_cash_register_sessions_tenant ON public.plg_financial_cash_register_sessions(tenant_id);\n\n-- Financial movements (installments, payments) \u2014 plugin-specific, not archetype\n-- Invoices themselves use public.orders (kind='invoice_payable'/'invoice_receivable')\nCREATE TABLE IF NOT EXISTS public.plg_financial_movements (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n invoice_id uuid REFERENCES public.orders(id),\n direction text NOT NULL,\n movement_kind text NOT NULL,\n amount numeric(14,2) NOT NULL,\n paid_amount numeric(14,2) DEFAULT 0,\n status text NOT NULL DEFAULT 'pending',\n due_date date NOT NULL,\n payment_date date,\n installment_number integer,\n payment_method_id uuid REFERENCES public.plg_financial_payment_methods(id),\n bank_account_id uuid REFERENCES public.plg_financial_bank_accounts(id),\n cash_session_id uuid REFERENCES public.plg_financial_cash_register_sessions(id),\n card_brand text,\n card_installments integer,\n notes text,\n metadata jsonb DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_movements ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_tenant ON public.plg_financial_movements(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_invoice ON public.plg_financial_movements(invoice_id);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_due ON public.plg_financial_movements(tenant_id, due_date);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_bank ON public.plg_financial_movements(bank_account_id);\n";
3
3
  export declare const MIGRATION_002_CHART_OF_ACCOUNTS = "-- Financial Plugin: Chart of Accounts + Cost Centers\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_chart_of_accounts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n code text NOT NULL,\n name text NOT NULL,\n node_type text NOT NULL DEFAULT 'leaf',\n parent_id uuid REFERENCES public.plg_financial_chart_of_accounts(id),\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_chart_of_accounts ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_chart_of_accounts_tenant ON public.plg_financial_chart_of_accounts(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_financial_chart_of_accounts_parent ON public.plg_financial_chart_of_accounts(parent_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_cost_centers (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n code text NOT NULL,\n name text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_cost_centers ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_cost_centers_tenant ON public.plg_financial_cost_centers(tenant_id);\n";
4
4
  export declare const MIGRATION_003_CARD_BRANDS = "-- Financial Plugin: Card Brands\n\nCREATE TABLE IF NOT EXISTS public.plg_financial_card_brands (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_financial_card_brands ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_financial_card_brands_tenant ON public.plg_financial_card_brands(tenant_id);\n";
5
- export declare const MIGRATION_004_ORDER_TO_CASH = "-- Financial plugin \u2014 order-to-cash companion SQL.\n-- The atomic \"promote an order into an invoice + installment ledger\" function\n-- that CRM (approveQuote) and the agenda financial bridge both RPC. It belongs\n-- to the financial plugin (it writes plg_financial_movements + reads orders), and it\n-- ships WITH the plugin so enabling Payments provisions it. Idempotent.\n-- Depends on: public.orders, public.plg_financial_movements (001_financial_base).\n\n-- 1. Sequence generator (REC-00001 / PAG-00001 numbering)\nCREATE TABLE IF NOT EXISTS public.sequences (\n tenant_id uuid NOT NULL REFERENCES public.tenants(id),\n kind text NOT NULL,\n current_value bigint NOT NULL DEFAULT 0,\n PRIMARY KEY (tenant_id, kind)\n);\nCREATE OR REPLACE FUNCTION public.next_sequence(p_tenant_id uuid, p_kind text)\nRETURNS bigint LANGUAGE plpgsql 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$$;\n\n-- 2. Order-to-cash columns on the spine orders (plugin extends public.orders)\nALTER TABLE public.orders\n ADD COLUMN IF NOT EXISTS stage text,\n ADD COLUMN IF NOT EXISTS direction text;\n\n-- 3. RLS + grants for plg_financial_movements (table from 001_financial_base.sql)\nALTER TABLE public.plg_financial_movements ENABLE ROW LEVEL SECURITY;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_movements TO authenticated;\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_movements' AND policyname='plg_financial_movements_select') THEN\n CREATE POLICY plg_financial_movements_select ON public.plg_financial_movements FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_insert ON public.plg_financial_movements FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_update ON public.plg_financial_movements FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_delete ON public.plg_financial_movements FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n END IF;\nEND $$;\n\n-- 4. The function (v2 \u2014 backward compatible with the 3-arg CRM call via defaults)\nDROP FUNCTION IF EXISTS public.fn_invoice_from_order(uuid, date, text);\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_ref text;\n v_seq bigint;\n v_summary text;\n v_contact text;\n v_existing uuid;\n v_kind text;\n v_prefix text;\n v_n int := GREATEST(COALESCE(p_installments, 1), 1);\n v_total numeric;\n v_base numeric;\n v_amt numeric;\n v_alloc numeric := 0;\n v_due date;\n i int;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;\n IF v_order.tenant_id NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'fn_invoice_from_order: forbidden';\n END IF;\n\n SELECT id INTO v_existing FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill' LIMIT 1;\n IF v_existing IS NOT NULL THEN RETURN p_order_id; END IF;\n\n IF p_direction = 'debit' THEN v_kind := 'invoice_payable'; v_prefix := 'PAG';\n ELSE v_kind := 'invoice_receivable'; v_prefix := 'REC'; END IF;\n\n SELECT public.next_sequence(v_order.tenant_id, v_kind) INTO v_seq;\n v_ref := v_prefix || '-' || lpad(v_seq::text, 5, '0');\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.order_items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n UPDATE public.orders SET\n status = COALESCE(p_status, status),\n stage = 'invoiced',\n direction = p_direction,\n reference_number = v_ref,\n metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(\n 'itemsSummary', COALESCE(v_summary, ''),\n 'contactName', v_contact,\n 'installmentCount', v_n,\n 'quoteNumber', v_order.reference_number,\n 'direction', p_direction\n ),\n updated_at = now()\n WHERE id = p_order_id;\n\n v_total := COALESCE(v_order.total, 0);\n v_base := round(v_total / v_n, 2);\n v_due := COALESCE(p_due_date, current_date);\n FOR i IN 1..v_n LOOP\n IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);\n ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;\n INSERT INTO public.plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number)\n VALUES\n (v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',\n (v_due + ((i - 1) || ' months')::interval)::date, i);\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\nGRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated;\n\n-- 5. Derived balances view (Payments page + agenda payment badge read this)\nCREATE OR REPLACE VIEW public.v_invoice_balances AS\nWITH bill AS (\n SELECT\n invoice_id,\n SUM(amount) FILTER (WHERE status <> 'cancelled') AS billed,\n SUM(paid_amount) FILTER (WHERE status <> 'cancelled') AS paid,\n bool_or(status NOT IN ('paid','cancelled') AND due_date < current_date) AS has_overdue\n FROM public.plg_financial_movements\n WHERE movement_kind = 'bill'\n GROUP BY invoice_id\n)\nSELECT\n o.id AS invoice_id, o.tenant_id, o.kind, o.reference_number, o.party_id, o.direction,\n CASE WHEN o.direction = 'debit' OR o.kind = 'invoice_payable' THEN 'payable' ELSE 'receivable' END AS flow,\n COALESCE(b.billed, o.total, 0) AS amount,\n COALESCE(b.paid, 0) AS paid,\n CASE WHEN o.status = 'cancelled' OR o.stage = 'cancelled' THEN 0\n ELSE COALESCE(b.billed, o.total, 0) - COALESCE(b.paid, 0) END AS balance,\n CASE\n WHEN o.status = 'cancelled' OR o.stage = 'cancelled' THEN 'cancelled'\n WHEN COALESCE(b.billed, o.total, 0) = 0 THEN 'open'\n WHEN COALESCE(b.paid, 0) >= COALESCE(b.billed, o.total, 0) THEN 'paid'\n WHEN COALESCE(b.paid, 0) > 0 THEN 'partial'\n WHEN COALESCE(b.has_overdue, false) THEN 'overdue'\n ELSE 'open'\n END AS status,\n o.created_at, o.updated_at\nFROM public.orders o\nLEFT JOIN bill b ON b.invoice_id = o.id\nWHERE o.kind IN ('invoice_receivable','invoice_payable') OR b.invoice_id IS NOT NULL;\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated;\n\n-- Refresh PostgREST's schema cache so the new RPC/views are visible immediately.\nNOTIFY pgrst, 'reload schema';\n";
5
+ export declare const MIGRATION_004_ORDER_TO_CASH = "-- Financial plugin \u2014 order-to-cash companion SQL.\n-- The atomic \"promote an order into an invoice + installment ledger\" function\n-- that CRM (approveQuote) and the agenda financial bridge both RPC. It belongs\n-- to the financial plugin (it writes plg_financial_movements + reads orders), and it\n-- ships WITH the plugin so enabling Payments provisions it. Idempotent.\n-- Depends on: public.orders, public.plg_financial_movements (001_financial_base).\n\n-- 1. Sequence generator (REC-00001 / PAG-00001 numbering)\nCREATE TABLE IF NOT EXISTS public.sequences (\n tenant_id uuid NOT NULL REFERENCES public.tenants(id),\n kind text NOT NULL,\n current_value bigint NOT NULL DEFAULT 0,\n PRIMARY KEY (tenant_id, kind)\n);\n-- Baseline: RLS ON, deny-all (no policies), like the salon pool. The counter is\n-- an internal, non-API table written ONLY through next_sequence() below, which is\n-- SECURITY DEFINER and so bypasses RLS as the table owner. Legacy pools that\n-- shipped this table without RLS are also remediated by packages/db 013_sequences_rls.sql.\nALTER TABLE public.sequences ENABLE ROW LEVEL SECURITY;\n\n-- SECURITY DEFINER: next_sequence is called directly via PostgREST RPC by\n-- authenticated users (src/data/supabase.ts \u2192 createInvoice), so with deny-all\n-- RLS on public.sequences it MUST run as the table owner to write the counter.\n-- SET search_path pins schema resolution for the definer context.\nCREATE OR REPLACE FUNCTION public.next_sequence(p_tenant_id uuid, p_kind text)\nRETURNS bigint\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE v_next bigint;\nBEGIN\n INSERT INTO public.sequences (tenant_id, kind, current_value)\n VALUES (p_tenant_id, p_kind, 1)\n ON CONFLICT (tenant_id, kind)\n DO UPDATE SET current_value = public.sequences.current_value + 1\n RETURNING current_value INTO v_next;\n RETURN v_next;\nEND;\n$$;\n\n-- 2. Order-to-cash columns on the spine orders (plugin extends public.orders)\nALTER TABLE public.orders\n ADD COLUMN IF NOT EXISTS stage text,\n ADD COLUMN IF NOT EXISTS direction text;\n\n-- 3. RLS + grants for plg_financial_movements (table from 001_financial_base.sql)\nALTER TABLE public.plg_financial_movements ENABLE ROW LEVEL SECURITY;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_financial_movements TO authenticated;\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename='plg_financial_movements' AND policyname='plg_financial_movements_select') THEN\n CREATE POLICY plg_financial_movements_select ON public.plg_financial_movements FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_insert ON public.plg_financial_movements FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_update ON public.plg_financial_movements FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n CREATE POLICY plg_financial_movements_delete ON public.plg_financial_movements FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n END IF;\nEND $$;\n\n-- 4. The function (v2 \u2014 backward compatible with the 3-arg CRM call via defaults)\nDROP FUNCTION IF EXISTS public.fn_invoice_from_order(uuid, date, text);\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_ref text;\n v_seq bigint;\n v_summary text;\n v_contact text;\n v_existing uuid;\n v_kind text;\n v_prefix text;\n v_n int := GREATEST(COALESCE(p_installments, 1), 1);\n v_total numeric;\n v_base numeric;\n v_amt numeric;\n v_alloc numeric := 0;\n v_due date;\n i int;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;\n IF v_order.tenant_id NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'fn_invoice_from_order: forbidden';\n END IF;\n\n SELECT id INTO v_existing FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill' LIMIT 1;\n IF v_existing IS NOT NULL THEN RETURN p_order_id; END IF;\n\n IF p_direction = 'debit' THEN v_kind := 'invoice_payable'; v_prefix := 'PAG';\n ELSE v_kind := 'invoice_receivable'; v_prefix := 'REC'; END IF;\n\n SELECT public.next_sequence(v_order.tenant_id, v_kind) INTO v_seq;\n v_ref := v_prefix || '-' || lpad(v_seq::text, 5, '0');\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.order_items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n UPDATE public.orders SET\n status = COALESCE(p_status, status),\n stage = 'invoiced',\n direction = p_direction,\n reference_number = v_ref,\n metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(\n 'itemsSummary', COALESCE(v_summary, ''),\n 'contactName', v_contact,\n 'installmentCount', v_n,\n 'quoteNumber', v_order.reference_number,\n 'direction', p_direction\n ),\n updated_at = now()\n WHERE id = p_order_id;\n\n v_total := COALESCE(v_order.total, 0);\n v_base := round(v_total / v_n, 2);\n v_due := COALESCE(p_due_date, current_date);\n FOR i IN 1..v_n LOOP\n IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);\n ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;\n INSERT INTO public.plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number)\n VALUES\n (v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',\n (v_due + ((i - 1) || ' months')::interval)::date, i);\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\nGRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated;\n\n-- 5. Derived balances view (Payments page + agenda payment badge read this)\nCREATE OR REPLACE VIEW public.v_invoice_balances AS\nWITH bill AS (\n SELECT\n invoice_id,\n SUM(amount) FILTER (WHERE status <> 'cancelled') AS billed,\n SUM(paid_amount) FILTER (WHERE status <> 'cancelled') AS paid,\n bool_or(status NOT IN ('paid','cancelled') AND due_date < current_date) AS has_overdue\n FROM public.plg_financial_movements\n WHERE movement_kind = 'bill'\n GROUP BY invoice_id\n)\nSELECT\n o.id AS invoice_id, o.tenant_id, o.kind, o.reference_number, o.party_id, o.direction,\n CASE WHEN o.direction = 'debit' OR o.kind = 'invoice_payable' THEN 'payable' ELSE 'receivable' END AS flow,\n COALESCE(b.billed, o.total, 0) AS amount,\n COALESCE(b.paid, 0) AS paid,\n CASE WHEN o.status = 'cancelled' OR o.stage = 'cancelled' THEN 0\n ELSE COALESCE(b.billed, o.total, 0) - COALESCE(b.paid, 0) END AS balance,\n CASE\n WHEN o.status = 'cancelled' OR o.stage = 'cancelled' THEN 'cancelled'\n WHEN COALESCE(b.billed, o.total, 0) = 0 THEN 'open'\n WHEN COALESCE(b.paid, 0) >= COALESCE(b.billed, o.total, 0) THEN 'paid'\n WHEN COALESCE(b.paid, 0) > 0 THEN 'partial'\n WHEN COALESCE(b.has_overdue, false) THEN 'overdue'\n ELSE 'open'\n END AS status,\n o.created_at, o.updated_at\nFROM public.orders o\nLEFT JOIN bill b ON b.invoice_id = o.id\nWHERE o.kind IN ('invoice_receivable','invoice_payable') OR b.invoice_id IS NOT NULL;\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated;\n\n-- Refresh PostgREST's schema cache so the new RPC/views are visible immediately.\nNOTIFY pgrst, 'reload schema';\n";
6
6
  export declare const MIGRATION_005_SEED_DEFAULTS = "-- Financial plugin \u2014 seed: payment method types + card brands.\n-- These are the read-only \"system\" registries the plugin's UI assumes exist\n-- (PaymentModal, card selection). They live as registry seedData in TS but were\n-- never written to the DB, so a fresh tenant had none. This backfills them for\n-- every tenant that has none. Idempotent. Plugin-owned so every app gets them.\n\nDO $$\nDECLARE t_id uuid;\nBEGIN\n FOR t_id IN (\n SELECT id FROM public.tenants\n WHERE id NOT IN (SELECT DISTINCT tenant_id FROM public.plg_financial_payment_method_types)\n )\n LOOP\n INSERT INTO public.plg_financial_payment_method_types (tenant_id, name, transaction_type) VALUES\n (t_id, 'Cash', 'cash'),\n (t_id, 'PIX', 'pix'),\n (t_id, 'Credit Card', 'credit_card'),\n (t_id, 'Debit Card', 'debit_card'),\n (t_id, 'Bank Transfer', 'bank_transfer'),\n (t_id, 'Check', 'check');\n END LOOP;\nEND $$;\n\nDO $$\nDECLARE t_id uuid;\nBEGIN\n FOR t_id IN (\n SELECT id FROM public.tenants\n WHERE id NOT IN (SELECT DISTINCT tenant_id FROM public.plg_financial_card_brands)\n )\n LOOP\n INSERT INTO public.plg_financial_card_brands (tenant_id, name) VALUES\n (t_id, 'Visa'), (t_id, 'Mastercard'), (t_id, 'American Express'),\n (t_id, 'Elo'), (t_id, 'Hipercard'), (t_id, 'Diners Club'),\n (t_id, 'Discover'), (t_id, 'JCB'), (t_id, 'Aura'), (t_id, 'Hiper');\n END LOOP;\nEND $$;\n";
7
7
  export declare const MIGRATION_006_RLS_POLICIES = "-- Financial Plugin: RLS policies + grants for ALL base tables.\n--\n-- The base migrations (001/002/003) ENABLE row-level security but relied on an\n-- external `project_rls.sql` auto-detection step to create the actual policies.\n-- That step does not run in the Drizzle/companion pipeline, so every table\n-- except plg_financial_movements was RLS-enabled with NO policy \u2192 all inserts denied\n-- (\"new row violates row-level security policy\"). This creates the canonical\n-- per-tenant policies idempotently. Re-running is a no-op (IF NOT EXISTS guards;\n-- plg_financial_movements policies from 004 are skipped).\n--\n-- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).\n\nDO $$\nDECLARE t text;\nBEGIN\n FOR t IN SELECT unnest(ARRAY[\n 'plg_financial_payment_method_types','plg_financial_payment_methods','plg_financial_bank_accounts',\n 'plg_financial_cash_register_sessions','plg_financial_movements',\n 'plg_financial_chart_of_accounts','plg_financial_cost_centers','plg_financial_card_brands'\n ])\n LOOP\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);\n END IF;\n END LOOP;\nEND $$;\n";
8
8
  export declare const MIGRATION_006B_EXTRACT_FEE_AMOUNT = "-- ---------------------------------------------------------------------------\n-- Financial extract (extrato): net card settlement + transfers\n-- ---------------------------------------------------------------------------\n-- Adds the processing/MDR fee column used by the bank/cash account statement so\n-- the running balance reflects the NET cash that actually lands in the account.\n-- gross = paid_amount (settles the receivable/payable)\n-- net cash = paid_amount - fee_amount (credits only; v1 has no fee on debits)\nALTER TABLE public.plg_financial_movements\n ADD COLUMN IF NOT EXISTS fee_amount numeric(14,2) NOT NULL DEFAULT 0;\n\nCOMMENT ON COLUMN public.plg_financial_movements.fee_amount IS\n 'Processing/MDR fee deducted at settlement. Net cash impact = paid_amount - fee_amount (credit). v1: same-day net, no D+N settlement-date modeling.';\n\nCREATE INDEX IF NOT EXISTS idx_plg_financial_movements_statement\n ON public.plg_financial_movements (bank_account_id, payment_date)\n WHERE status IN ('paid', 'partial');\n\n-- Transfers between accounts = a debit row (source) + credit row (destination),\n-- both status='paid', movement_kind='transfer', correlated via metadata jsonb:\n-- metadata->>'transferId' | 'transferRole' ('out'|'in') | 'counterAccountId'\n";
9
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
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
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 MIGRATION_009_AGENT_RPCS = "-- ============================================================================\n-- plugin-financial 009: server-plane agent write RPC.\n--\n-- public.agent_financial_mark_payment_received \u2014 \"marca o atendimento como\n-- pago em cart\u00E3o 2x\". Resolves the order (directly or via a booking id),\n-- guard-checks the actor (agent_guard, spine 015), promotes the order into an\n-- invoice + installment ledger through the EXISTING invariant function\n-- fn_invoice_from_order (004) \u2014 never re-implementing its numbering/allocation\n-- logic \u2014 then settles the installments following 008's split model (bill rows\n-- flip to paid; each cash event is its own movement_kind='payment' row).\n--\n-- fn_invoice_from_order authorizes via user_tenant_ids() (auth.uid()). This\n-- RPC runs under the broker's service key, so it impersonates the ALREADY\n-- GUARD-VERIFIED actor for the duration of the transaction by setting\n-- request.jwt.claims locally \u2014 the invariant function keeps working unchanged\n-- and the audit trail carries the real acting user.\n--\n-- Contract: (p_tenant_id, p_actor_user_id, p_payload) \u2192 jsonb\n-- payload {order_id? uuid, booking_id? uuid, method text\n-- ('cash'|'pix'|'credit_card'|'debit_card'|'transfer'),\n-- installments? int (credit_card only, 1\u201312), amount? numeric}\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION public.agent_financial_mark_payment_received(\n p_tenant_id uuid,\n p_actor_user_id uuid,\n p_payload jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_denial jsonb;\n v_order_id uuid;\n v_booking_id uuid;\n v_method text;\n v_installments int;\n v_amount numeric;\n v_order record;\n v_bill record;\n v_paid_count int := 0;\n v_total_paid numeric := 0;\nBEGIN\n -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 BEGIN\n v_order_id := (p_payload->>'order_id')::uuid;\n v_booking_id := (p_payload->>'booking_id')::uuid;\n v_amount := (p_payload->>'amount')::numeric;\n v_installments := COALESCE((p_payload->>'installments')::int, 1);\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);\n END;\n v_method := lower(COALESCE(p_payload->>'method', 'cash'));\n IF v_method NOT IN ('cash', 'pix', 'credit_card', 'debit_card', 'transfer') THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown payment method');\n END IF;\n IF v_installments NOT BETWEEN 1 AND 12\n OR (v_installments > 1 AND v_method <> 'credit_card') THEN\n RETURN jsonb_build_object('ok', false, 'error', 'installments require credit_card (1-12)');\n END IF;\n\n -- booking \u2192 order\n IF v_order_id IS NULL AND v_booking_id IS NOT NULL THEN\n SELECT b.order_id INTO v_order_id\n FROM appointments b\n WHERE b.id = v_booking_id AND b.tenant_id = p_tenant_id;\n END IF;\n IF v_order_id IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'order_id or booking_id required (booking must have an order)');\n END IF;\n\n SELECT o.id, o.status, o.total INTO v_order\n FROM orders o\n 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 -- \u2500\u2500 authorization \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_denial := 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 paid?\n IF EXISTS (\n SELECT 1 FROM plg_financial_movements m\n WHERE m.invoice_id = v_order_id AND m.movement_kind = 'bill'\n ) AND NOT EXISTS (\n SELECT 1 FROM plg_financial_movements m\n WHERE m.invoice_id = v_order_id AND m.movement_kind = 'bill' AND m.status <> 'paid'\n ) THEN\n RETURN jsonb_build_object('ok', false, 'error', 'order is already fully paid');\n END IF;\n\n -- \u2500\u2500 invoice via the existing invariant function (impersonate the verified\n -- actor so fn_invoice_from_order's user_tenant_ids() check passes) \u2500\u2500\u2500\u2500\u2500\n PERFORM set_config('request.jwt.claims',\n jsonb_build_object('sub', p_actor_user_id, 'role', 'authenticated')::text,\n true);\n PERFORM fn_invoice_from_order(v_order_id, current_date, NULL, v_installments, 'credit');\n\n -- \u2500\u2500 settle: flip pending bill installments to paid + emit payment rows \u2500\u2500\u2500\n FOR v_bill IN\n SELECT m.* FROM plg_financial_movements m\n WHERE m.invoice_id = v_order_id\n AND m.tenant_id = p_tenant_id\n AND m.movement_kind = 'bill'\n AND m.status <> 'paid'\n ORDER BY m.installment_number NULLS FIRST\n LOOP\n UPDATE plg_financial_movements SET\n status = 'paid',\n paid_amount = v_bill.amount,\n payment_date = current_date,\n card_installments = CASE WHEN v_method = 'credit_card' THEN v_installments ELSE NULL END,\n metadata = COALESCE(metadata, '{}'::jsonb)\n || jsonb_build_object('paidVia', 'agent', 'method', v_method),\n updated_at = now()\n WHERE id = v_bill.id;\n\n INSERT INTO plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, card_installments, metadata)\n VALUES\n (p_tenant_id, v_order_id, v_bill.direction, 'payment', v_bill.amount, v_bill.amount,\n 'paid', v_bill.due_date, current_date, v_bill.installment_number,\n CASE WHEN v_method = 'credit_card' THEN v_installments ELSE NULL END,\n jsonb_build_object('source', 'agent', 'method', v_method));\n\n v_paid_count := v_paid_count + 1;\n v_total_paid := v_total_paid + v_bill.amount;\n END LOOP;\n\n IF v_paid_count = 0 THEN\n RETURN jsonb_build_object('ok', false, 'error', 'no open installments found to settle');\n END IF;\n\n INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant_id, p_actor_user_id, 'agent.markPaymentReceived', 'order', v_order_id::text,\n jsonb_build_object('payload', p_payload, 'installmentsSettled', v_paid_count,\n 'totalPaid', v_total_paid));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_order_id,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_order_id, 'resource', 'orders',\n 'archetype', 'order:service_order'),\n 'method', v_method,\n 'installments', v_installments,\n 'installments_settled', v_paid_count,\n 'total_paid', v_total_paid\n )\n );\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb) FROM public;\nREVOKE EXECUTE ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb) FROM anon;\nGRANT EXECUTE ON FUNCTION public.agent_financial_mark_payment_received(uuid, uuid, jsonb)\n TO authenticated, service_role;\n";
13
+ export declare const MIGRATION_010_INVOICE_INTERNAL = "-- Financial plugin \u2014 separate WHO MAY invoice from HOW an invoice is built.\n--\n-- fn_invoice_from_order (004) mixes the two: it checks that the caller is a\n-- member of the order's tenant, then does the work. That check is right for a\n-- human clicking \"Faturar\" in the admin, and impossible for anything else \u2014 a\n-- storefront checkout runs as the SHOPPER, whose user_tenant_ids() is empty by\n-- definition, so an order that should raise a receivable the moment it is placed\n-- cannot raise one at all. SECURITY DEFINER does not help: auth.uid() still\n-- belongs to the caller inside the definer context.\n--\n-- So the body moves to an internal function with no authorization of its own,\n-- and fn_invoice_from_order keeps its signature, keeps its check, and delegates.\n-- Callers that exist today (CRM approveQuote, the agenda bridge) are untouched.\n-- The internal one is granted to NOBODY: it is reachable only from other\n-- SECURITY DEFINER functions and triggers that have already decided the caller\n-- is entitled \u2014 the shop's order-to-cash bridge being the first.\n--\n-- Idempotent.\n\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order_internal(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_order public.orders%ROWTYPE;\n v_ref text;\n v_seq bigint;\n v_summary text;\n v_contact text;\n v_existing uuid;\n v_kind text;\n v_prefix text;\n v_n int := GREATEST(COALESCE(p_installments, 1), 1);\n v_total numeric;\n v_base numeric;\n v_amt numeric;\n v_alloc numeric := 0;\n v_due date;\n i int;\nBEGIN\n SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;\n IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;\n\n -- Idempotent: an order already carrying a bill is already invoiced.\n SELECT id INTO v_existing FROM public.plg_financial_movements\n WHERE invoice_id = p_order_id AND movement_kind = 'bill' LIMIT 1;\n IF v_existing IS NOT NULL THEN RETURN p_order_id; END IF;\n\n IF p_direction = 'debit' THEN v_kind := 'invoice_payable'; v_prefix := 'PAG';\n ELSE v_kind := 'invoice_receivable'; v_prefix := 'REC'; END IF;\n\n SELECT public.next_sequence(v_order.tenant_id, v_kind) INTO v_seq;\n v_ref := v_prefix || '-' || lpad(v_seq::text, 5, '0');\n\n SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary\n FROM public.order_items WHERE order_id = p_order_id;\n SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;\n\n UPDATE public.orders SET\n status = COALESCE(p_status, status),\n stage = 'invoiced',\n direction = p_direction,\n reference_number = v_ref,\n metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(\n 'itemsSummary', COALESCE(v_summary, ''),\n 'contactName', v_contact,\n 'installmentCount', v_n,\n 'quoteNumber', v_order.reference_number,\n 'direction', p_direction\n ),\n updated_at = now()\n WHERE id = p_order_id;\n\n v_total := COALESCE(v_order.total, 0);\n v_base := round(v_total / v_n, 2);\n v_due := COALESCE(p_due_date, current_date);\n FOR i IN 1..v_n LOOP\n IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);\n ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;\n INSERT INTO public.plg_financial_movements\n (tenant_id, invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, installment_number)\n VALUES\n (v_order.tenant_id, p_order_id, p_direction, 'bill', v_amt, 0, 'pending',\n (v_due + ((i - 1) || ' months')::interval)::date, i);\n END LOOP;\n\n RETURN p_order_id;\nEND;\n$$;\n\n-- Deliberately NOT granted to authenticated/anon: entry is through\n-- fn_invoice_from_order, or from another definer function that has already\n-- established the caller's right to create this receivable.\nREVOKE ALL ON FUNCTION public.fn_invoice_from_order_internal(uuid, date, text, int, text) FROM PUBLIC;\n\n-- The public entry point: unchanged signature and behaviour, now a guard around\n-- the shared body instead of a second copy of it.\nCREATE OR REPLACE FUNCTION public.fn_invoice_from_order(\n p_order_id uuid,\n p_due_date date DEFAULT NULL,\n p_status text DEFAULT NULL,\n p_installments int DEFAULT 1,\n p_direction text DEFAULT 'credit'\n) RETURNS uuid\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE v_tenant uuid;\nBEGIN\n SELECT tenant_id INTO v_tenant FROM public.orders WHERE id = p_order_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id;\n END IF;\n IF v_tenant NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'fn_invoice_from_order: forbidden';\n END IF;\n\n RETURN public.fn_invoice_from_order_internal(\n p_order_id, p_due_date, p_status, p_installments, p_direction);\nEND;\n$$;\n\nGRANT EXECUTE ON FUNCTION public.fn_invoice_from_order(uuid, date, text, int, text) TO authenticated;\n\nNOTIFY pgrst, 'reload schema';\n";
12
14
  export declare const MIGRATIONS: Array<{
13
15
  id: string;
14
16
  sql: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,wBAAwB,8+DA8BpC,CAAA;AAED,eAAO,MAAM,4BAA4B,woKA6GxC,CAAA;AAED,eAAO,MAAM,+BAA+B,67CA4B3C,CAAA;AAED,eAAO,MAAM,yBAAyB,0kBAYrC,CAAA;AAED,eAAO,MAAM,2BAA2B,yqOAoKvC,CAAA;AAED,eAAO,MAAM,2BAA2B,y6CAsCvC,CAAA;AAED,eAAO,MAAM,0BAA0B,k3EAqCtC,CAAA;AAED,eAAO,MAAM,iCAAiC,kxCAoB7C,CAAA;AAED,eAAO,MAAM,4BAA4B,sjEAgCxC,CAAA;AAED,eAAO,MAAM,2CAA2C,8YAMvD,CAAA;AAED,eAAO,MAAM,qCAAqC,qqDA6BjD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAYzD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,wBAAwB,8+DA8BpC,CAAA;AAED,eAAO,MAAM,4BAA4B,woKA6GxC,CAAA;AAED,eAAO,MAAM,+BAA+B,67CA4B3C,CAAA;AAED,eAAO,MAAM,yBAAyB,0kBAYrC,CAAA;AAED,eAAO,MAAM,2BAA2B,25PAkLvC,CAAA;AAED,eAAO,MAAM,2BAA2B,y6CAsCvC,CAAA;AAED,eAAO,MAAM,0BAA0B,k3EAqCtC,CAAA;AAED,eAAO,MAAM,iCAAiC,kxCAoB7C,CAAA;AAED,eAAO,MAAM,4BAA4B,sjEAgCxC,CAAA;AAED,eAAO,MAAM,2CAA2C,8YAMvD,CAAA;AAED,eAAO,MAAM,qCAAqC,qqDA6BjD,CAAA;AAED,eAAO,MAAM,wBAAwB,uzPAqKpC,CAAA;AAED,eAAO,MAAM,8BAA8B,unKAqI1C,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAczD,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"CardsView.d.ts","sourceRoot":"","sources":["../../src/views/CardsView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAsB3D,wBAAgB,SAAS,sBA2LxB"}
1
+ {"version":3,"file":"CardsView.d.ts","sourceRoot":"","sources":["../../src/views/CardsView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAuB3D,wBAAgB,SAAS,sBA6LxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"InvoiceFormView.d.ts","sourceRoot":"","sources":["../../src/views/InvoiceFormView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsC,MAAM,OAAO,CAAA;AAW1D,OAAO,KAAK,EAAE,oBAAoB,EAA+C,MAAM,UAAU,CAAA;AAMjG,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AA0WD,wBAAgB,eAAe,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;IAC9D,SAAS,EAAE,oBAAoB,CAAA;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC,qBAmiBA"}
1
+ {"version":3,"file":"InvoiceFormView.d.ts","sourceRoot":"","sources":["../../src/views/InvoiceFormView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsC,MAAM,OAAO,CAAA;AAY1D,OAAO,KAAK,EAAE,oBAAoB,EAA+C,MAAM,UAAU,CAAA;AAMjG,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AA0WD,wBAAgB,eAAe,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;IAC9D,SAAS,EAAE,oBAAoB,CAAA;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC,qBA4iBA"}
@@ -1 +1 @@
1
- {"version":3,"file":"InvoiceListView.d.ts","sourceRoot":"","sources":["../../src/views/InvoiceListView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAQ3D,OAAO,KAAK,EAAE,oBAAoB,EAA2D,MAAM,UAAU,CAAA;AA2E7G,wBAAgB,eAAe,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IAC5D,SAAS,EAAE,oBAAoB,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,IAAI,CAAA;IAClB,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B,qBAkJA"}
1
+ {"version":3,"file":"InvoiceListView.d.ts","sourceRoot":"","sources":["../../src/views/InvoiceListView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAQ3D,OAAO,KAAK,EAAE,oBAAoB,EAA2D,MAAM,UAAU,CAAA;AAmG7G,wBAAgB,eAAe,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IAC5D,SAAS,EAAE,oBAAoB,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,IAAI,CAAA;IAClB,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B,qBAmNA"}
@@ -1 +1 @@
1
- {"version":3,"file":"QuickTransactionForm.d.ts","sourceRoot":"","sources":["../../src/views/QuickTransactionForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA+C,MAAM,OAAO,CAAA;AAUnE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AAoIpD,wBAAgB,oBAAoB,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,WAAuB,EAAE,gBAAgB,EAAE,YAAoB,EAAE,EAAE;IAC5H,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,WAAW,CAAC,EAAE,oBAAoB,CAAA;IAClC,iGAAiG;IACjG,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,qBA8XA"}
1
+ {"version":3,"file":"QuickTransactionForm.d.ts","sourceRoot":"","sources":["../../src/views/QuickTransactionForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA+C,MAAM,OAAO,CAAA;AAWnE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AAoIpD,wBAAgB,oBAAoB,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,WAAuB,EAAE,gBAAgB,EAAE,YAAoB,EAAE,EAAE;IAC5H,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,WAAW,CAAC,EAAE,oBAAoB,CAAA;IAClC,iGAAiG;IACjG,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,qBAwYA"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.8.0",
6
+ "version": "0.8.3",
7
7
  "description": "Fayz SDK — plugin-financial plugin",
8
8
  "type": "module",
9
9
  "main": "./dist/index.cjs",
@@ -29,9 +29,9 @@
29
29
  "lucide-react": "^0.400.0",
30
30
  "zustand": "^4.5.0",
31
31
  "@tanstack/react-table": "^8.20.0",
32
- "@fayz-ai/ui": "^0.8.0",
33
- "@fayz-ai/saas": "^0.8.0",
34
- "@fayz-ai/core": "^0.8.0"
32
+ "@fayz-ai/ui": "^0.8.3",
33
+ "@fayz-ai/saas": "^0.8.3",
34
+ "@fayz-ai/core": "^0.8.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/react": "^18.3.0",
@@ -39,6 +39,12 @@ export interface ResolvedFinancialConfig {
39
39
  entityLookups: EntityLookupMap
40
40
  contactLookup?: EntityLookup
41
41
  onBookingClick?: (orderId: string) => void
42
+ /** Internal nav item id → access feature id, exactly as the APP declared it
43
+ * (`options.navFeatureMap`; empty when the app gates nothing — the plugin
44
+ * ships no defaults). Drives BOTH the sub-module link paywall
45
+ * (`useModuleNavAccess`) and the matching content gate, so the two can't
46
+ * drift. Unmapped ids are ungated. */
47
+ navFeatureMap: Record<string, string>
42
48
  }
43
49
 
44
50
  const ctx = createPluginContext<ResolvedFinancialConfig, FinancialDataProvider, FinancialUIState>('FinancialPage')
@@ -6,7 +6,7 @@ import { FinancialContextProvider, type ResolvedFinancialConfig } from './Financ
6
6
  import type { FinancialDataProvider } from './data/types'
7
7
  import type { FinancialUIState } from './store'
8
8
  import type { PluginRegistryDef, PluginQuickAction } from '@fayz-ai/core'
9
- import { useModuleNavigation, ModuleActionBar, parseViewId, PluginSettingsPanel } from '@fayz-ai/saas'
9
+ import { useModuleNavigation, ModuleActionBar, parseViewId, PluginSettingsPanel, EntitlementGate, UpgradePrompt, useModuleNavAccess, PermissionGate, usePermissionOptional } from '@fayz-ai/saas'
10
10
  import { SummaryView } from './views/SummaryView'
11
11
  import { QuickTransactionForm } from './views/QuickTransactionForm'
12
12
  import { usePendingQuickAdd, consumeQuickAdd } from './quick-add'
@@ -141,6 +141,14 @@ function buildNav(
141
141
  })
142
142
  }
143
143
 
144
+ // Tag each nav item with its access feature id (item id → feature via the
145
+ // resolved navFeatureMap). `useModuleNavAccess` then hides role-denied links
146
+ // and Crown-badges plan-denied ones; ids absent from the map stay ungated.
147
+ for (const item of items) {
148
+ const feature = config.navFeatureMap[item.id]
149
+ if (feature) item.feature = feature
150
+ }
151
+
144
152
  return items
145
153
  }
146
154
 
@@ -178,9 +186,18 @@ export function FinancialPage({ config, provider, store, registries }: {
178
186
  }, [onboardingComplete, provider])
179
187
 
180
188
  const t = useTranslation()
189
+ // Role-side create gate (QA: create affordances in custom plugin UIs must honor
190
+ // the role's `create` action, not just the route-level `read` guard). The
191
+ // financial module owns a single `/financial` route gated on read, so every
192
+ // in-page "new" affordance below is otherwise ungated.
193
+ const can = usePermissionOptional()
194
+ const canCreate = can('financial', 'create')
181
195
  const intent = parseIntent(view)
182
196
  const isSettings = view === 'settings'
183
- const nav = buildNav(config, view, navigate, t)
197
+ // Access-aware sub-nav: role denial hides the link, plan denial badges it with
198
+ // a Crown (freemium discovery). The click still navigates — the gated CONTENT
199
+ // renders the UpgradePrompt (see renderView's EntitlementGates below).
200
+ const nav = useModuleNavAccess(buildNav(config, view, navigate, t))
184
201
 
185
202
  // FAY-1225 "log money in a few taps": a reachable quick-add that opens the
186
203
  // Mobills-grade transaction sheet (default = expense). Prominent primary
@@ -203,6 +220,9 @@ export function FinancialPage({ config, provider, store, registries }: {
203
220
  if (!pendingQuickAdd) return
204
221
  const req = consumeQuickAdd()
205
222
  if (!req) return
223
+ // Defense in depth: the app-shell global "+" can request a quick-add even for
224
+ // a read-only member — swallow it when the role lacks `create`.
225
+ if (!canCreate) return
206
226
  setQuickAddType(req.type)
207
227
  setQuickAddReceipt(req.receipt)
208
228
  setQuickAddOpen(true)
@@ -217,6 +237,9 @@ export function FinancialPage({ config, provider, store, registries }: {
217
237
  // cash actions and no quick-add buttons render — exactly today's behavior.
218
238
  const quickActions = useMemo<PluginQuickAction[]>(() => {
219
239
  if (config.quickAdd) return []
240
+ // No `create` action → surface no "new" quick actions (payable/receivable/cash
241
+ // are all create affordances under the one-primary-action rule).
242
+ if (!canCreate) return []
220
243
  const actions: PluginQuickAction[] = []
221
244
  if (config.modules.payables) {
222
245
  actions.push({
@@ -246,7 +269,7 @@ export function FinancialPage({ config, provider, store, registries }: {
246
269
  })
247
270
  }
248
271
  return actions
249
- }, [config.modules, config.quickAdd])
272
+ }, [config.modules, config.quickAdd, canCreate, navigate, t])
250
273
 
251
274
  function handleOnboardingComplete() {
252
275
  setOnboardingComplete(true)
@@ -303,6 +326,24 @@ export function FinancialPage({ config, provider, store, registries }: {
303
326
  }
304
327
  }
305
328
 
329
+ // Content side of the sub-module paywall. A sub-module is premium ONLY when
330
+ // the app mapped its nav id to a feature (`navFeatureMap`) — the plugin ships
331
+ // no default, so an unmapped view renders ungated. Symmetric with the nav
332
+ // above: same map drives the Crown on the link and the gate on the content,
333
+ // so an app can never end up with a free-looking link over locked content.
334
+ // The gate must live here because an internal tab is not a shell route — the
335
+ // route guard never sees it (QA finding B35). Full UpgradePrompt (not the
336
+ // inline banner) fills the whole module panel.
337
+ function renderGatedView() {
338
+ const feature = config.navFeatureMap[intent.view]
339
+ if (!feature) return renderView()
340
+ return (
341
+ <EntitlementGate feature={feature} fallback={<UpgradePrompt feature={feature} />}>
342
+ {renderView()}
343
+ </EntitlementGate>
344
+ )
345
+ }
346
+
306
347
  return (
307
348
  <FinancialContextProvider config={config} provider={provider} store={store}>
308
349
  <ModulePage
@@ -319,14 +360,17 @@ export function FinancialPage({ config, provider, store, registries }: {
319
360
  // add affordance in B2C mode. The receipt entry point lives inside the
320
361
  // sheet's "Fotografar ou anexar recibo" attachment step, so no separate
321
362
  // "Enviar recibo" button is needed here.
322
- <Button
323
- size="sm"
324
- className="hidden md:inline-flex"
325
- onClick={() => openQuickAdd('expense')}
326
- >
327
- <Plus className="h-4 w-4" />
328
- {t('financial.quickTx.newTransaction')}
329
- </Button>
363
+ // Gated on the role's `create` action so read-only members don't see it.
364
+ <PermissionGate feature="financial" action="create">
365
+ <Button
366
+ size="sm"
367
+ className="hidden md:inline-flex"
368
+ onClick={() => openQuickAdd('expense')}
369
+ >
370
+ <Plus className="h-4 w-4" />
371
+ {t('financial.quickTx.newTransaction')}
372
+ </Button>
373
+ </PermissionGate>
330
374
  )}
331
375
  <ModuleActionBar
332
376
  quickActions={quickActions}
@@ -336,7 +380,7 @@ export function FinancialPage({ config, provider, store, registries }: {
336
380
  </div>
337
381
  }
338
382
  >
339
- {renderView()}
383
+ {renderGatedView()}
340
384
 
341
385
  {/* FAY-1242: the mobile quick-add + receipt FAB stack was removed — the
342
386
  app shell's elevated center "+" bottom-nav button now owns global
@@ -1,25 +1,38 @@
1
1
  import React from 'react'
2
- import { SettingsGroup, ToggleRow } from '@fayz-ai/saas'
2
+ import { SettingsGroup, ToggleRow, useTenantPluginSettings } from '@fayz-ai/saas'
3
3
  import { useTranslation } from '@fayz-ai/core'
4
4
 
5
+ // Per-tenant defaults = the values that used to be hardcoded here. Persisted +
6
+ // re-hydrated per tenant; behavioural consumption is a follow-up.
7
+ const FINANCIAL_DEFAULTS = {
8
+ autoInstallments: true,
9
+ requireDoc: false,
10
+ allowPartial: true,
11
+ requireOpening: true,
12
+ autoClose: false,
13
+ overdueAlerts: true,
14
+ dailySummary: false,
15
+ }
16
+
5
17
  export function FinancialGeneralSettings() {
6
18
  const t = useTranslation()
19
+ const s = useTenantPluginSettings('financial', FINANCIAL_DEFAULTS)
7
20
  return (
8
21
  <div className="space-y-4">
9
22
  <SettingsGroup title={t('financial.settings.invoices')} description={t('financial.settings.invoicesDesc')}>
10
- <ToggleRow label={t('financial.settings.autoInstallments')} description={t('financial.settings.autoInstallmentsDesc')} checked={true} onChange={() => {}} />
11
- <ToggleRow label={t('financial.settings.requireDoc')} description={t('financial.settings.requireDocDesc')} checked={false} onChange={() => {}} />
12
- <ToggleRow label={t('financial.settings.allowPartial')} description={t('financial.settings.allowPartialDesc')} checked={true} onChange={() => {}} />
23
+ <ToggleRow label={t('financial.settings.autoInstallments')} description={t('financial.settings.autoInstallmentsDesc')} checked={s.get('autoInstallments')} onChange={(v) => s.set('autoInstallments', v)} />
24
+ <ToggleRow label={t('financial.settings.requireDoc')} description={t('financial.settings.requireDocDesc')} checked={s.get('requireDoc')} onChange={(v) => s.set('requireDoc', v)} />
25
+ <ToggleRow label={t('financial.settings.allowPartial')} description={t('financial.settings.allowPartialDesc')} checked={s.get('allowPartial')} onChange={(v) => s.set('allowPartial', v)} />
13
26
  </SettingsGroup>
14
27
 
15
28
  <SettingsGroup title={t('financial.settings.cashRegister')} description={t('financial.settings.cashRegisterDesc')}>
16
- <ToggleRow label={t('financial.settings.requireOpening')} description={t('financial.settings.requireOpeningDesc')} checked={true} onChange={() => {}} />
17
- <ToggleRow label={t('financial.settings.autoClose')} description={t('financial.settings.autoCloseDesc')} checked={false} onChange={() => {}} />
29
+ <ToggleRow label={t('financial.settings.requireOpening')} description={t('financial.settings.requireOpeningDesc')} checked={s.get('requireOpening')} onChange={(v) => s.set('requireOpening', v)} />
30
+ <ToggleRow label={t('financial.settings.autoClose')} description={t('financial.settings.autoCloseDesc')} checked={s.get('autoClose')} onChange={(v) => s.set('autoClose', v)} />
18
31
  </SettingsGroup>
19
32
 
20
33
  <SettingsGroup title={t('financial.settings.notifications')} description={t('financial.settings.notificationsDesc')}>
21
- <ToggleRow label={t('financial.settings.overdueAlerts')} description={t('financial.settings.overdueAlertsDesc')} checked={true} onChange={() => {}} />
22
- <ToggleRow label={t('financial.settings.dailySummary')} description={t('financial.settings.dailySummaryDesc')} checked={false} onChange={() => {}} />
34
+ <ToggleRow label={t('financial.settings.overdueAlerts')} description={t('financial.settings.overdueAlertsDesc')} checked={s.get('overdueAlerts')} onChange={(v) => s.set('overdueAlerts', v)} />
35
+ <ToggleRow label={t('financial.settings.dailySummary')} description={t('financial.settings.dailySummaryDesc')} checked={s.get('dailySummary')} onChange={(v) => s.set('dailySummary', v)} />
23
36
  </SettingsGroup>
24
37
  </div>
25
38
  )
@@ -182,7 +182,12 @@ export function createSupabaseFinancialProvider(): FinancialDataProvider {
182
182
  }
183
183
  if (query.contactId) qb = qb.eq('party_id', query.contactId)
184
184
  if (query.search) qb = qb.ilike('notes', `%${query.search}%`)
185
- if (query.dateRange) qb = qb.gte('created_at', query.dateRange.from).lte('created_at', query.dateRange.to)
185
+ // `to` is a plain date from the UI filter — widen it to end-of-day so the
186
+ // last day of the range isn't cut off at midnight.
187
+ if (query.dateRange) {
188
+ const to = query.dateRange.to.length === 10 ? `${query.dateRange.to}T23:59:59.999` : query.dateRange.to
189
+ qb = qb.gte('created_at', query.dateRange.from).lte('created_at', to)
190
+ }
186
191
  const page = query.page ?? 1
187
192
  const pageSize = query.pageSize ?? 50
188
193
  qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('created_at', { ascending: false })
package/src/index.ts CHANGED
@@ -121,6 +121,22 @@ export interface FinancialPluginOptions {
121
121
 
122
122
  /** Callback when user clicks a booking link on an invoice. Receives the order ID. */
123
123
  onBookingClick?: (orderId: string) => void
124
+
125
+ /**
126
+ * Which internal-nav links this APP gates, as `nav item id → feature id`.
127
+ * Empty by default: the plugin ships no opinion about what is paid — packaging
128
+ * is the app's decision, declared alongside its `permissions.ts` (RBAC) and
129
+ * `billing.ts` (plan entitlements). An id absent from this map is never hidden
130
+ * and never gated, in the nav OR in the content.
131
+ *
132
+ * {@link FINANCIAL_NAV_FEATURES} is an opt-in preset of the `fin_*` ids used
133
+ * by the reference apps — spread the entries you actually want:
134
+ *
135
+ * ```ts
136
+ * navFeatureMap: { reconciliation: FINANCIAL_NAV_FEATURES.reconciliation }
137
+ * ```
138
+ */
139
+ navFeatureMap?: Record<string, string>
124
140
  }
125
141
 
126
142
  // ---------------------------------------------------------------------------
@@ -150,6 +166,23 @@ const DEFAULT_LABELS: FinancialPluginLabels = {
150
166
 
151
167
  const DEFAULT_CURRENCY = { code: 'BRL', locale: 'pt-BR', symbol: 'R$' }
152
168
 
169
+ /**
170
+ * OPT-IN preset of feature ids for the financial sub-modules — a naming
171
+ * convention offered to apps, NOT a default. Nothing here is applied unless an
172
+ * app passes the entries it wants through `options.navFeatureMap`, because what
173
+ * is paid is a product decision that belongs to the app (its `permissions.ts` +
174
+ * `billing.ts`), never to the plugin.
175
+ */
176
+ export const FINANCIAL_NAV_FEATURES = {
177
+ reconciliation: 'fin_reconciliation',
178
+ cards: 'fin_cards',
179
+ commissions: 'fin_commissions',
180
+ statements: 'fin_statements',
181
+ receivables: 'fin_receivables',
182
+ payables: 'fin_payables',
183
+ 'cash-registers': 'fin_cash_registers',
184
+ } as const satisfies Record<string, string>
185
+
153
186
  const DEFAULT_ITEM_TYPES: ItemTypeOption[] = [
154
187
  { value: 'service', label: 'Service', icon: 'Briefcase' },
155
188
  { value: 'product', label: 'Product', icon: 'Package' },
@@ -186,6 +219,7 @@ function resolveConfig(options?: FinancialPluginOptions): ResolvedFinancialConfi
186
219
  entityLookups: options?.entityLookups ?? {},
187
220
  contactLookup: options?.contactLookup,
188
221
  onBookingClick: options?.onBookingClick,
222
+ navFeatureMap: { ...options?.navFeatureMap },
189
223
  }
190
224
  }
191
225
 
@@ -225,6 +259,71 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
225
259
  ...(config.modules.payables ? [{ id: 'financial.payables', label: config.labels.payables, group: config.labels.pageTitle }] : []),
226
260
  ...(config.modules.commissions ? [{ id: 'financial.commissions', label: config.labels.commissions, group: config.labels.pageTitle }] : []),
227
261
  ],
262
+ // Recurring monthly quota — counts financial movement rows created this
263
+ // month (invoices write one per installment; quick transactions one, or two
264
+ // for a transfer).
265
+ declaredLimits: [
266
+ { key: 'movements_month', label: 'Financial movements this month', table: 'plg_financial_movements', period: 'month' },
267
+ ],
268
+ queryEntities: [
269
+ {
270
+ key: 'financial:invoices',
271
+ entity: {
272
+ name: 'Invoice',
273
+ namePlural: 'Invoices (receipts REC- / payables PAG-)',
274
+ icon: 'Receipt',
275
+ permission: { feature: 'financial', action: 'read' },
276
+ fields: [
277
+ { key: 'referenceNumber', label: 'Reference (REC-/PAG-)', type: 'text', searchable: true },
278
+ { key: 'flow', label: 'Flow (receivable/payable)', type: 'text' },
279
+ { key: 'amount', label: 'Amount', type: 'number' },
280
+ { key: 'paid', label: 'Paid', type: 'number' },
281
+ { key: 'balance', label: 'Balance', type: 'number' },
282
+ { key: 'status', label: 'Status', type: 'text' },
283
+ { key: 'createdAt', label: 'Created at', type: 'text' },
284
+ ],
285
+ data: {
286
+ table: 'v_invoice_balances',
287
+ tenantScoped: true,
288
+ searchColumns: ['reference_number'],
289
+ },
290
+ },
291
+ },
292
+ {
293
+ key: 'financial:movements',
294
+ entity: {
295
+ name: 'Financial movement',
296
+ namePlural: 'Financial movements (installment ledger)',
297
+ icon: 'Banknote',
298
+ permission: { feature: 'financial', action: 'read' },
299
+ fields: [
300
+ { key: 'direction', label: 'Direction', type: 'text' },
301
+ { key: 'movementKind', label: 'Kind (bill/payment)', type: 'text' },
302
+ { key: 'amount', label: 'Amount', type: 'number' },
303
+ { key: 'paidAmount', label: 'Paid amount', type: 'number' },
304
+ { key: 'status', label: 'Status', type: 'text' },
305
+ { key: 'dueDate', label: 'Due date', type: 'text' },
306
+ { key: 'paymentDate', label: 'Payment date', type: 'text' },
307
+ { key: 'installmentNumber', label: 'Installment', type: 'number' },
308
+ { key: 'cardInstallments', label: 'Card installments', type: 'number' },
309
+ { key: 'createdAt', label: 'Created at', type: 'text' },
310
+ ],
311
+ data: {
312
+ table: 'plg_financial_movements',
313
+ tenantScoped: true,
314
+ },
315
+ },
316
+ },
317
+ ],
318
+ declaredRpcs: [
319
+ {
320
+ name: 'agent_financial_mark_payment_received',
321
+ kind: 'write' as const,
322
+ description:
323
+ 'Guarded settle: invoices the order via fn_invoice_from_order (installments for credit card) and marks the receivable paid, audited with the acting user.',
324
+ audits: true,
325
+ },
326
+ ],
228
327
  navigation: [
229
328
  {
230
329
  section: options?.navSection ?? 'main',
@@ -278,6 +377,7 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
278
377
  description: 'Creates a new invoice/payable for a contact.',
279
378
  icon: 'FileText',
280
379
  mode: 'persist' as const,
380
+ limitKey: 'movements_month',
281
381
  category: 'Finance',
282
382
  parameters: {
283
383
  type: 'object' as const,
@@ -293,6 +393,34 @@ export function createFinancialPlugin(options?: FinancialPluginOptions): PluginM
293
393
  ],
294
394
  permission: { feature: 'financial', action: 'create' as const },
295
395
  },
396
+ {
397
+ id: 'financial.mark-payment-received',
398
+ name: 'markPaymentReceived',
399
+ description:
400
+ 'Marks an order/appointment as paid: invoices it (with installments for credit card, e.g. "cartão 2x") and settles the receivable. Resolve the booking or order id via search tools first.',
401
+ icon: 'BadgeDollarSign',
402
+ mode: 'persist' as const,
403
+ execution: { plane: 'server' as const, kind: 'rpc' as const, rpc: 'agent_financial_mark_payment_received' },
404
+ category: 'Finance',
405
+ parameters: {
406
+ type: 'object' as const,
407
+ properties: {
408
+ booking_id: { type: 'string' as const, description: 'Appointment id (alternative to order_id)' },
409
+ order_id: { type: 'string' as const, description: 'Order id' },
410
+ method: {
411
+ type: 'string' as const,
412
+ description: 'Payment method',
413
+ enum: ['cash', 'pix', 'credit_card', 'debit_card', 'transfer'],
414
+ },
415
+ installments: { type: 'number' as const, description: 'Credit-card installments (1-12), e.g. 2 for "cartão 2x"' },
416
+ },
417
+ required: ['method'],
418
+ },
419
+ suggestions: [
420
+ { label: 'Marcar o atendimento como pago no cartão em 2x' },
421
+ ],
422
+ permission: { feature: 'financial.receivables', action: 'update' as const },
423
+ },
296
424
  {
297
425
  id: 'financial.list-payables',
298
426
  name: 'listPayables',
package/src/locales/en.ts CHANGED
@@ -142,6 +142,16 @@ export const en: Record<string, string> = {
142
142
  "financial.invoice.filterAllAccounts": "All accounts",
143
143
  "financial.invoice.filterCostCenter": "Cost center",
144
144
  "financial.invoice.filterAllCostCenters": "All cost centers",
145
+ "financial.invoice.filterPeriod": "Period",
146
+ "financial.invoice.filterAllPeriods": "All time",
147
+ "financial.invoice.filterThisMonth": "This month",
148
+ "financial.invoice.filterLastMonth": "Last month",
149
+ "financial.invoice.filterLast30": "Last 30 days",
150
+ "financial.invoice.filterThisYear": "This year",
151
+ "financial.invoice.filterCustom": "Custom",
152
+ "financial.invoice.filterFrom": "From",
153
+ "financial.invoice.filterTo": "To",
154
+ "financial.invoice.filterClear": "Clear",
145
155
  "financial.reconciliation.title": "Reconciliation",
146
156
  "financial.reconciliation.subtitle": "Match imported bank lines to your movements",
147
157
  "financial.reconciliation.pending": "{{count}} to reconcile",
@@ -142,6 +142,16 @@ export const ptBR: Record<string, string> = {
142
142
  "financial.invoice.filterAllAccounts": "Todos os planos",
143
143
  "financial.invoice.filterCostCenter": "Centro de custo",
144
144
  "financial.invoice.filterAllCostCenters": "Todos os centros",
145
+ "financial.invoice.filterPeriod": "Período",
146
+ "financial.invoice.filterAllPeriods": "Todo o período",
147
+ "financial.invoice.filterThisMonth": "Este mês",
148
+ "financial.invoice.filterLastMonth": "Mês passado",
149
+ "financial.invoice.filterLast30": "Últimos 30 dias",
150
+ "financial.invoice.filterThisYear": "Este ano",
151
+ "financial.invoice.filterCustom": "Personalizado",
152
+ "financial.invoice.filterFrom": "De",
153
+ "financial.invoice.filterTo": "Até",
154
+ "financial.invoice.filterClear": "Limpar",
145
155
  "financial.reconciliation.title": "Conciliação",
146
156
  "financial.reconciliation.subtitle": "Concilie as linhas importadas do banco com seus lançamentos",
147
157
  "financial.reconciliation.pending": "{{count}} para conciliar",
@@ -12,8 +12,22 @@ CREATE TABLE IF NOT EXISTS public.sequences (
12
12
  current_value bigint NOT NULL DEFAULT 0,
13
13
  PRIMARY KEY (tenant_id, kind)
14
14
  );
15
+ -- Baseline: RLS ON, deny-all (no policies), like the salon pool. The counter is
16
+ -- an internal, non-API table written ONLY through next_sequence() below, which is
17
+ -- SECURITY DEFINER and so bypasses RLS as the table owner. Legacy pools that
18
+ -- shipped this table without RLS are also remediated by packages/db 013_sequences_rls.sql.
19
+ ALTER TABLE public.sequences ENABLE ROW LEVEL SECURITY;
20
+
21
+ -- SECURITY DEFINER: next_sequence is called directly via PostgREST RPC by
22
+ -- authenticated users (src/data/supabase.ts → createInvoice), so with deny-all
23
+ -- RLS on public.sequences it MUST run as the table owner to write the counter.
24
+ -- SET search_path pins schema resolution for the definer context.
15
25
  CREATE OR REPLACE FUNCTION public.next_sequence(p_tenant_id uuid, p_kind text)
16
- RETURNS bigint LANGUAGE plpgsql AS $$
26
+ RETURNS bigint
27
+ LANGUAGE plpgsql
28
+ SECURITY DEFINER
29
+ SET search_path = public
30
+ AS $$
17
31
  DECLARE v_next bigint;
18
32
  BEGIN
19
33
  INSERT INTO public.sequences (tenant_id, kind, current_value)