--liquibase formatted sql -- PANELS · core · RPC gastos e IVA --changeset panel:core-022a-fn-expense-list splitStatements:false CREATE OR REPLACE FUNCTION core.fn_expense_list(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint; v_date_from date := NULLIF(payload->>'date_from', '')::date; v_date_to date := NULLIF(payload->>'date_to', '')::date; v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint; v_source text := nullif(btrim(payload->>'source'), ''); v_rows jsonb; BEGIN SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.expense_date DESC, x.id DESC), '[]'::jsonb) INTO v_rows FROM ( SELECT e.*, bi.description AS budget_item_description, bi.code AS budget_item_code FROM expense_entries e LEFT JOIN budget_items bi ON bi.id = e.budget_item_id WHERE e.tenant_id = v_tid AND e.status <> 'void' AND (v_project_id IS NULL OR e.project_id = v_project_id) AND (v_company_id IS NULL OR e.company_id = v_company_id) AND (v_date_from IS NULL OR e.expense_date >= v_date_from) AND (v_date_to IS NULL OR e.expense_date <= v_date_to) AND (v_budget_item_id IS NULL OR e.budget_item_id = v_budget_item_id) AND (v_source IS NULL OR e.source = v_source) ) x; RETURN core.rpc_ok( jsonb_build_object('expenses', v_rows, 'count', jsonb_array_length(v_rows)), format('Listado de gastos: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid), jsonb_build_object('fn', 'fn_expense_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows)) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_expense_list', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022b-fn-expense-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_expense_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_id bigint := NULLIF(payload->>'id', '')::bigint; v_row jsonb; v_attachments jsonb; BEGIN IF v_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_expense_get: id es obligatorio', jsonb_build_object('fn', 'fn_expense_get', 'field', 'id')); END IF; SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id; IF v_row IS NULL THEN RETURN core.rpc_err('NOT_FOUND', format('fn_expense_get: gasto id=%s no encontrado', v_id), jsonb_build_object('fn', 'fn_expense_get', 'id', v_id)); END IF; SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.uploaded_at DESC), '[]'::jsonb) INTO v_attachments FROM expense_attachments a WHERE a.expense_id = v_id; RETURN core.rpc_ok( jsonb_build_object('expense', v_row, 'attachments', v_attachments), format('Gasto id=%s recuperado con %s comprobante(s)', v_id, jsonb_array_length(v_attachments)), jsonb_build_object('fn', 'fn_expense_get', 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_expense_get', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022c-fn-expense-create splitStatements:false CREATE OR REPLACE FUNCTION core.fn_expense_create(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint; v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; v_expense_date date := COALESCE(NULLIF(payload->>'expense_date', '')::date, CURRENT_DATE); v_description text := coalesce(nullif(btrim(payload->>'description'), ''), ''); v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint; v_budget_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint; v_quantity numeric := COALESCE((payload->>'quantity')::numeric, 0); v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), ''); v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0); v_subtotal_in numeric := COALESCE((payload->>'subtotal')::numeric, 0); v_iva_rate numeric; v_tax_included boolean := COALESCE((payload->>'tax_included')::boolean, false); v_total_in numeric := (payload->>'total')::numeric; v_subtotal numeric; v_iva_amount numeric; v_total numeric; v_is_deductible boolean := COALESCE((payload->>'is_deductible')::boolean, true); v_counts_toward_budget boolean := COALESCE((payload->>'counts_toward_budget')::boolean, true); v_supplier_name text := coalesce(nullif(btrim(payload->>'supplier_name'), ''), ''); v_supplier_rfc text := upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), '')); v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), ''); v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer; v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), ''); v_id bigint; v_warning jsonb; v_row jsonb; BEGIN IF v_company_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_expense_create: company_id es obligatorio', jsonb_build_object('fn', 'fn_expense_create', 'field', 'company_id')); END IF; IF v_description = '' THEN RETURN core.rpc_err('VALIDATION', 'fn_expense_create: description es obligatoria', jsonb_build_object('fn', 'fn_expense_create', 'field', 'description')); END IF; IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN RETURN core.rpc_err('VALIDATION', format('fn_expense_create: empresa id=%s no encontrada', v_company_id), jsonb_build_object('fn', 'fn_expense_create', 'company_id', v_company_id)); END IF; IF v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proyecto id=%s no encontrado', v_project_id), jsonb_build_object('fn', 'fn_expense_create', 'project_id', v_project_id)); END IF; IF v_subtotal_in <= 0 AND v_quantity > 0 AND v_unit_price > 0 THEN v_subtotal_in := round(v_quantity * v_unit_price, 2); END IF; SELECT default_iva_rate INTO v_iva_rate FROM core._cost_settings(v_tid); v_iva_rate := COALESCE((payload->>'iva_rate')::numeric, v_iva_rate); SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total FROM core._cost_calc_amounts(v_subtotal_in, v_iva_rate, v_tax_included, v_total_in) c; IF v_budget_item_id IS NOT NULL THEN SELECT chapter_id INTO v_budget_chapter_id FROM budget_items WHERE id = v_budget_item_id; END IF; INSERT INTO expense_entries ( tenant_id, company_id, project_id, expense_date, source, description, supplier_name, supplier_rfc, budget_chapter_id, budget_item_id, quantity, unit, unit_price, subtotal, iva_rate, iva_amount, total, tax_included, is_deductible, counts_toward_budget, status, created_by_id, created_by_name, notes ) VALUES ( v_tid, v_company_id, v_project_id, v_expense_date, 'manual', v_description, v_supplier_name, v_supplier_rfc, v_budget_chapter_id, v_budget_item_id, v_quantity, v_unit, v_unit_price, v_subtotal, v_iva_rate, v_iva_amount, v_total, v_tax_included, v_is_deductible, v_counts_toward_budget, 'confirmed', v_created_by_id, v_created_by_name, v_notes ) RETURNING id INTO v_id; IF v_counts_toward_budget AND v_budget_item_id IS NOT NULL THEN v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0); END IF; SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id; RETURN core.rpc_created( jsonb_build_object('expense', v_row, 'budget_warning', v_warning), format('Gasto registrado por %s (subtotal %s, IVA %s)', v_description, v_subtotal, v_iva_amount), jsonb_build_object('fn', 'fn_expense_create', 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_expense_create', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022d-fn-expense-update splitStatements:false CREATE OR REPLACE FUNCTION core.fn_expense_update(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_id bigint := NULLIF(payload->>'id', '')::bigint; v_tid integer := core._cost_tenant_id(payload); v_cur expense_entries%ROWTYPE; v_warning jsonb; v_row jsonb; v_subtotal numeric; v_iva_amount numeric; v_total numeric; BEGIN IF v_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_expense_update: id es obligatorio', jsonb_build_object('fn', 'fn_expense_update', 'field', 'id')); END IF; SELECT * INTO v_cur FROM expense_entries WHERE id = v_id; IF NOT FOUND THEN RETURN core.rpc_err('NOT_FOUND', format('fn_expense_update: gasto id=%s no encontrado', v_id), jsonb_build_object('fn', 'fn_expense_update', 'id', v_id)); END IF; IF v_cur.is_locked OR v_cur.source <> 'manual' THEN RETURN core.rpc_err('VALIDATION', 'fn_expense_update: solo se editan gastos manuales no bloqueados', jsonb_build_object('fn', 'fn_expense_update', 'id', v_id, 'source', v_cur.source)); END IF; UPDATE expense_entries SET expense_date = COALESCE(NULLIF(payload->>'expense_date', '')::date, expense_date), description = COALESCE(nullif(btrim(payload->>'description'), ''), description), supplier_name = CASE WHEN payload ? 'supplier_name' THEN coalesce(nullif(btrim(payload->>'supplier_name'), ''), '') ELSE supplier_name END, budget_item_id = CASE WHEN payload ? 'budget_item_id' THEN NULLIF(payload->>'budget_item_id', '')::bigint ELSE budget_item_id END, quantity = CASE WHEN payload ? 'quantity' THEN COALESCE((payload->>'quantity')::numeric, 0) ELSE quantity END, unit = CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE unit END, unit_price = CASE WHEN payload ? 'unit_price' THEN COALESCE((payload->>'unit_price')::numeric, 0) ELSE unit_price END, notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END WHERE id = v_id; SELECT * INTO v_cur FROM expense_entries WHERE id = v_id; SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total FROM core._cost_calc_amounts( CASE WHEN v_cur.quantity > 0 AND v_cur.unit_price > 0 THEN round(v_cur.quantity * v_cur.unit_price, 2) ELSE v_cur.subtotal END, v_cur.iva_rate, v_cur.tax_included, NULL ) c; UPDATE expense_entries SET subtotal = v_subtotal, iva_amount = v_iva_amount, total = v_total WHERE id = v_id; IF v_cur.counts_toward_budget AND v_cur.budget_item_id IS NOT NULL THEN v_warning := core._cost_budget_warning(v_tid, v_cur.budget_item_id, 0, 0); END IF; SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id; RETURN core.rpc_ok( jsonb_build_object('expense', v_row, 'budget_warning', v_warning), format('Gasto id=%s actualizado', v_id), jsonb_build_object('fn', 'fn_expense_update', 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_expense_update', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022e-fn-expense-void splitStatements:false CREATE OR REPLACE FUNCTION core.fn_expense_void(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_id bigint := NULLIF(payload->>'id', '')::bigint; v_cur expense_entries%ROWTYPE; BEGIN IF v_id IS NULL THEN RETURN core.rpc_err('VALIDATION', 'fn_expense_void: id es obligatorio', jsonb_build_object('fn', 'fn_expense_void', 'field', 'id')); END IF; SELECT * INTO v_cur FROM expense_entries WHERE id = v_id; IF NOT FOUND THEN RETURN core.rpc_err('NOT_FOUND', format('fn_expense_void: gasto id=%s no encontrado', v_id), jsonb_build_object('fn', 'fn_expense_void', 'id', v_id)); END IF; IF v_cur.is_locked THEN RETURN core.rpc_err('VALIDATION', 'fn_expense_void: no se puede anular un gasto derivado de nómina', jsonb_build_object('fn', 'fn_expense_void', 'id', v_id)); END IF; UPDATE expense_entries SET status = 'void' WHERE id = v_id; RETURN core.rpc_ok( jsonb_build_object('id', v_id, 'status', 'void'), format('Gasto id=%s anulado', v_id), jsonb_build_object('fn', 'fn_expense_void', 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_expense_void', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022f-fn-expense-attachment-add splitStatements:false CREATE OR REPLACE FUNCTION core.fn_expense_attachment_add(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_expense_id bigint := NULLIF(payload->>'expense_id', '')::bigint; v_id bigint; BEGIN IF v_expense_id IS NULL OR NOT EXISTS (SELECT 1 FROM expense_entries WHERE id = v_expense_id) THEN RETURN core.rpc_err('NOT_FOUND', 'fn_expense_attachment_add: gasto no encontrado', jsonb_build_object('fn', 'fn_expense_attachment_add', 'expense_id', v_expense_id)); END IF; INSERT INTO expense_attachments ( expense_id, original_name, mime, size_bytes, sha256, iv, storage_name, uploaded_by_id, uploaded_by_name ) VALUES ( v_expense_id, coalesce(nullif(btrim(payload->>'original_name'), ''), 'comprobante.pdf'), coalesce(nullif(btrim(payload->>'mime'), ''), 'application/pdf'), COALESCE((payload->>'size_bytes')::bigint, 0), coalesce(nullif(btrim(payload->>'sha256'), ''), ''), coalesce(nullif(btrim(payload->>'iv'), ''), ''), coalesce(nullif(btrim(payload->>'storage_name'), ''), ''), NULLIF(payload->>'uploaded_by_id', '')::integer, coalesce(nullif(btrim(payload->>'uploaded_by_name'), ''), '') ) RETURNING id INTO v_id; RETURN core.rpc_created( jsonb_build_object('id', v_id, 'expense_id', v_expense_id), format('Comprobante adjunto al gasto id=%s', v_expense_id), jsonb_build_object('fn', 'fn_expense_attachment_add', 'id', v_id) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_expense_attachment_add', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022g-fn-iva-period-summary splitStatements:false CREATE OR REPLACE FUNCTION core.fn_iva_period_summary(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint; v_year integer := COALESCE((payload->>'year')::integer, EXTRACT(YEAR FROM CURRENT_DATE)::integer); v_month integer := COALESCE((payload->>'month')::integer, EXTRACT(MONTH FROM CURRENT_DATE)::integer); v_acreditable numeric; v_trasladado numeric; BEGIN SELECT COALESCE(SUM(e.iva_amount), 0) INTO v_acreditable FROM expense_entries e WHERE e.tenant_id = v_tid AND e.status = 'confirmed' AND e.is_deductible = true AND EXTRACT(YEAR FROM e.expense_date) = v_year AND EXTRACT(MONTH FROM e.expense_date) = v_month AND (v_company_id IS NULL OR e.company_id = v_company_id); SELECT COALESCE(iva_trasladado, 0) INTO v_trasladado FROM iva_period_entries WHERE tenant_id = v_tid AND (v_company_id IS NULL OR company_id = v_company_id) AND period_year = v_year AND period_month = v_month LIMIT 1; RETURN core.rpc_ok( jsonb_build_object( 'year', v_year, 'month', v_month, 'iva_acreditable', round(COALESCE(v_acreditable, 0), 2), 'iva_trasladado', round(COALESCE(v_trasladado, 0), 2), 'iva_a_pagar', round(COALESCE(v_trasladado, 0) - COALESCE(v_acreditable, 0), 2) ), format('Resumen IVA %s/%s: acreditable %s, trasladado %s', v_month, v_year, v_acreditable, v_trasladado), jsonb_build_object('fn', 'fn_iva_period_summary', 'tenant_id', v_tid) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_iva_period_summary', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022h-fn-cost-settings-get splitStatements:false CREATE OR REPLACE FUNCTION core.fn_cost_settings_get(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); v_row jsonb; BEGIN SELECT to_jsonb(s) INTO v_row FROM tenant_cost_settings s WHERE tenant_id = v_tid; IF v_row IS NULL THEN v_row := jsonb_build_object('tenant_id', v_tid, 'budget_warn_pct', 85, 'budget_critical_pct', 100, 'default_iva_rate', 0.16); END IF; RETURN core.rpc_ok(v_row, format('Configuración de costos del tenant %s', v_tid), jsonb_build_object('fn', 'fn_cost_settings_get', 'tenant_id', v_tid)); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_cost_settings_get', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022i-fn-cost-settings-save splitStatements:false CREATE OR REPLACE FUNCTION core.fn_cost_settings_save(payload jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = core AS $$ DECLARE v_tid integer := core._cost_tenant_id(payload); BEGIN INSERT INTO tenant_cost_settings (tenant_id, budget_warn_pct, budget_critical_pct, default_iva_rate, updated_at) VALUES ( v_tid, COALESCE((payload->>'budget_warn_pct')::numeric, 85), COALESCE((payload->>'budget_critical_pct')::numeric, 100), COALESCE((payload->>'default_iva_rate')::numeric, 0.16), now() ) ON CONFLICT (tenant_id) DO UPDATE SET budget_warn_pct = EXCLUDED.budget_warn_pct, budget_critical_pct = EXCLUDED.budget_critical_pct, default_iva_rate = EXCLUDED.default_iva_rate, updated_at = now(); RETURN core.rpc_ok( (SELECT to_jsonb(s) FROM tenant_cost_settings s WHERE tenant_id = v_tid), format('Configuración de costos guardada para tenant %s', v_tid), jsonb_build_object('fn', 'fn_cost_settings_save', 'tenant_id', v_tid) ); EXCEPTION WHEN OTHERS THEN RETURN core.rpc_from_exception('fn_cost_settings_save', SQLSTATE, SQLERRM); END; $$; --changeset panel:core-022j-expense-grants endDelimiter:; splitStatements:true GRANT EXECUTE ON FUNCTION core.fn_expense_list(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_expense_get(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_expense_create(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_expense_update(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_expense_void(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_expense_attachment_add(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_iva_period_summary(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_cost_settings_get(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_cost_settings_save(jsonb) TO panels_core_app;