@fayz-ai/plugin-financial 0.14.2 → 0.14.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.
@@ -30,6 +30,54 @@ export declare const MIGRATION_028_O_CARTAO_TEM_COR = "-- 028_o_cartao_tem_cor.s
30
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
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";
32
32
  export declare const MIGRATION_031_THE_BANK_LINE_IS_EVIDENCE_NOT_A_SECOND_PAYMENT = "-- A bank statement line is evidence of cash movement until reconciliation.\n-- If matched, the app-native payment remains the sole economic event. If the\n-- user accepts the line without a match, that line becomes the economic event.\nCREATE OR REPLACE VIEW public.v_financial_cash_movements\nWITH (security_invoker = true) AS\nSELECT movement.*\n FROM public.plg_financial_movements movement\n LEFT JOIN public.plg_financial_movements reversed\n ON reversed.id = movement.reverses_id\n WHERE CASE\n -- A reversal has exactly the economic nature of the row it reverses. This\n -- keeps provider corrections neutral both for matched evidence and for an\n -- imported line that had previously been accepted as standalone.\n WHEN reversed.id IS NOT NULL THEN\n reversed.external_source IS NULL\n OR (reversed.reconciled_at IS NOT NULL AND reversed.matched_movement_id IS NULL)\n ELSE\n movement.external_source IS NULL\n OR (movement.reconciled_at IS NOT NULL AND movement.matched_movement_id IS NULL)\n END;\n\nCOMMENT ON VIEW public.v_financial_cash_movements IS\n 'Economic cash events. Native movements always count; imported statement lines count only after standalone acceptance, never when matched to a native movement. Reversals inherit the economic nature of the row they reverse.';\n\nGRANT SELECT ON public.v_financial_cash_movements TO authenticated, service_role;\n\nCREATE OR REPLACE VIEW public.v_financial_account_balances\nWITH (security_invoker = true) AS\nWITH debits AS (\n SELECT movement.debit_account_id AS account_id,\n sum(movement.amount) AS total,\n count(*) AS n\n FROM public.v_financial_cash_movements movement\n WHERE movement.status <> 'cancelled'\n AND movement.debit_account_id IS NOT NULL\n AND movement.movement_kind IN ('payment', 'transfer')\n GROUP BY movement.debit_account_id\n), credits AS (\n SELECT movement.credit_account_id AS account_id,\n sum(movement.amount) AS total,\n count(*) AS n\n FROM public.v_financial_cash_movements movement\n WHERE movement.status <> 'cancelled'\n AND movement.credit_account_id IS NOT NULL\n AND movement.movement_kind IN ('payment', 'transfer')\n GROUP BY movement.credit_account_id\n)\nSELECT account.id AS account_id,\n account.tenant_id,\n account.unit_id,\n account.kind,\n account.code,\n account.name,\n account.person_id,\n account.is_active,\n account.account_type,\n account.bank_name,\n account.agency_number,\n account.account_number,\n account.metadata,\n coalesce(debits.total, 0)::numeric(14,2) AS debit_total,\n coalesce(credits.total, 0)::numeric(14,2) AS credit_total,\n (coalesce(credits.total, 0) - coalesce(debits.total, 0))::numeric(14,2) AS net_credit,\n CASE\n WHEN account.kind IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit', 'card_receivable_debit')\n THEN (coalesce(debits.total, 0) - coalesce(credits.total, 0))::numeric(14,2)\n ELSE (coalesce(credits.total, 0) - coalesce(debits.total, 0))::numeric(14,2)\n END AS balance,\n (coalesce(debits.n, 0) + coalesce(credits.n, 0))::integer AS movement_count,\n account.created_at,\n account.updated_at\n FROM public.plg_financial_accounts account\n LEFT JOIN debits ON debits.account_id = account.id\n LEFT JOIN credits ON credits.account_id = account.id;\n\nCOMMENT ON VIEW public.v_financial_account_balances IS\n 'Balances from economic cash events; matched bank evidence never duplicates its app-native payment.';\n\nGRANT SELECT ON public.v_financial_account_balances TO authenticated, service_role;\n";
33
+ export declare const MIGRATION_032_CONTAS_A_PAGAR_GANHAM_ONDE_MORAR = "-- Contas a pagar ganham onde morar.\n--\n-- `recurring_bills`, `recurring_bill_items` e `recurring_bill_occurrences` do V1\n-- estavam em `migration.excluded_tables` com este motivo: *\"no destination\n-- contract \u2026 plg_financial_recurring_bills nunca foi constru\u00EDda (#188)\"*.\n--\n-- **A nota est\u00E1 vencida.** `public.plg_financial_recurring_bills` existe, com\n-- forma pr\u00F3pria e diferente da do V1 \u2014 e \u00E9 essa forma que manda:\n--\n-- V1 V2\n-- recurring_bills plg_financial_recurring_bills\n-- recurring_bill_items (tabela) .items (jsonb na pr\u00F3pria conta)\n-- recurring_bill_occurrences (tabela) \u2014 n\u00E3o existe\n-- interval_unit + interval_count frequency (weekly|monthly|quarterly|yearly)\n-- first_due_date starts_on + due_day (1..31)\n-- account_central_id \u2192 contacts counterparty_person_id \u2192 public.people\n-- (soma dos itens) amount NOT NULL, CHECK (amount > 0)\n-- (primeira descri\u00E7\u00E3o de item) description NOT NULL\n--\n-- Este arquivo faz tr\u00EAs coisas, e s\u00F3 a terceira cria tabela:\n--\n-- 1. escreve os writers que levam a conta e os itens para a forma do V2\n-- 2. escreve o de-para e tira as tr\u00EAs tabelas da lista de adiadas\n-- 3. cria `plg_financial_recurring_bill_occurrences`, que \u00E9 a \u00FAnica lacuna\n-- DE VERDADE\n--\n-- \u2500\u2500 Por que a ocorr\u00EAncia precisa existir \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 V2 deriva os vencimentos de `frequency` + `due_day`: a conta \u00E9 a regra, e o\n-- calend\u00E1rio sai dela. Isso funciona enquanto ningu\u00E9m mexe num vencimento \u2014 e o\n-- V1 deixa mexer. `recurring_bill_occurrences.overridden` marca o vencimento que\n-- algu\u00E9m editou \u00E0 m\u00E3o (mudou o valor de mar\u00E7o sem mexer na regra), e\n-- `paid_value` guarda o que de fato foi pago. Nenhum dos dois \u00E9 deriv\u00E1vel: a\n-- f\u00F3rmula devolve o valor da regra, n\u00E3o o que aconteceu.\n--\n-- Medido na origem em 18/09/2026 (licen\u00E7a 8, Pertinho do C\u00E9u): 35 contas, 35\n-- itens, 833 ocorr\u00EAncias, R$ 1.937.827,67 entre 25/05/2026 e 02/09/2028, 111 j\u00E1\n-- consolidadas. Descartar as ocorr\u00EAncias apagaria o hist\u00F3rico de pagamento e\n-- toda edi\u00E7\u00E3o manual junto.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. A \u00FAnica tabela que falta\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 IF NOT EXISTS public.plg_financial_recurring_bill_occurrences (\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 REFERENCES app.units(id),\n recurring_bill_id uuid NOT NULL\n REFERENCES public.plg_financial_recurring_bills(id) ON DELETE CASCADE,\n seq integer NOT NULL,\n competence_date date NOT NULL,\n due_date date NOT NULL,\n amount numeric NOT NULL,\n paid_amount numeric NOT NULL DEFAULT 0,\n status text NOT NULL DEFAULT 'provisioned'\n CHECK (status IN ('provisioned', 'consolidated', 'cancelled')),\n -- Algu\u00E9m editou ESTE vencimento, fora da regra. \u00C9 a raz\u00E3o de a ocorr\u00EAncia ser\n -- uma linha e n\u00E3o um c\u00E1lculo.\n overridden boolean NOT NULL DEFAULT false,\n -- O t\u00EDtulo que o vencimento virou, quando virou. Fica nulo at\u00E9 consolidar, e\n -- n\u00E3o vem do V1: l\u00E1 o campo aponta para `invoices`, cuja contraparte no V2 \u00E9\n -- uma VENDA, e ligar uma conta a pagar a uma venda inverteria o sentido do\n -- dinheiro para ganhar um link. A refer\u00EAncia crua do V1 fica em metadata.\n invoice_id uuid REFERENCES public.plg_financial_invoices(id) ON DELETE SET NULL,\n notes text,\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 (recurring_bill_id, seq)\n);\n\n-- A pergunta que a tela faz: \"o que vence neste per\u00EDodo?\". Sem este \u00EDndice ela\n-- varre as ocorr\u00EAncias de todos os tenants.\nCREATE INDEX IF NOT EXISTS plg_financial_recurring_bill_occurrences_due_idx\n ON public.plg_financial_recurring_bill_occurrences (tenant_id, due_date, status);\n\nDROP TRIGGER IF EXISTS plg_financial_recurring_bill_occurrences_updated_at\n ON public.plg_financial_recurring_bill_occurrences;\nCREATE TRIGGER plg_financial_recurring_bill_occurrences_updated_at\n BEFORE UPDATE ON public.plg_financial_recurring_bill_occurrences\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nGRANT SELECT, INSERT, UPDATE, DELETE\n ON public.plg_financial_recurring_bill_occurrences TO authenticated, service_role;\n\n-- O scaffold \u00E9 quem sabe desenhar a pol\u00EDtica deste cluster (tenant, unidade,\n-- permiss\u00E3o do plugin, escotilhas de compartilhamento). Escrever a policy \u00E0 m\u00E3o\n-- criaria a segunda regra de autoriza\u00E7\u00E3o do financeiro, que diverge da primeira\n-- na pr\u00F3xima mudan\u00E7a do scaffold.\n--\n-- `unit_optional`, e n\u00E3o `unit`: `unit_id` aceita NULL aqui porque a conta pode\n-- n\u00E3o ter unidade. Declarar `unit` numa coluna anul\u00E1vel \u00E9 o erro que j\u00E1 escondeu\n-- todo movimento financeiro sem unidade e zerou um relat\u00F3rio inteiro.\nSELECT app.scaffold_table('public.plg_financial_recurring_bill_occurrences'::regclass,\n 'financial.recurring_bill_occurrence', 'financial', false, 'enforce');\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. Os writers\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 vocabul\u00E1rio do de-para \u00E9 lista fechada de prop\u00F3sito: uma linha que nomeia um\n-- writer inexistente falha na hora, n\u00E3o na terceira hora da onda.\nDO $$\nDECLARE v_def text;\nBEGIN\n -- Replay-safe, e agora tamb\u00E9m MERGE-safe.\n --\n -- Este bloco reescrevia a lista inteira a partir do instant\u00E2neo daqui. Isso\n -- sobrevive a um replay (o guarda abaixo sai cedo), mas n\u00E3o sobrevive a um\n -- writer que tenha entrado ANTES deste arquivo por outro caminho: a\n -- reescrita apaga o nome e a linha que o usa passa a violar a constraint.\n --\n -- Foi o que aconteceu: `082` do spine (funil do V1) acrescenta\n -- `crm_pipeline`, o spine roda antes das cadeias de plugin, e este arquivo\n -- devolvia a lista sem ele \u2014 `db-from-zero` parou aqui com \"check constraint\n -- allowlist_writer_check is violated by some row\".\n --\n -- O padr\u00E3o certo j\u00E1 estava escrito na 062 e \u00E9 o que a pr\u00F3pria 082 usa: ler a\n -- defini\u00E7\u00E3o vigente e INJETAR os nomes ao lado de um termo que sabidamente\n -- est\u00E1 l\u00E1. Cir\u00FArgico, n\u00E3o substitutivo.\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n\n -- J\u00E1 sabe destes writers: nada a fazer.\n IF v_def IS NOT NULL AND strpos(v_def, 'fin_recurring_bill_occurrence') > 0 THEN\n RETURN;\n END IF;\n\n IF v_def IS NULL THEN\n -- N\u00E3o existe ainda: cria com a lista deste momento.\n ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check\n CHECK (writer IS NULL OR writer = ANY (ARRAY[\n 'person', 'unit', 'category', 'product', 'order', 'invoice', 'price_table',\n 'price_table_item', 'package', 'package_item', 'field_rule', 'holiday', 'facet',\n 'fin_account', 'fin_payment_method_type', 'fin_payment_method', 'fin_card_brand',\n 'fin_chart_account', 'fin_cost_center', 'inv_measurement_unit', 'inv_unit_conversion',\n 'inv_location', 'inv_movement', 'order_item', 'fin_movement', 'authz_role',\n 'authz_role_permission', 'person_registration',\n 'fin_recurring_bill', 'fin_recurring_bill_item', 'fin_recurring_bill_occurrence']));\n RETURN;\n END IF;\n\n -- Existe: preserva o que est\u00E1 l\u00E1 e acrescenta os tr\u00EAs desta onda.\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''person''::text',\n '''person''::text, ''fin_recurring_bill''::text, '\n || '''fin_recurring_bill_item''::text, ''fin_recurring_bill_occurrence''::text');\nEND $$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_recurring_bill(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_dir_v1 text := nullif(btrim(upper(coalesce(p_row ->> 'direction', ''))), '');\n v_dir text;\n v_unit_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'interval_unit', ''))), '');\n v_count integer := coalesce(nullif(btrim(coalesce(p_row ->> 'interval_count', '')), '')::integer, 1);\n v_freq text;\n v_amount numeric := (p_row ->> 'amount')::numeric;\n v_desc text := nullif(btrim(coalesce(p_row ->> 'description', '')), '');\n v_due_day integer;\n v_starts date := (p_row ->> 'starts_on')::date;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_starts IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma conta recorrente precisa de id e de primeiro vencimento');\n END IF;\n\n -- Sem corre\u00E7\u00E3o silenciosa: o V1 grava 'D' (despesa) e 'R' (receita) em `type`.\n -- Um vencimento com o sentido do dinheiro trocado some do contas a pagar e\n -- aparece no contas a receber, e ningu\u00E9m confere um n\u00FAmero que parece plaus\u00EDvel.\n v_dir := CASE v_dir_v1 WHEN 'D' THEN 'payable' WHEN 'R' THEN 'receivable' ELSE NULL END;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('sentido do dinheiro desconhecido: type=%L (o V1 usa D=despesa, R=receita)', v_dir_v1));\n END IF;\n\n -- Duas colunas do V1 (unidade + quantidade) viram uma palavra no V2. A tradu\u00E7\u00E3o\n -- \u00E9 exaustiva de prop\u00F3sito: \"a cada 2 meses\" n\u00E3o tem nome no vocabul\u00E1rio do V2,\n -- e arredondar para 'monthly' dobraria a previs\u00E3o de caixa daquela conta.\n v_freq := CASE\n WHEN v_unit_v1 = 'week' AND v_count = 1 THEN 'weekly'\n WHEN v_unit_v1 = 'month' AND v_count = 1 THEN 'monthly'\n WHEN v_unit_v1 = 'month' AND v_count = 3 THEN 'quarterly'\n WHEN v_unit_v1 = 'month' AND v_count = 12 THEN 'yearly'\n WHEN v_unit_v1 = 'year' AND v_count = 1 THEN 'yearly'\n ELSE NULL END;\n IF v_freq IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('periodicidade sem equivalente no V2: a cada %s %s (o V2 conhece '\n 'weekly, monthly, quarterly e yearly)', v_count, v_unit_v1));\n END IF;\n\n -- `description` e `amount` s\u00E3o NOT NULL no V2 e n\u00E3o existem na conta do V1:\n -- l\u00E1 eles moram nos itens. O plano de extra\u00E7\u00E3o os reconstr\u00F3i a partir da\n -- pr\u00F3pria origem (Step.augment) \u2014 se vierem vazios, a conta n\u00E3o tem item, e\n -- isso \u00E9 uma linha quebrada na origem, n\u00E3o um default a inventar.\n IF v_desc IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'conta recorrente sem descri\u00E7\u00E3o: no V1 ela vem do item, e esta conta n\u00E3o tem item');\n END IF;\n IF v_amount IS NULL OR v_amount <= 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('valor da conta recorrente \u00E9 %s: o V2 exige amount > 0, e no V1 ele \u00E9 a '\n 'soma dos itens', coalesce(v_amount::text, 'nulo')));\n END IF;\n\n v_due_day := extract(day from v_starts)::integer;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_recurring_bills WHERE id = p_id) THEN\n INSERT INTO public.plg_financial_recurring_bills\n (id, tenant_id, unit_id, description, direction, counterparty_person_id, amount,\n chart_account_id, cost_center_id, frequency, due_day, starts_on, ends_on,\n is_active, notes, items, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_desc, v_dir,\n (p_row ->> 'counterparty_person_id')::uuid, v_amount,\n (p_row ->> 'chart_account_id')::uuid, (p_row ->> 'cost_center_id')::uuid,\n v_freq, v_due_day, v_starts, (p_row ->> 'ends_on')::date,\n coalesce((p_row ->> 'is_active')::boolean, true), p_row ->> 'notes',\n '[]'::jsonb, v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_recurring_bills SET\n unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,\n description = v_desc,\n direction = v_dir,\n counterparty_person_id = CASE WHEN p_row ? 'counterparty_person_id' THEN (p_row ->> 'counterparty_person_id')::uuid ELSE counterparty_person_id END,\n amount = v_amount,\n chart_account_id = CASE WHEN p_row ? 'chart_account_id' THEN (p_row ->> 'chart_account_id')::uuid ELSE chart_account_id END,\n cost_center_id = CASE WHEN p_row ? 'cost_center_id' THEN (p_row ->> 'cost_center_id')::uuid ELSE cost_center_id END,\n frequency = v_freq,\n due_day = v_due_day,\n starts_on = v_starts,\n ends_on = CASE WHEN p_row ? 'ends_on' THEN (p_row ->> 'ends_on')::date ELSE ends_on END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_recurring_bills', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- O item do V1 \u00E9 uma LINHA; no V2 ele \u00E9 um elemento do jsonb `items` da conta.\n-- Por isso este writer n\u00E3o tem tabela pr\u00F3pria e devolve o id da CONTA: o ledger\n-- passa a apontar a linha de item do V1 para a conta que a cont\u00E9m, que \u00E9 onde o\n-- dado foi parar de verdade.\nCREATE OR REPLACE FUNCTION migration.upsert_fin_recurring_bill_item(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_bill uuid := (p_row ->> 'recurring_bill_id')::uuid;\n v_item jsonb;\n v_items jsonb;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_bill IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'um item precisa de id e da conta recorrente a que pertence');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_recurring_bills\n WHERE id = v_bill AND tenant_id = p_tenant) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a conta recorrente %s n\u00E3o existe neste tenant', v_bill));\n END IF;\n\n v_item := jsonb_strip_nulls(jsonb_build_object(\n 'id', p_id,\n 'description', p_row ->> 'description',\n 'quantity', (p_row ->> 'quantity')::numeric,\n 'unit_price', (p_row ->> 'unit_price')::numeric,\n 'discount', (p_row ->> 'discount')::numeric,\n 'addition', (p_row ->> 'addition')::numeric,\n 'total_amount', (p_row ->> 'total_amount')::numeric,\n 'chart_account_id', p_row ->> 'chart_account_id',\n 'cost_center_id', p_row ->> 'cost_center_id',\n -- No V2 um servi\u00E7o \u00C9 um produto: as duas colunas do V1 desaguam aqui.\n 'product_id', coalesce(p_row ->> 'product_id', p_row ->> 'service_product_id')\n ));\n\n -- Substitui o item de mesmo id em vez de acrescentar: reoferecer a mesma linha\n -- numa onda seguinte tem que atualizar, n\u00E3o duplicar o valor da conta.\n SELECT coalesce(jsonb_agg(x), '[]'::jsonb) INTO v_items\n FROM (SELECT value AS x FROM jsonb_array_elements(\n (SELECT items FROM public.plg_financial_recurring_bills WHERE id = v_bill))\n WHERE value ->> 'id' IS DISTINCT FROM p_id::text) s;\n\n UPDATE public.plg_financial_recurring_bills\n SET items = v_items || jsonb_build_array(v_item)\n WHERE id = v_bill;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_recurring_bills', v_bill, '{}'::jsonb); END IF;\n -- target_id \u00E9 a CONTA: \u00E9 l\u00E1 que o item mora no V2.\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_bill,\n 'reason', 'item gravado em plg_financial_recurring_bills.items');\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_recurring_bill_occurrence(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_bill uuid := (p_row ->> 'recurring_bill_id')::uuid;\n v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text;\n v_unit uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_bill IS NULL OR p_row ->> 'due_date' IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma ocorr\u00EAncia precisa de id, da conta recorrente e de vencimento');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_financial_recurring_bills\n WHERE id = v_bill AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a conta recorrente %s n\u00E3o existe neste tenant', v_bill));\n END IF;\n\n -- O V1 usa provisioned | consolidated; 'cancelled' existe do lado do V2 para o\n -- vencimento cancelado DEPOIS da virada. Estado desconhecido vira quarentena\n -- com a palavra \u00E0 vista, n\u00E3o um estado aproximado.\n v_status := CASE v_status_v1\n WHEN 'provisioned' THEN 'provisioned'\n WHEN 'consolidated' THEN 'consolidated'\n WHEN 'cancelled' THEN 'cancelled'\n WHEN 'canceled' THEN 'cancelled'\n ELSE NULL END;\n IF v_status IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('estado de vencimento desconhecido: %L', v_status_v1));\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_recurring_bill_occurrences WHERE id = p_id) THEN\n INSERT INTO public.plg_financial_recurring_bill_occurrences\n (id, tenant_id, unit_id, recurring_bill_id, seq, competence_date, due_date,\n amount, paid_amount, status, overridden, notes, metadata)\n VALUES (p_id, p_tenant, v_unit, v_bill,\n coalesce((p_row ->> 'seq')::integer, 1),\n coalesce((p_row ->> 'competence_date')::date, (p_row ->> 'due_date')::date),\n (p_row ->> 'due_date')::date,\n coalesce((p_row ->> 'amount')::numeric, 0),\n coalesce((p_row ->> 'paid_amount')::numeric, 0),\n v_status, coalesce((p_row ->> 'overridden')::boolean, false),\n p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_recurring_bill_occurrences SET\n unit_id = v_unit,\n seq = CASE WHEN p_row ? 'seq' THEN coalesce((p_row ->> 'seq')::integer, seq) ELSE seq END,\n competence_date = CASE WHEN p_row ? 'competence_date' THEN coalesce((p_row ->> 'competence_date')::date, competence_date) ELSE competence_date END,\n due_date = (p_row ->> 'due_date')::date,\n amount = CASE WHEN p_row ? 'amount' THEN coalesce((p_row ->> 'amount')::numeric, amount) ELSE amount END,\n paid_amount = CASE WHEN p_row ? 'paid_amount' THEN coalesce((p_row ->> 'paid_amount')::numeric, paid_amount) ELSE paid_amount END,\n status = v_status,\n overridden = CASE WHEN p_row ? 'overridden' THEN coalesce((p_row ->> 'overridden')::boolean, overridden) ELSE overridden END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_recurring_bill_occurrences', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 3. O de-para\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Saem da lista de adiadas: a nota delas dizia que o destino n\u00E3o existia, e ele\n-- existe (duas das tr\u00EAs) ou passou a existir (a terceira, acima).\nDELETE FROM migration.excluded_tables\n WHERE source_table IN ('recurring_bills', 'recurring_bill_items', 'recurring_bill_occurrences');\n\n-- `_allow` \u00E9 o construtor oficial: a allowlist tem gatilho de congelamento, e um\n-- INSERT direto por cima de uma linha `frozen` seria recusado.\n--\n-- As quatro colunas que o V2 exige e o V1 n\u00E3o tem na conta \u2014 description, amount,\n-- starts_on e o comprador \u2014 s\u00E3o reconstru\u00EDdas a partir da PR\u00D3PRIA origem pelo\n-- plano de extra\u00E7\u00E3o (`Step.augment` em fayz_etl/sources/beautysoft_v1.py), que \u00E9\n-- a ponte declarada para isso. Nada aqui inventa valor.\n-- A replay finds these rows already frozen, and trg_allowlist_frozen refuses a\n-- plain write; a migration file is the writer the hatch exists for (033/035).\n-- SET LOCAL so the unlock dies with this transaction.\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'recurring_bills', 'public.plg_financial_recurring_bills', 'financial',\n 'fin_recurring_bill', '{}'::jsonb, ARRAY['amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies:app.units',\n 'type', 'direction',\n 'first_due_date', 'starts_on',\n 'end_date', 'ends_on',\n 'interval_count', 'interval_count',\n 'interval_unit', 'interval_unit',\n 'active', 'is_active',\n 'observations', 'notes',\n -- Derivadas pelo plano, a partir dos itens e do account_central da conta.\n 'description', 'description',\n 'amount', 'amount',\n 'counterparty_contact_id','counterparty_person_id@contacts:public.people',\n 'chart_account_id', 'chart_account_id@chart_of_accounts',\n 'cost_center_id', 'cost_center_id@cost_centers',\n -- A conta financeira da contraparte continua registrada, ainda que o V2\n -- prefira a PESSOA: \u00E9 por ela que o V1 alcan\u00E7a quem recebe.\n 'account_central_id', 'jsonb:v1.account_central_id',\n -- O autor \u00E9 um login do V1, e nada liga login do V1 a usu\u00E1rio do V2 (#115).\n -- A refer\u00EAncia crua vale mais que perd\u00EA-la e mais que fingir que resolve.\n 'created_by', 'jsonb:v1.created_by',\n 'payment_method_id', 'jsonb:v1.payment_method_id'\n ),\n 'Regra de conta recorrente. description/amount/contraparte s\u00E3o derivados dos itens e do account_central pelo plano de extra\u00E7\u00E3o.'\n);\n\nSELECT migration._allow(\n 'recurring_bill_items', 'public.plg_financial_recurring_bills', 'financial',\n 'fin_recurring_bill_item', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'recurring_bill_id', 'recurring_bill_id@recurring_bills',\n 'description', 'description',\n 'quantity', 'quantity',\n 'unit_price', 'unit_price',\n 'discount', 'discount',\n 'additional', 'addition',\n 'total_amount', 'total_amount',\n 'account_id', 'chart_account_id@chart_of_accounts',\n 'cost_center_id', 'cost_center_id@cost_centers',\n 'product_id', 'product_id@products',\n 'service_id', 'service_product_id@services:public.products',\n -- \u00DAnico valor medido: 'other'. Traduzi-lo exigiria um vocabul\u00E1rio de destino\n -- que n\u00E3o existe; no metadata ele fica recuper\u00E1vel sem inventar nada.\n 'item_type', 'jsonb:v1.item_type'\n ),\n 'Item da conta. No V2 ele \u00E9 elemento do jsonb `items` da pr\u00F3pria conta \u2014 n\u00E3o tem tabela.'\n);\n\nSELECT migration._allow(\n 'recurring_bill_occurrences', 'public.plg_financial_recurring_bill_occurrences',\n 'financial',\n 'fin_recurring_bill_occurrence', '{}'::jsonb, ARRAY['amount', 'paid_amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'recurring_bill_id', 'recurring_bill_id@recurring_bills',\n 'seq', 'seq',\n 'competence_date', 'competence_date',\n 'due_date', 'due_date',\n 'value', 'amount',\n 'paid_value', 'paid_amount',\n 'status', 'status',\n 'overridden', 'overridden',\n 'observations', 'notes',\n -- N\u00C3O vira FK: no V1 aponta para `invoices`, cuja contraparte no V2 \u00E9 uma\n -- VENDA. Ligar uma conta a pagar a uma venda inverteria o sentido do\n -- dinheiro para ganhar um link. O estado `consolidated` j\u00E1 diz o que importa.\n 'invoice_id', 'jsonb:v1.invoice_id'\n ),\n 'O vencimento. `overridden` marca o que algu\u00E9m editou fora da regra \u2014 n\u00E3o \u00E9 deriv\u00E1vel.'\n);\n";
34
+ export declare const MIGRATION_033_O_VENCIMENTO_APONTA_PARA_O_TITULO = "-- O vencimento aponta para o t\u00EDtulo.\n--\n-- Corre\u00E7\u00E3o de um mapeamento da 046. L\u00E1, `recurring_bill_occurrences.invoice_id`\n-- foi jogado em `metadata` com esta justificativa:\n--\n-- \"N\u00C3O vira FK: no V1 aponta para `invoices`, cuja contraparte no V2 \u00E9 uma\n-- VENDA. Ligar uma conta a pagar a uma venda inverteria o sentido do\n-- dinheiro para ganhar um link.\"\n--\n-- A primeira metade est\u00E1 certa e a segunda \u00E9 falsa. `invoices` do V1 tem DOIS\n-- destinos no V2, n\u00E3o um: o writer `order` grava a Venda em `public.orders` e\n-- devolve, em `extra`, o T\u00CDTULO em `public.plg_financial_invoices`. O ledger\n-- guarda as duas linhas. Medido no tenant 8: 2.365 para `public.orders` e 2.365\n-- para `public.plg_financial_invoices`.\n--\n-- E o t\u00EDtulo \u00E9 exatamente o objeto certo: das 111 faturas que as ocorr\u00EAncias\n-- consolidadas referenciam, as 111 t\u00EAm t\u00EDtulo no V2, e **todas com\n-- `direction = 'payable'`**. N\u00E3o h\u00E1 invers\u00E3o nenhuma a evitar.\n--\n-- \u2500\u2500 O que o erro custava \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `recurring_bill_occurrences.paid_value` \u00E9 0 nas 833 linhas da origem \u2014 no V1 o\n-- pagamento n\u00E3o mora no vencimento, mora no movimento financeiro ligado ao\n-- t\u00EDtulo. Com `invoice_id` em metadata, a ocorr\u00EAncia consolidada chegava ao V2\n-- sem nenhum caminho at\u00E9 o dinheiro: 123 vencimentos marcados `consolidated` e\n-- indistingu\u00EDveis, na tela, de 710 em aberto. A tela dizia a verdade sobre o\n-- campo e mentia sobre o fato.\n--\n-- Com a FK, o caminho existe:\n--\n-- ocorr\u00EAncia \u2192 plg_financial_invoices \u2192 plg_financial_movements (paid_amount)\n--\n-- S\u00E3o R$ 311.006,00 pagos em 181 movimentos, que passam a ser alcan\u00E7\u00E1veis a\n-- partir do vencimento.\n--\n-- Idempotente.\n\n-- A linha j\u00E1 est\u00E1 `frozen`, e o gatilho `trg_allowlist_frozen` recusa a escrita\n-- com a frase certa: \"core-A allowlist changes are a migration file, not a\n-- write\". Este \u00C9 o arquivo de migration, e a escotilha existe exatamente para\n-- ele \u2014 `SET LOCAL`, para a destravada morrer com a transa\u00E7\u00E3o e n\u00E3o vazar para a\n-- sess\u00E3o seguinte.\nSET LOCAL migration.allowlist_unlock = 'on';\n\n-- `_allow` reescreve a linha inteira (ON CONFLICT DO UPDATE), ent\u00E3o o mapa vai\n-- completo \u2014 inclusive as colunas que n\u00E3o mudaram.\nSELECT migration._allow(\n 'recurring_bill_occurrences', 'public.plg_financial_recurring_bill_occurrences',\n 'financial',\n 'fin_recurring_bill_occurrence', '{}'::jsonb, ARRAY['amount', 'paid_amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'recurring_bill_id', 'recurring_bill_id@recurring_bills',\n 'seq', 'seq',\n 'competence_date', 'competence_date',\n 'due_date', 'due_date',\n 'value', 'amount',\n 'paid_value', 'paid_amount',\n 'status', 'status',\n 'overridden', 'overridden',\n 'observations', 'notes',\n -- O T\u00CDTULO, n\u00E3o a venda. `invoices` tem dois destinos no V2 e este \u00E9 o\n -- financeiro; o `:public.plg_financial_invoices` escolhe qual dos dois o\n -- ledger deve resolver.\n 'invoice_id', 'invoice_id@invoices:public.plg_financial_invoices'\n ),\n 'O vencimento. `overridden` marca o que algu\u00E9m editou fora da regra \u2014 n\u00E3o \u00E9 '\n 'deriv\u00E1vel. `invoice_id` liga o vencimento consolidado ao t\u00EDtulo, que \u00E9 onde o '\n 'pagamento mora: no V1 paid_value \u00E9 sempre 0.'\n);\n\n-- O writer precisa gravar a coluna que o de-para agora entrega. Sem isto o mapa\n-- resolveria a refer\u00EAncia e o writer a jogaria fora em sil\u00EAncio \u2014 que \u00E9 a forma\n-- mais cara de errar aqui, porque tudo parece ter funcionado.\nCREATE OR REPLACE FUNCTION migration.upsert_fin_recurring_bill_occurrence(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_bill uuid := (p_row ->> 'recurring_bill_id')::uuid;\n v_invoice uuid := (p_row ->> 'invoice_id')::uuid;\n v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text;\n v_unit uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_bill IS NULL OR p_row ->> 'due_date' IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma ocorr\u00EAncia precisa de id, da conta recorrente e de vencimento');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_financial_recurring_bills\n WHERE id = v_bill AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a conta recorrente %s n\u00E3o existe neste tenant', v_bill));\n END IF;\n\n -- O V1 usa provisioned | consolidated; 'cancelled' existe do lado do V2 para o\n -- vencimento cancelado DEPOIS da virada. Estado desconhecido vira quarentena\n -- com a palavra \u00E0 vista, n\u00E3o um estado aproximado.\n v_status := CASE v_status_v1\n WHEN 'provisioned' THEN 'provisioned'\n WHEN 'consolidated' THEN 'consolidated'\n WHEN 'cancelled' THEN 'cancelled'\n WHEN 'canceled' THEN 'cancelled'\n ELSE NULL END;\n IF v_status IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('estado de vencimento desconhecido: %L', v_status_v1));\n END IF;\n\n -- Um t\u00EDtulo que n\u00E3o existe (ou \u00E9 de outro tenant) n\u00E3o derruba o vencimento: o\n -- vencimento \u00E9 o dado, o v\u00EDnculo \u00E9 o enriquecimento. Perder o v\u00EDnculo \u00E9 ruim;\n -- perder o vencimento por causa dele seria pior.\n IF v_invoice IS NOT NULL AND NOT EXISTS (\n SELECT 1 FROM public.plg_financial_invoices\n WHERE id = v_invoice AND tenant_id = p_tenant) THEN\n v_meta := migration._deep_set(v_meta, ARRAY['v1', 'invoice_unresolved'], to_jsonb(v_invoice));\n v_invoice := NULL;\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_recurring_bill_occurrences WHERE id = p_id) THEN\n INSERT INTO public.plg_financial_recurring_bill_occurrences\n (id, tenant_id, unit_id, recurring_bill_id, seq, competence_date, due_date,\n amount, paid_amount, status, overridden, invoice_id, notes, metadata)\n VALUES (p_id, p_tenant, v_unit, v_bill,\n coalesce((p_row ->> 'seq')::integer, 1),\n coalesce((p_row ->> 'competence_date')::date, (p_row ->> 'due_date')::date),\n (p_row ->> 'due_date')::date,\n coalesce((p_row ->> 'amount')::numeric, 0),\n coalesce((p_row ->> 'paid_amount')::numeric, 0),\n v_status, coalesce((p_row ->> 'overridden')::boolean, false),\n v_invoice, p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_recurring_bill_occurrences SET\n unit_id = v_unit,\n seq = CASE WHEN p_row ? 'seq' THEN coalesce((p_row ->> 'seq')::integer, seq) ELSE seq END,\n competence_date = CASE WHEN p_row ? 'competence_date' THEN coalesce((p_row ->> 'competence_date')::date, competence_date) ELSE competence_date END,\n due_date = (p_row ->> 'due_date')::date,\n amount = CASE WHEN p_row ? 'amount' THEN coalesce((p_row ->> 'amount')::numeric, amount) ELSE amount END,\n paid_amount = CASE WHEN p_row ? 'paid_amount' THEN coalesce((p_row ->> 'paid_amount')::numeric, paid_amount) ELSE paid_amount END,\n status = v_status,\n overridden = CASE WHEN p_row ? 'overridden' THEN coalesce((p_row ->> 'overridden')::boolean, overridden) ELSE overridden END,\n invoice_id = coalesce(v_invoice, invoice_id),\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_recurring_bill_occurrences', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A leitura de contas a pagar, num lugar s\u00F3\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 pergunta que a tela faz \u00E9 \"o que vence, e o que j\u00E1 foi pago?\", e respond\u00EA-la\n-- exige tr\u00EAs jun\u00E7\u00F5es e o conhecimento de que o pagamento mora no movimento, n\u00E3o\n-- no vencimento. Uma view p\u00F5e esse conhecimento no banco em vez de em cada tela\n-- que vier a perguntar.\nCREATE OR REPLACE VIEW public.v_financial_payables AS\nSELECT\n o.tenant_id,\n o.unit_id,\n o.id AS occurrence_id,\n b.id AS recurring_bill_id,\n b.description,\n b.counterparty_person_id,\n p.name AS counterparty_name,\n b.frequency,\n o.seq,\n o.competence_date,\n o.due_date,\n o.amount,\n -- O pagamento vem do movimento do t\u00EDtulo; `o.paid_amount` \u00E9 0 em tudo que veio\n -- do V1, e somar os dois contaria duas vezes o dia em que o V2 come\u00E7ar a\n -- escrever no vencimento.\n coalesce(o.paid_amount, 0) + coalesce(m.pago, 0) AS paid_amount,\n o.amount - (coalesce(o.paid_amount, 0) + coalesce(m.pago, 0)) AS open_amount,\n o.status,\n o.overridden,\n o.invoice_id,\n CASE\n WHEN o.status = 'cancelled' THEN 'cancelado'\n WHEN o.amount <= coalesce(o.paid_amount, 0) + coalesce(m.pago, 0) THEN 'pago'\n WHEN coalesce(m.pago, 0) > 0 THEN 'parcial'\n WHEN o.due_date < current_date THEN 'vencido'\n ELSE 'em aberto'\n END AS situacao\nFROM public.plg_financial_recurring_bill_occurrences o\nJOIN public.plg_financial_recurring_bills b ON b.id = o.recurring_bill_id\nLEFT JOIN public.people p ON p.id = b.counterparty_person_id\nLEFT JOIN LATERAL (\n SELECT sum(mv.paid_amount) AS pago\n FROM public.plg_financial_movements mv\n WHERE mv.financial_invoice_id = o.invoice_id\n) m ON o.invoice_id IS NOT NULL;\n\nCOMMENT ON VIEW public.v_financial_payables IS\n 'Contas a pagar por vencimento, com o pago somado do movimento do t\u00EDtulo. '\n 'No V1 recurring_bill_occurrences.paid_value \u00E9 sempre 0: o pagamento mora no '\n 'movimento, e sem esta jun\u00E7\u00E3o todo vencimento consolidado parece em aberto.';\n\nGRANT SELECT ON public.v_financial_payables TO authenticated, service_role;\n";
35
+ export declare const MIGRATION_034_A_UNIDADE_TEM_CONTA_E_A_PESSOA_TEM_UMA_SO = "-- A unidade tem conta, e a pessoa tem uma s\u00F3.\n--\n-- Duas decis\u00F5es sobre o raz\u00E3o financeiro, tomadas depois de medir o que cada uma\n-- custava. As duas estavam registradas como \"adiar\" com uma estimativa de custo,\n-- e a estimativa veio do cliente errado.\n--\n-- \u2500\u2500 1. `account_type = 'unit'` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 plano de extra\u00E7\u00E3o do V1 traduz o vocabul\u00E1rio de conta do V1 para as oito\n-- esp\u00E9cies do V2, e deixava `unit` passar sem tradu\u00E7\u00E3o de prop\u00F3sito, com este\n-- coment\u00E1rio:\n--\n-- \"conta da unidade n\u00E3o \u00E9 pessoa, nem banco, nem caixa. Encaixar em 'system'\n-- seria escolher a sem\u00E2ntica no lugar de quem \u00E9 dono do contrato\n-- financeiro. Custa 6 movimentos na onda 1.\"\n--\n-- A recusa em escolher estava certa. A estimativa n\u00E3o: os 6 movimentos s\u00E3o do\n-- Espa\u00E7o Facial, uma rede de est\u00E9tica onde o dinheiro entra pela venda. Num\n-- restaurante o caixa passa pela conta da pr\u00F3pria unidade, e no Pertinho do C\u00E9u\n-- (licen\u00E7a 8) DUAS contas travam **1.359 movimentos** \u2014 30% do raz\u00E3o:\n--\n-- account_central #195 \"Pertinho do C\u00E9u\" \u2192 777 movimentos\n-- account_central #654100 \"PC Servi\u00E7os\" \u2192 582 movimentos\n--\n-- E n\u00E3o \u00E9 s\u00F3 a contagem: sem elas, `v_financial_payables` mostra 833 vencimentos\n-- em aberto num cliente que pagou R$ 311.006. O n\u00FAmero certo some junto com a\n-- conta.\n--\n-- A escolha foi **criar a esp\u00E9cie**, n\u00E3o encaixar em `system`. `system` j\u00E1 tem\n-- dono sem\u00E2ntico (`CHECK (kind <> 'system' OR code IS NOT NULL)` \u2014 conta de\n-- plano de contas, identificada por c\u00F3digo) e jogar a conta da casa l\u00E1 dentro\n-- faria os dois conceitos compartilharem um balde que nenhum relat\u00F3rio consegue\n-- separar depois.\n--\n-- \u2500\u2500 2. Uma conta por pessoa \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_accounts_person_uq` diz que uma pessoa tem UMA conta no tenant.\n-- O V1 permite v\u00E1rias (48 casos no Pertinho do C\u00E9u), e cada colis\u00E3o derrubava a\n-- conta E todos os movimentos que a referenciavam.\n--\n-- A constraint FICA. A decis\u00E3o foi **fundir**: da segunda conta em diante, o\n-- writer devolve o id da conta que j\u00E1 existe, o ledger amarra a linha do V1 a\n-- ela, e todo movimento daquela conta passa a apontar para a conta \u00FAnica da\n-- pessoa. O saldo da pessoa fica certo; o que se perde \u00E9 a separa\u00E7\u00E3o que o V1\n-- fazia entre as contas dela, e isso fica escrito em\n-- `metadata.migration.merged_from`.\n--\n-- Fundir \u00E9 o mesmo mecanismo que `upsert_person` j\u00E1 usa para pessoa duplicada, e\n-- \u00E9 revers\u00EDvel pela leitura do ledger: `source_numeric_id` distintos apontando\n-- para o mesmo `target_id` \u00E9 a marca da fus\u00E3o.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. A esp\u00E9cie nova\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nDO $$\nBEGIN\n -- Replay-safe: once the CHECK knows `unit`, leave it alone. 037 extends the\n -- same list with `unidentified`, and rewriting it from this snapshot on a\n -- replay fails on every account 037's kind already holds.\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = 'plg_financial_accounts_kind_check'\n AND strpos(pg_get_constraintdef(oid), '''unit''') > 0) THEN\n ALTER TABLE public.plg_financial_accounts DROP CONSTRAINT IF EXISTS plg_financial_accounts_kind_check;\n ALTER TABLE public.plg_financial_accounts ADD CONSTRAINT plg_financial_accounts_kind_check\n CHECK (kind = ANY (ARRAY['bank', 'cash_physical', 'cash_session',\n 'card_receivable_credit', 'card_receivable_debit',\n 'card_payable', 'person', 'system', 'unit']));\n END IF;\n\n -- Uma conta de unidade sem unidade seria a conta de ningu\u00E9m. O par esp\u00E9cie \u2194\n -- coluna obrigat\u00F3ria \u00E9 a mesma disciplina que `person`/`person_id` j\u00E1 tem.\n ALTER TABLE public.plg_financial_accounts DROP CONSTRAINT IF EXISTS plg_financial_accounts_unit_check;\n ALTER TABLE public.plg_financial_accounts ADD CONSTRAINT plg_financial_accounts_unit_check\n CHECK (kind <> 'unit' OR unit_id IS NOT NULL);\nEND $$;\n\nCOMMENT ON CONSTRAINT plg_financial_accounts_unit_check ON public.plg_financial_accounts IS\n 'A conta da pr\u00F3pria unidade (a conta da casa) nomeia a unidade a que pertence. '\n 'Sem isso ela seria indistingu\u00EDvel de uma conta de sistema sem dono.';\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O writer\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_account(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind text := coalesce(p_row ->> 'kind', 'bank');\n v_person uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_merge_into uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'this row needs an id and a name');\n END IF;\n IF v_kind NOT IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit',\n 'card_receivable_debit', 'card_payable', 'person', 'system', 'unit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown account kind %L \u2014 V1 account_type does not map onto the ledger''s nine', v_kind));\n END IF;\n\n -- A pessoa da conta polim\u00F3rfica. O ledger \u00E9 a \u00FAnica fonte que sabe o de-para,\n -- e ele guarda a tabela de origem \u2014 ent\u00E3o `clients`, `contacts` e\n -- `professionals` resolvem pelo mesmo caminho, sem o writer conhecer nenhuma.\n IF v_kind = 'person' THEN\n SELECT l.target_id INTO v_person\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = (v_meta #>> '{v1,reference_table}')\n AND l.source_numeric_id = nullif(v_meta #>> '{v1,reference_id}', '')::bigint\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n -- Recusar \u00E9 o certo, e \u00E9 tempor\u00E1rio: a pessoa entra na mesma onda, e a\n -- segunda passada oferece a conta de novo. Inventar uma pessoa aqui seria\n -- pendurar dinheiro em quem n\u00E3o existe.\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de pessoa cuja pessoa ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,reference_table}', '<sem tabela>'),\n coalesce(v_meta #>> '{v1,reference_id}', '<sem id>')));\n END IF;\n\n -- FUS\u00C3O. O V2 admite uma conta por pessoa e o V1 admite v\u00E1rias; da segunda\n -- em diante a conta do V1 passa a APONTAR para a que j\u00E1 existe, em vez de\n -- derrubar a si mesma e a todo movimento que a referencia.\n SELECT id INTO v_merge_into\n FROM public.plg_financial_accounts\n WHERE tenant_id = p_tenant AND person_id = v_person AND id <> p_id\n LIMIT 1;\n END IF;\n\n -- A unidade da conta da casa, pelo mesmo caminho da pessoa: o ledger sabe qual\n -- `app.units` a `companies` do V1 virou.\n IF v_kind = 'unit' AND v_unit IS NULL THEN\n SELECT l.target_id INTO v_unit\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = coalesce(v_meta #>> '{v1,reference_table}', 'companies')\n AND l.source_numeric_id = nullif(v_meta #>> '{v1,reference_id}', '')::bigint\n AND l.target_table = 'app.units'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de unidade cuja unidade ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,reference_table}', 'companies'),\n coalesce(v_meta #>> '{v1,reference_id}', '<sem id>')));\n END IF;\n END IF;\n\n IF v_merge_into IS NOT NULL THEN\n -- Enriquece a conta que fica e devolve O ID DELA. O motor grava esse\n -- target_id no ledger, e a partir da\u00ED todo movimento que referenciava a\n -- conta do V1 resolve para a conta \u00FAnica da pessoa no V2.\n UPDATE public.plg_financial_accounts SET\n is_active = is_active OR coalesce((p_row ->> 'is_active')::boolean, true),\n metadata = migration._merge_meta(\n metadata,\n migration._deep_set(v_meta, ARRAY['migration', 'merged_from'],\n coalesce(metadata #> '{migration,merged_from}', '[]'::jsonb)\n || jsonb_build_array(jsonb_build_object(\n 'v1_account', v_meta #> '{v1,reference_id}',\n 'name', p_row ->> 'name', 'account_id', p_id))))\n WHERE id = v_merge_into;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', v_merge_into, jsonb_build_object('reason', 'merged')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_merge_into, 'merged', true,\n 'reason', 'conta fundida na conta \u00FAnica da pessoa (o V2 admite uma por pessoa)');\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_financial_accounts WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_financial_accounts (id, tenant_id, unit_id, kind, code, name, bank_name, settlement_days, is_active, person_id, metadata)\n VALUES (p_id, p_tenant, v_unit, v_kind,\n p_row ->> 'code',\n p_row ->> 'name',\n p_row ->> 'bank_name',\n (p_row ->> 'settlement_days')::integer,\n coalesce((p_row ->> 'is_active')::boolean, true),\n v_person,\n v_meta);\n v_created := true;\n ELSE\n -- kind e person_id andam JUNTOS: a constraint amarra os dois, ent\u00E3o mudar\n -- um sem o outro derruba a linha que j\u00E1 estava l\u00E1.\n UPDATE public.plg_financial_accounts SET\n unit_id = coalesce(v_unit, unit_id),\n kind = CASE WHEN p_row ? 'kind' THEN v_kind ELSE kind END,\n person_id = CASE WHEN p_row ? 'kind' THEN v_person ELSE person_id END,\n code = CASE WHEN p_row ? 'code' THEN p_row ->> 'code' ELSE code END,\n name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,\n bank_name = CASE WHEN p_row ? 'bank_name' THEN p_row ->> 'bank_name' ELSE bank_name END,\n settlement_days = CASE WHEN p_row ? 'settlement_days' THEN (p_row ->> 'settlement_days')::integer ELSE settlement_days END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n";
36
+ export declare const MIGRATION_035_A_CLASSIFICACAO_CONTABIL_CHEGA_INTEIRA = "-- A classifica\u00E7\u00E3o cont\u00E1bil chega inteira.\n--\n-- Sem esta migration n\u00E3o existe DRE no V2 \u2014 e o motivo n\u00E3o \u00E9 a tela. O DRE do V1\n-- \u00E9 montado sobre DOIS eixos, e os dois se perdiam no caminho:\n--\n-- 1. a NATUREZA da conta (receita \u00D7 despesa)\n-- 2. a CONTA CONT\u00C1BIL DO ITEM (qual linha do DRE aquele lan\u00E7amento \u00E9)\n--\n-- \u2500\u2500 Eixo 1: a natureza estava na coluna errada \u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 de-para mapeava `chart_of_accounts.account_type \u2192 node_type`. S\u00E3o coisas\n-- diferentes: no V1 `account_type` \u00E9 a NATUREZA (asset|liability|equity|revenue|\n-- expense); no V2 `node_type` \u00E9 a FORMA DO N\u00D3 (group|leaf) e `account_type` \u00E9 a\n-- natureza. O resultado, medido no cluster em 18/09/2026:\n--\n-- node_type = 'expense' \u2192 302 linhas account_type = NULL\n-- node_type = 'revenue' \u2192 38 linhas account_type = NULL\n-- node_type = 'leaf' \u2192 244 linhas account_type preenchido\n--\n-- As 244 corretas s\u00E3o as que algu\u00E9m criou pelo app; as 340 erradas s\u00E3o as que o\n-- porte escreveu. E `upsert_fin_chart_account` **nunca gravou `account_type`** \u2014\n-- a coluna n\u00E3o aparece no INSERT nem no UPDATE dele.\n--\n-- Um DRE sem natureza n\u00E3o separa receita de despesa. N\u00E3o erra por pouco: n\u00E3o\n-- monta.\n--\n-- \u2500\u2500 Eixo 2: a conta do item virava texto opaco \u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `invoice_items.account_id \u2192 jsonb:financial.v1_account_ref`. A classifica\u00E7\u00E3o\n-- cont\u00E1bil, que no V1 \u00E9 do ITEM e n\u00E3o do movimento, chegava ao V2 como o id\n-- num\u00E9rico cru do V1 dentro de `metadata` \u2014 sem FK, sem join poss\u00EDvel. E n\u00E3o\n-- havia para onde resolver: `public.items` n\u00E3o tinha coluna de conta cont\u00E1bil.\n--\n-- (`plg_financial_invoice_items.chart_account_id` existe e tem ZERO linhas: o\n-- item do V1 cai s\u00F3 em `public.items`; n\u00E3o h\u00E1 split financeiro para ele.)\n--\n-- \u2500\u2500 O terceiro eixo N\u00C3O precisou de nada \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 dire\u00E7\u00E3o (receita \u00D7 despesa do documento) parecia perdida tamb\u00E9m \u2014\n-- `invoices.type \u2192 jsonb:financial.v1_type`. N\u00E3o est\u00E1: o writer `order` devolve\n-- em `extra` o T\u00CDTULO em `plg_financial_invoices`, e l\u00E1 `direction` j\u00E1 chega\n-- certa. Medido: 8.175 `receivable` + 2.503 `payable`. O DRE l\u00EA o t\u00EDtulo, n\u00E3o o\n-- pedido, e o eixo j\u00E1 existe.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. O item ganha onde guardar a classifica\u00E7\u00E3o\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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.items\n ADD COLUMN IF NOT EXISTS chart_account_id uuid REFERENCES public.plg_financial_chart_of_accounts(id),\n ADD COLUMN IF NOT EXISTS cost_center_id uuid REFERENCES public.plg_financial_cost_centers(id);\n\nCOMMENT ON COLUMN public.items.chart_account_id IS\n 'Conta cont\u00E1bil DESTE item. A classifica\u00E7\u00E3o \u00E9 da linha, n\u00E3o do documento: no '\n 'V1 dois itens da mesma venda podem cair em ramos diferentes do DRE, e \u00E9 essa '\n 'granularidade que o relat\u00F3rio precisa.';\nCOMMENT ON COLUMN public.items.cost_center_id IS\n 'Centro de custo do item. O DRE n\u00E3o usa \u2014 \u00E9 a segunda dimens\u00E3o, para an\u00E1lise '\n 'de custo \u2014 mas perder o dado na entrada seria irrevers\u00EDvel.';\n\n-- A pergunta do DRE \u00E9 \"some os itens por conta, no per\u00EDodo\". Sem \u00EDndice ela\n-- varre `items` inteira por linha do relat\u00F3rio.\nCREATE INDEX IF NOT EXISTS items_chart_account_idx\n ON public.items (tenant_id, chart_account_id) WHERE chart_account_id IS NOT NULL;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. Conserto do dado j\u00E1 migrado\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Devolve a natureza para a coluna certa nas linhas que o porte escreveu. O\n-- teste \u00E9 o pr\u00F3prio vocabul\u00E1rio: `node_type` s\u00F3 pode ser group|leaf|account, e\n-- qualquer outra coisa ali \u00E9 natureza que foi parar no lugar errado.\nUPDATE public.plg_financial_chart_of_accounts\n SET account_type = node_type,\n node_type = 'leaf'\n WHERE node_type IN ('asset', 'liability', 'equity', 'revenue', 'expense')\n AND account_type IS NULL;\n\n-- `node_type` de verdade: \u00E9 grupo quem tem filho. O V1 n\u00E3o tem essa coluna, e\n-- deriv\u00E1-la da pr\u00F3pria \u00E1rvore \u00E9 mais honesto que herdar um default.\nUPDATE public.plg_financial_chart_of_accounts p\n SET node_type = 'group'\n WHERE node_type <> 'group'\n AND EXISTS (SELECT 1 FROM public.plg_financial_chart_of_accounts c WHERE c.parent_id = p.id);\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 3. Os writers\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_chart_account(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_type text := nullif(btrim(lower(coalesce(p_row ->> 'account_type', ''))), '');\n v_parent uuid := (p_row ->> 'parent_id')::uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'this row needs an id and a name');\n END IF;\n\n -- Sem corre\u00E7\u00E3o silenciosa: uma natureza fora do vocabul\u00E1rio do raz\u00E3o faria a\n -- conta sumir do DRE sem aparecer em lugar nenhum \u2014 nem na receita, nem na\n -- despesa, nem num aviso.\n IF v_type IS NOT NULL AND v_type NOT IN ('asset', 'liability', 'equity', 'revenue', 'expense') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('natureza de conta desconhecida: %L (o raz\u00E3o conhece asset, liability, equity, revenue e expense)', v_type));\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_financial_chart_of_accounts WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_financial_chart_of_accounts\n (id, tenant_id, code, name, node_type, account_type, parent_id, is_active)\n VALUES (p_id, p_tenant, p_row ->> 'code', p_row ->> 'name',\n -- Nasce folha; vira grupo abaixo, quando o primeiro filho chegar.\n coalesce(p_row ->> 'node_type', 'leaf'),\n v_type, v_parent,\n coalesce((p_row ->> 'is_active')::boolean, true));\n v_created := true;\n ELSE\n UPDATE public.plg_financial_chart_of_accounts SET\n code = CASE WHEN p_row ? 'code' THEN p_row ->> 'code' ELSE code END,\n name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,\n node_type = CASE WHEN p_row ? 'node_type' THEN coalesce(p_row ->> 'node_type', node_type) ELSE node_type END,\n account_type = coalesce(v_type, account_type),\n parent_id = CASE WHEN p_row ? 'parent_id' THEN v_parent ELSE parent_id END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END\n WHERE id = p_id;\n END IF;\n\n -- Quem tem filho \u00E9 grupo. Marcar aqui, e n\u00E3o num passe no fim, faz a \u00E1rvore\n -- ficar correta mesmo se a onda parar no meio.\n IF v_parent IS NOT NULL THEN\n UPDATE public.plg_financial_chart_of_accounts\n SET node_type = 'group'\n WHERE id = v_parent AND tenant_id = p_tenant AND node_type <> 'group';\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_chart_of_accounts', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- O writer do item passa a gravar as duas colunas novas. O resto do corpo \u00E9 o\n-- que j\u00E1 estava l\u00E1 \u2014 est\u00E1 reemitido inteiro porque \u00E9 assim que se troca uma\n-- fun\u00E7\u00E3o no Postgres, n\u00E3o porque mudou.\nCREATE OR REPLACE FUNCTION migration.upsert_order_item(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_order uuid := (p_row ->> 'order_id')::uuid;\n v_chart uuid := (p_row ->> 'chart_account_id')::uuid;\n v_center uuid := (p_row ->> 'cost_center_id')::uuid;\n v_order_tenant uuid;\n v_existing uuid;\n v_cancelled timestamptz;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_order IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an order line needs an id and an order');\n END IF;\n SELECT tenant_id INTO v_order_tenant FROM public.orders WHERE id = v_order;\n IF v_order_tenant IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', format('order %s is not in the destination yet', v_order));\n END IF;\n IF v_order_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order %s belongs to another tenant', v_order));\n END IF;\n\n v_cancelled := nullif(v_meta #>> '{cancellation,at}', '')::timestamptz;\n\n SELECT tenant_id INTO v_existing FROM public.items WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order line %s already exists in another tenant', p_id));\n END IF;\n IF v_existing IS NULL THEN\n -- `order_id` e S\u00D3 ele: a fatura alcan\u00E7a a linha pelo pedido (items_one_parent).\n INSERT INTO public.items (id, tenant_id, order_id, product_id, name, description,\n quantity, unit_price, discount, total, sort_order,\n cancelled_at, cancel_reason, chart_account_id, cost_center_id, metadata)\n VALUES (p_id, p_tenant, v_order, (p_row ->> 'product_id')::uuid,\n coalesce(nullif(btrim(coalesce(p_row ->> 'name', '')), ''), 'Item'),\n coalesce(p_row ->> 'description', nullif(btrim(coalesce(p_row ->> 'name', '')), '')),\n greatest(coalesce((p_row ->> 'quantity')::numeric, 1), 0.0001),\n round(coalesce((p_row ->> 'unit_price')::numeric, 0), 2),\n round(coalesce((p_row ->> 'discount')::numeric, 0), 2),\n round(coalesce((p_row ->> 'total')::numeric, 0), 2),\n coalesce((p_row ->> 'sort_order')::integer, 0),\n v_cancelled, v_meta #>> '{cancellation,reason}',\n v_chart, v_center, v_meta);\n v_created := true;\n ELSE\n UPDATE public.items SET\n order_id = v_order,\n product_id = CASE WHEN p_row ? 'product_id' THEN (p_row ->> 'product_id')::uuid ELSE product_id END,\n name = CASE WHEN p_row ? 'name' THEN coalesce(nullif(btrim(p_row ->> 'name'), ''), name) ELSE name END,\n quantity = CASE WHEN p_row ? 'quantity' THEN greatest(coalesce((p_row ->> 'quantity')::numeric, 1), 0.0001) ELSE quantity END,\n unit_price = CASE WHEN p_row ? 'unit_price' THEN round(coalesce((p_row ->> 'unit_price')::numeric, 0), 2) ELSE unit_price END,\n discount = CASE WHEN p_row ? 'discount' THEN round(coalesce((p_row ->> 'discount')::numeric, 0), 2) ELSE discount END,\n total = CASE WHEN p_row ? 'total' THEN round(coalesce((p_row ->> 'total')::numeric, 0), 2) ELSE total END,\n cancelled_at = coalesce(v_cancelled, cancelled_at),\n cancel_reason = coalesce(v_meta #>> '{cancellation,reason}', cancel_reason),\n -- `coalesce` e n\u00E3o CASE: a classifica\u00E7\u00E3o \u00E9 enriquecimento. Uma reoferta\n -- sem a coluna n\u00E3o deve apagar a conta que j\u00E1 est\u00E1 l\u00E1.\n chart_account_id = coalesce(v_chart, chart_account_id),\n cost_center_id = coalesce(v_center, cost_center_id),\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.items', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 4. O de-para\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nSET LOCAL migration.allowlist_unlock = 'on';\n\n-- `account_type` para `account_type`. O mapa anterior mandava a natureza para\n-- `node_type`, e \u00E9 a corre\u00E7\u00E3o que faz o DRE existir.\nSELECT migration._allow(\n 'chart_of_accounts', 'public.plg_financial_chart_of_accounts', 'financial',\n 'fin_chart_account', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'code', 'code',\n 'name', 'name',\n 'account_type', 'account_type',\n 'parent_id', 'parent_id@chart_of_accounts',\n 'active', 'is_active'\n ),\n 'Plano de contas. `account_type` \u00E9 a NATUREZA (o eixo do DRE); `node_type` \u00E9 '\n 'derivado da \u00E1rvore pelo writer \u2014 quem tem filho \u00E9 grupo.'\n);\n\n-- O item passa a resolver a conta cont\u00E1bil e o centro de custo em vez de\n-- carreg\u00E1-los como id cru do V1 em metadata.\nSELECT migration._allow(\n 'invoice_items', 'public.items', 'financial',\n 'order_item', '{}'::jsonb, ARRAY['unit_price', 'total_amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'invoice_id', 'order_id@invoices:public.orders',\n 'description', 'name',\n 'quantity', 'quantity',\n 'unit_price', 'unit_price',\n 'discount', 'discount',\n 'total_amount', 'total',\n 'additional', 'jsonb:financial.additional',\n 'product_id', 'product_id@products',\n 'service_id', 'product_id@services',\n 'account_id', 'chart_account_id@chart_of_accounts',\n 'cost_center_id', 'cost_center_id@cost_centers',\n 'item_type', 'jsonb:item_type',\n 'canceled_at', 'jsonb:cancellation.at',\n 'cancel_reason', 'jsonb:cancellation.reason',\n 'appointment_id', 'jsonb:agenda.v1_appointment_ref',\n 'execution_date', 'jsonb:execution.date',\n 'execution_start_time', 'jsonb:execution.start_time',\n 'execution_end_time', 'jsonb:execution.end_time',\n 'execution_photos', '-',\n 'executed_by_professional_id', 'jsonb:execution.v1_professional_ref',\n 'is_executed', 'jsonb:execution.is_executed',\n 'gift_card_id', 'jsonb:v1_gift_card_ref',\n 'service_package_id', 'jsonb:v1_package_ref'\n ),\n 'A linha da venda. `account_id` \u00E9 a classifica\u00E7\u00E3o cont\u00E1bil DO ITEM \u2014 o eixo '\n 'que o DRE agrupa; antes ela vinha como id cru do V1 em metadata.'\n);\n";
37
+ export declare const MIGRATION_036_A_FUSAO_PARA_DE_DOBRAR_A_PROPRIA_MEMORIA = "-- A fus\u00E3o para de dobrar a pr\u00F3pria mem\u00F3ria.\n--\n-- Corre\u00E7\u00E3o de um defeito que eu introduzi na 048 e que derrubou o cluster quatro\n-- vezes em 18/09/2026.\n--\n-- \u2500\u2500 O que eu escrevi \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Na fus\u00E3o de conta de pessoa, para registrar de onde a conta veio:\n--\n-- metadata = migration._merge_meta(\n-- metadata,\n-- migration._deep_set(v_meta, ARRAY['migration','merged_from'],\n-- coalesce(metadata #> '{migration,merged_from}', '[]'::jsonb)\n-- || jsonb_build_array(...)))\n--\n-- Leio o array que j\u00E1 est\u00E1 na linha, acrescento um elemento \u2014 e entrego o\n-- resultado a `_merge_meta`, que **concatena com o array que j\u00E1 est\u00E1 na linha de\n-- novo**. Cada oferta n\u00E3o acrescenta um: ela DOBRA.\n--\n-- oferta 1 \u2192 1 elemento\n-- oferta 5 \u2192 16\n-- oferta 10 \u2192 512\n-- oferta 18 \u2192 262.143 \u2190 medido\n--\n-- \u2500\u2500 O que isso custou \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Medido antes deste conserto, no tenant Pertinho do C\u00E9u:\n--\n-- maior merged_from 262.143 elementos (2\u00B9\u2078 \u2212 1)\n-- maior metadata 1.378 kB numa \u00FAnica linha\n--\n-- Montar e mesclar um jsonb desse tamanho, uma vez por linha, dentro de um\n-- statement, num cluster com `shared_buffers` de 224 MB, mata o backend. E o\n-- sintoma n\u00E3o apontava para c\u00E1: a conex\u00E3o ca\u00EDa no meio de `account_central`, o\n-- transporte reportava \"server closed the connection unexpectedly\", e eu passei\n-- duas rodadas reduzindo o tamanho do lote \u2014 de 2.000 para 250, depois para 100.\n-- Nenhuma ajudou, porque 417 linhas nunca foram o problema.\n--\n-- \u2500\u2500 O conserto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `merged_from` sai. N\u00E3o vira \"append idempotente\", n\u00E3o vira \"array com limite\":\n-- sai inteiro, porque ele nunca deveria ter existido \u2014 **o ledger j\u00E1 registra a\n-- fus\u00E3o**, e melhor: dois `source_numeric_id` distintos apontando para o mesmo\n-- `target_id` \u00C9 a defini\u00E7\u00E3o de fus\u00E3o, \u00E9 consult\u00E1vel, e \u00E9 o lugar onde o resto\n-- deste schema j\u00E1 procura essa resposta.\n--\n-- select source_numeric_id, target_id\n-- from migration.ledger\n-- where source_table = 'account_central' and target_table = 'public.plg_financial_accounts'\n-- order by target_id;\n--\n-- Guardar a mesma informa\u00E7\u00E3o num jsonb ao lado era a segunda c\u00F3pia da regra \u2014\n-- exatamente o que o cabe\u00E7alho do `sdkmig.py` diz para n\u00E3o fazer. A segunda\n-- c\u00F3pia divergiu no primeiro dia, e de forma exponencial.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. Desarmar o que j\u00E1 explodiu\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Primeiro o dado, depois a fun\u00E7\u00E3o: enquanto as linhas gordas estiverem l\u00E1,\n-- qualquer rodada que as toque derruba o banco de novo \u2014 inclusive a que\n-- aplicaria esta migration mais adiante.\nUPDATE public.plg_financial_accounts\n SET metadata = (coalesce(metadata, '{}'::jsonb) #- '{migration,merged_from}')\n WHERE metadata #> '{migration,merged_from}' IS NOT NULL;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O writer para de acumular\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_account(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind text := coalesce(p_row ->> 'kind', 'bank');\n v_person uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_merge_into uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'this row needs an id and a name');\n END IF;\n IF v_kind NOT IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit',\n 'card_receivable_debit', 'card_payable', 'person', 'system', 'unit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown account kind %L \u2014 V1 account_type does not map onto the ledger''s nine', v_kind));\n END IF;\n\n IF v_kind = 'person' THEN\n SELECT l.target_id INTO v_person\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = (v_meta #>> '{v1,reference_table}')\n AND l.source_numeric_id = nullif(v_meta #>> '{v1,reference_id}', '')::bigint\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de pessoa cuja pessoa ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,reference_table}', '<sem tabela>'),\n coalesce(v_meta #>> '{v1,reference_id}', '<sem id>')));\n END IF;\n\n SELECT id INTO v_merge_into\n FROM public.plg_financial_accounts\n WHERE tenant_id = p_tenant AND person_id = v_person AND id <> p_id\n LIMIT 1;\n END IF;\n\n IF v_kind = 'unit' AND v_unit IS NULL THEN\n SELECT l.target_id INTO v_unit\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = coalesce(v_meta #>> '{v1,reference_table}', 'companies')\n AND l.source_numeric_id = nullif(v_meta #>> '{v1,reference_id}', '')::bigint\n AND l.target_table = 'app.units'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de unidade cuja unidade ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,reference_table}', 'companies'),\n coalesce(v_meta #>> '{v1,reference_id}', '<sem id>')));\n END IF;\n END IF;\n\n IF v_merge_into IS NOT NULL THEN\n -- A fus\u00E3o N\u00C3O escreve hist\u00F3rico aqui. Quem registra que duas contas do V1\n -- viraram uma no V2 \u00E9 o ledger: dois `source_numeric_id` apontando para o\n -- mesmo `target_id`. Acumular a mesma informa\u00E7\u00E3o num array de metadata foi o\n -- defeito que dobrava de tamanho a cada oferta e derrubou o cluster.\n UPDATE public.plg_financial_accounts\n SET is_active = is_active OR coalesce((p_row ->> 'is_active')::boolean, true)\n WHERE id = v_merge_into;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', v_merge_into, jsonb_build_object('reason', 'merged')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_merge_into, 'merged', true,\n 'reason', 'conta fundida na conta \u00FAnica da pessoa (o V2 admite uma por pessoa)');\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_financial_accounts WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_financial_accounts (id, tenant_id, unit_id, kind, code, name, bank_name, settlement_days, is_active, person_id, metadata)\n VALUES (p_id, p_tenant, v_unit, v_kind,\n p_row ->> 'code', p_row ->> 'name', p_row ->> 'bank_name',\n (p_row ->> 'settlement_days')::integer,\n coalesce((p_row ->> 'is_active')::boolean, true), v_person, v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_accounts SET\n unit_id = coalesce(v_unit, unit_id),\n kind = CASE WHEN p_row ? 'kind' THEN v_kind ELSE kind END,\n person_id = CASE WHEN p_row ? 'kind' THEN v_person ELSE person_id END,\n code = CASE WHEN p_row ? 'code' THEN p_row ->> 'code' ELSE code END,\n name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,\n bank_name = CASE WHEN p_row ? 'bank_name' THEN p_row ->> 'bank_name' ELSE bank_name END,\n settlement_days = CASE WHEN p_row ? 'settlement_days' THEN (p_row ->> 'settlement_days')::integer ELSE settlement_days END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n";
38
+ export declare const MIGRATION_037_A_VENDA_DE_BALCAO_TEM_CONTA = "-- A venda de balc\u00E3o tem conta.\n--\n-- Terceira esp\u00E9cie de conta que o V1 tem e o raz\u00E3o do V2 n\u00E3o tinha \u2014 depois de\n-- `unit` (a conta da casa, migration 048) vem `unidentified`: a conta da venda\n-- para quem n\u00E3o deixou cadastro.\n--\n-- No V1 ela \u00E9 uma linha de `account_central` com `account_type = 'client'` e\n-- `reference_id = 0`. O tipo diz \"pessoa\"; a refer\u00EAncia \u00E9 o sentinela de \"n\u00E3o\n-- h\u00E1 pessoa\". O V2 n\u00E3o aceita a combina\u00E7\u00E3o, e com raz\u00E3o:\n--\n-- CHECK ((kind = 'person') = (person_id IS NOT NULL))\n--\n-- Uma conta de pessoa sem pessoa \u00E9 dinheiro pendurado em ningu\u00E9m. O CHECK est\u00E1\n-- certo; o que faltava era a palavra para o caso em que **n\u00E3o h\u00E1 contraparte e\n-- isso \u00E9 o fato**, n\u00E3o uma falha de cadastro.\n--\n-- \u2500\u2500 Por que esp\u00E9cie pr\u00F3pria, e n\u00E3o `system` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `system` j\u00E1 tem dono: \u00E9 a conta do plano de contas, identificada por c\u00F3digo\n-- (`CHECK (kind <> 'system' OR code IS NOT NULL)`). Jogar a venda de balc\u00E3o l\u00E1\n-- dentro faria dois conceitos dividirem um balde que nenhum relat\u00F3rio separa\n-- depois \u2014 e o de balc\u00E3o n\u00E3o \u00E9 detalhe: num restaurante ele \u00E9 a maior parte do\n-- movimento.\n--\n-- `unidentified` diz a verdade: existe contraparte, ela comprou, e ela n\u00E3o se\n-- identificou. \u00C9 diferente de `system` (n\u00E3o h\u00E1 contraparte) e de `person` (h\u00E1, e\n-- sabemos quem).\n--\n-- \u2500\u2500 O que estava preso \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Medido em 18/09/2026 no tenant Pertinho do C\u00E9u, antes deste arquivo:\n--\n-- account_central em quarentena por `clients #0` 5\n-- financial_movements travados atr\u00E1s delas 110\n-- valor R$ 15.102,78\n--\n-- A asser\u00E7\u00E3o de paridade (`make parity`) apontava exatamente isso: contas a\n-- pagar com \u0394 \u221210.218,30 contra a origem, e a cadeia inteira levava a uma conta.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. A esp\u00E9cie\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nDO $$\nBEGIN\n ALTER TABLE public.plg_financial_accounts DROP CONSTRAINT IF EXISTS plg_financial_accounts_kind_check;\n ALTER TABLE public.plg_financial_accounts ADD CONSTRAINT plg_financial_accounts_kind_check\n CHECK (kind = ANY (ARRAY['bank', 'cash_physical', 'cash_session',\n 'card_receivable_credit', 'card_receivable_debit',\n 'card_payable', 'person', 'system', 'unit',\n 'unidentified']));\nEND $$;\n\nCOMMENT ON COLUMN public.plg_financial_accounts.kind IS\n 'bank | cash_physical | cash_session | card_receivable_credit | '\n 'card_receivable_debit | card_payable | person | system | unit | unidentified. '\n '`unidentified` \u00E9 a contraparte que comprou e n\u00E3o se identificou (venda de '\n 'balc\u00E3o): diferente de `system`, onde n\u00E3o h\u00E1 contraparte, e de `person`, onde '\n 'h\u00E1 e sabemos quem.';\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O writer reconhece o sentinela\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 a perna que muda: quando a conta \u00E9 de pessoa e a refer\u00EAncia da origem \u00E9 o\n-- sentinela (`reference_id = 0`) ou aponta para linha que n\u00E3o existe mais, ela\n-- deixa de ser `person` e passa a ser `unidentified` \u2014 em vez de recusar a conta\n-- e, com ela, todo movimento que a cita.\n--\n-- A distin\u00E7\u00E3o importa e est\u00E1 no c\u00F3digo: refer\u00EAncia AUSENTE (0) vira\n-- `unidentified`; refer\u00EAncia PRESENTE cuja pessoa ainda n\u00E3o migrou continua\n-- caindo em quarentena de `fk`, porque a\u00ED a pessoa existe e a ordem \u00E9 que est\u00E1\n-- errada \u2014 e a segunda passada resolve.\nCREATE OR REPLACE FUNCTION migration.upsert_fin_account(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind text := coalesce(p_row ->> 'kind', 'bank');\n v_ref_id bigint := nullif(v_meta #>> '{v1,reference_id}', '')::bigint;\n v_ref_table text := v_meta #>> '{v1,reference_table}';\n v_person uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_merge_into uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'this row needs an id and a name');\n END IF;\n\n -- O sentinela ANTES da checagem de vocabul\u00E1rio: `clients#0` n\u00E3o \u00E9 uma conta de\n -- pessoa mal formada, \u00E9 uma conta sem contraparte.\n IF v_kind = 'person' AND coalesce(v_ref_id, 0) = 0 THEN\n v_kind := 'unidentified';\n END IF;\n\n IF v_kind NOT IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit',\n 'card_receivable_debit', 'card_payable', 'person', 'system', 'unit',\n 'unidentified') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown account kind %L \u2014 V1 account_type does not map onto the ledger''s ten', v_kind));\n END IF;\n\n IF v_kind = 'person' THEN\n SELECT l.target_id INTO v_person\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = v_ref_table\n AND l.source_numeric_id = v_ref_id\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de pessoa cuja pessoa ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_ref_table, '<sem tabela>'), coalesce(v_ref_id::text, '<sem id>')));\n END IF;\n\n SELECT id INTO v_merge_into\n FROM public.plg_financial_accounts\n WHERE tenant_id = p_tenant AND person_id = v_person AND id <> p_id\n LIMIT 1;\n END IF;\n\n IF v_kind = 'unit' AND v_unit IS NULL THEN\n SELECT l.target_id INTO v_unit\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = coalesce(v_ref_table, 'companies')\n AND l.source_numeric_id = v_ref_id\n AND l.target_table = 'app.units'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de unidade cuja unidade ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_ref_table, 'companies'), coalesce(v_ref_id::text, '<sem id>')));\n END IF;\n END IF;\n\n IF v_merge_into IS NOT NULL THEN\n UPDATE public.plg_financial_accounts\n SET is_active = is_active OR coalesce((p_row ->> 'is_active')::boolean, true)\n WHERE id = v_merge_into;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', v_merge_into, jsonb_build_object('reason', 'merged')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_merge_into, 'merged', true,\n 'reason', 'conta fundida na conta \u00FAnica da pessoa (o V2 admite uma por pessoa)');\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_financial_accounts WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_financial_accounts (id, tenant_id, unit_id, kind, code, name, bank_name, settlement_days, is_active, person_id, metadata)\n VALUES (p_id, p_tenant, v_unit, v_kind,\n p_row ->> 'code', p_row ->> 'name', p_row ->> 'bank_name',\n (p_row ->> 'settlement_days')::integer,\n coalesce((p_row ->> 'is_active')::boolean, true), v_person, v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_accounts SET\n unit_id = coalesce(v_unit, unit_id),\n kind = CASE WHEN p_row ? 'kind' THEN v_kind ELSE kind END,\n person_id = CASE WHEN p_row ? 'kind' THEN v_person ELSE person_id END,\n code = CASE WHEN p_row ? 'code' THEN p_row ->> 'code' ELSE code END,\n name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,\n bank_name = CASE WHEN p_row ? 'bank_name' THEN p_row ->> 'bank_name' ELSE bank_name END,\n settlement_days = CASE WHEN p_row ? 'settlement_days' THEN (p_row ->> 'settlement_days')::integer ELSE settlement_days END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n";
39
+ export declare const MIGRATION_038_A_LINHA_DA_FATURA_ALCANCA_QUEM_VEIO_PELO_PEDIDO = "-- A linha da fatura alcan\u00E7a quem veio pelo pedido.\n--\n-- `v_financial_invoice_lines` liga o item ao t\u00EDtulo por `items.invoice_id`. S\u00F3\n-- que `items_one_parent` \u2014 `CHECK (num_nonnulls(order_id, invoice_id,\n-- fulfillment_id, quote_id) = 1)` \u2014 diz que um item tem EXATAMENTE UM pai. Um\n-- item que nasceu numa venda tem `order_id` preenchido e `invoice_id` **nulo**,\n-- por constru\u00E7\u00E3o, e a view nunca o enxerga.\n--\n-- Isso n\u00E3o \u00E9 um caso de borda: \u00E9 todo item que veio de venda, e \u00E9 100% do que o\n-- porte do V1 traz. Medido em 18/09/2026 no tenant Pertinho do C\u00E9u:\n--\n-- public.items do tenant 5.420\n-- \u2026com invoice_id preenchido 0\n-- linhas em v_financial_invoice_lines 0\n--\n-- Cinco mil e quatrocentas linhas de fatura, e a view do financeiro devolvia\n-- zero. Qualquer tela ou relat\u00F3rio constru\u00EDdo sobre ela \u2014 an\u00E1lise de custo,\n-- encargos por conta cont\u00E1bil, DRE por natureza de linha \u2014 nasce vazio e parece\n-- \"cliente sem dado\" em vez de \"consulta pelo caminho errado\".\n--\n-- \u2500\u2500 O conserto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 t\u00EDtulo alcan\u00E7a a linha por DOIS caminhos, e a view passa a cobrir os dois:\n--\n-- direto items.invoice_id = f.id (linha criada no t\u00EDtulo)\n-- pelo pedido items.order_id = f.order_id (linha criada na venda)\n--\n-- \u00C9 `UNION ALL` e n\u00E3o um `JOIN ... ON a OR b` de prop\u00F3sito: a forma com OR faz o\n-- planner cair em nested loop sobre as duas tabelas inteiras, e esta view \u00E9 lida\n-- por tela. Dois ramos disjuntos (o segundo exige `invoice_id IS NULL`) d\u00E3o dois\n-- index scans e nenhuma linha contada duas vezes.\n--\n-- `chart_account_id` e `cost_center_id` passam a vir com `coalesce`: a extens\u00E3o\n-- financeira (`plg_financial_invoice_items`) vence quando existe, e o item do\n-- n\u00FAcleo responde quando n\u00E3o \u2014 que \u00E9 onde o porte grava a classifica\u00E7\u00E3o desde a\n-- migration 049 do spine.\n--\n-- Idempotente. Mesma lista de colunas, mesma ordem \u2014 `CREATE OR REPLACE VIEW`\n-- exige, e manter a assinatura \u00E9 o que deixa esta corre\u00E7\u00E3o ser um replace e n\u00E3o\n-- um drop com depend\u00EAncia quebrada.\n\nCREATE OR REPLACE VIEW public.v_financial_invoice_lines AS\n-- \u2500\u2500 caminho 1: a linha nasceu no pr\u00F3prio t\u00EDtulo \u2500\u2500\u2500\u2500\u2500\u2500\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 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 COALESCE(x.chart_account_id, i.chart_account_id) AS chart_account_id,\n COALESCE(x.cost_center_id, i.cost_center_id) AS 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\nUNION ALL\n\n-- \u2500\u2500 caminho 2: a linha nasceu na venda, e o t\u00EDtulo veio dela \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- `i.invoice_id IS NULL` mant\u00E9m os dois ramos disjuntos: sem isso, um item com\n-- os dois preenchidos apareceria duas vezes e dobraria o valor da conta. Hoje o\n-- CHECK impede que isso exista, e a condi\u00E7\u00E3o \u00E9 o cinto que sobrevive a um dia em\n-- que o CHECK mude.\nSELECT i.id AS item_id,\n i.tenant_id,\n i.unit_id,\n f.id AS 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 COALESCE(x.chart_account_id, i.chart_account_id) AS chart_account_id,\n COALESCE(x.cost_center_id, i.cost_center_id) AS 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.order_id = i.order_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 NULL AND i.order_id IS NOT NULL\n -- Only for an invoice with no lines of its own. An invoice billed from\n -- PART of an order (financial_invoice_from_lines) has its own lines, and\n -- without this guard it also listed every line of the order \u2014 the whole\n -- sale reported as billed, and summed twice.\n AND NOT EXISTS (SELECT 1 FROM public.items own WHERE own.invoice_id = f.id);\n\nCOMMENT ON VIEW public.v_financial_invoice_lines IS\n 'Linha de fatura com classifica\u00E7\u00E3o cont\u00E1bil, pelos DOIS caminhos: a linha '\n 'criada no t\u00EDtulo (items.invoice_id) e a criada na venda (items.order_id = '\n 'plg_financial_invoices.order_id). O segundo \u00E9 100% do que o porte do V1 traz '\n '\u2014 items_one_parent pro\u00EDbe um item de ter os dois pais.';\n\n-- `security_invoker` precisa sobreviver ao replace: sem ele a view passaria a\n-- rodar com os direitos do dono e furaria a RLS de todo mundo que a l\u00EA.\nALTER VIEW public.v_financial_invoice_lines SET (security_invoker = true);\n\nGRANT SELECT ON public.v_financial_invoice_lines TO authenticated, service_role;\n";
40
+ export declare const MIGRATION_039_O_TITULO_DE_UMA_VENDA_CANCELADA_NASCE_CANCELADO = "-- O t\u00EDtulo de uma venda cancelada nasce cancelado.\n--\n-- Uma venda do V1 tem DOIS destinos no V2: a Venda em `public.orders` e o T\u00CDTULO\n-- em `public.plg_financial_invoices`, criado pelo `extra` do writer `order`. O\n-- cancelamento atravessa para o primeiro e **n\u00E3o** para o segundo:\n--\n-- V1 invoices #177530 status = 'canceled'\n-- \u2192 public.orders status = 'cancelled' \u2705\n-- \u2192 plg_financial_invoices cancelled_at = NULL \u274C\n--\n-- O resultado \u00E9 pior que perder a linha: o t\u00EDtulo cancelado **conta como\n-- ativo**. Medido em 18/09/2026 no tenant Pertinho do C\u00E9u: 8 t\u00EDtulos de venda\n-- cancelada, **R$ 24.362,90** somando no faturamento e no DRE como se fossem\n-- receita viva. Uma linha ausente algu\u00E9m procura; uma linha a mais ningu\u00E9m\n-- confere.\n--\n-- Foi a asser\u00E7\u00E3o de paridade que achou: o DRE do V2 dava R$ 12.000 A MAIS que o\n-- do V1 na conta 8.2.5 de agosto, e o destino ter mais que a origem s\u00F3 pode ser\n-- m\u00EAs deslocado ou dado que n\u00E3o deveria estar l\u00E1.\n--\n-- \u2500\u2500 Por que um gatilho, e n\u00E3o um conserto no writer \u2500\u2500\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 caminho \u00F3bvio seria fazer `upsert_order` carimbar o t\u00EDtulo junto. N\u00E3o basta,\n-- e por uma raz\u00E3o que vale al\u00E9m da migra\u00E7\u00E3o: o cancelamento de uma venda tamb\u00E9m\n-- acontece DEPOIS, pelo app, e nesse caminho o writer da migra\u00E7\u00E3o nem roda. A\n-- regra \"t\u00EDtulo de venda cancelada est\u00E1 cancelado\" \u00E9 do modelo, n\u00E3o do\n-- transporte \u2014 ent\u00E3o ela mora onde o modelo mora.\n--\n-- O gatilho \u00E9 `AFTER UPDATE OF status`, n\u00E3o `AFTER UPDATE`: uma venda recebe\n-- escrita o tempo todo (total, notas, unidade) e disparar em todas faria dele um\n-- custo em cada uma delas.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. O que j\u00E1 entrou errado\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `updated_at` da venda, e n\u00E3o `now()`: o carimbo tem que dizer quando a venda\n-- foi cancelada, n\u00E3o quando esta migration rodou. Um `now()` aqui colocaria\n-- todos os cancelamentos hist\u00F3ricos na data do deploy, e qualquer relat\u00F3rio por\n-- per\u00EDodo passaria a mentir a partir de hoje.\nUPDATE public.plg_financial_invoices fi\n SET cancelled_at = coalesce(\n nullif(o.metadata #>> '{cancellation,at}', '')::timestamptz,\n o.updated_at),\n cancel_reason = coalesce(fi.cancel_reason, o.metadata #>> '{cancellation,reason}')\n FROM public.orders o\n WHERE o.id = fi.order_id\n AND o.status = 'cancelled'\n AND fi.cancelled_at IS NULL;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O que entrar daqui para a frente\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION public.trg_order_cancels_its_invoice()\nRETURNS trigger\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO ''\nAS $function$\nBEGIN\n IF NEW.status = 'cancelled' AND coalesce(OLD.status, '') <> 'cancelled' THEN\n UPDATE public.plg_financial_invoices\n SET cancelled_at = coalesce(\n nullif(NEW.metadata #>> '{cancellation,at}', '')::timestamptz, now()),\n cancel_reason = coalesce(cancel_reason, NEW.metadata #>> '{cancellation,reason}')\n WHERE order_id = NEW.id AND cancelled_at IS NULL;\n -- Descancelar \u00E9 reabrir: se a venda volta, o t\u00EDtulo volta com ela. Sem esta\n -- perna o estorno de um cancelamento deixaria o t\u00EDtulo morto para sempre, e o\n -- dinheiro sumiria do relat\u00F3rio sem ningu\u00E9m ter pedido.\n ELSIF coalesce(OLD.status, '') = 'cancelled' AND NEW.status <> 'cancelled' THEN\n UPDATE public.plg_financial_invoices\n SET cancelled_at = NULL, cancel_reason = NULL\n WHERE order_id = NEW.id;\n END IF;\n RETURN NULL;\nEND $function$;\n\nDROP TRIGGER IF EXISTS orders_cancel_reaches_the_invoice ON public.orders;\nCREATE TRIGGER orders_cancel_reaches_the_invoice\n AFTER UPDATE OF status ON public.orders\n FOR EACH ROW EXECUTE FUNCTION public.trg_order_cancels_its_invoice();\n\nCOMMENT ON FUNCTION public.trg_order_cancels_its_invoice() IS\n 'Cancelar a venda cancela o t\u00EDtulo dela, e descancelar reabre. O porte do V1 '\n 'levava o cancelamento para public.orders e n\u00E3o para plg_financial_invoices \u2014 '\n '8 t\u00EDtulos, R$ 24.362,90, contavam como receita viva.';\n";
41
+ export declare const MIGRATION_040_A_DRE_LE_A_LINHA_DA_VENDA = "-- A DRE l\u00EA a linha da venda.\n--\n-- O relat\u00F3rio que o V1 tem e o V2 n\u00E3o tinha. Ele existe agora porque o DADO\n-- passou a bater: `make parity CLIENT=pertinho-do-ceu DE=2026-01-01\n-- ATE=2026-12-31` fecha em 376 de 376 chaves (conta cont\u00E1bil \u00D7 m\u00EAs), com\n-- A = R$ 2.181.543,30 e B = R$ 2.181.543,29. Construir a DRE antes disso seria\n-- dar forma bonita a um n\u00FAmero errado.\n--\n-- \u2500\u2500 De onde cada n\u00FAmero sai, e 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\n--\n-- A DRE do V1 \u00E9 montada sobre `invoices \u00D7 invoice_items`, classificando pelo\n-- **item** e n\u00E3o pelo movimento \u2014 dois itens da mesma venda podem cair em ramos\n-- diferentes. Ela NUNCA l\u00EA `financial_movements`: parcela e baixa n\u00E3o importam,\n-- o lan\u00E7amento conta inteiro no m\u00EAs da compet\u00EAncia.\n--\n-- O caminho equivalente no V2 \u00E9 este, e n\u00E3o outro:\n--\n-- public.items \u2192 public.orders \u2192 public.plg_financial_invoices\n--\n-- **N\u00E3o** \u00E9 `items.invoice_id`. O CHECK `items_one_parent` exige exatamente um\n-- pai (`order_id` XOR `invoice_id` XOR \u2026), e o item que nasce numa venda tem\n-- `order_id`. Ligar pelo `invoice_id` devolve zero linha para todo dado migrado\n-- \u2014 foi o que aconteceu com `v_financial_invoice_lines` antes da migration 032.\n--\n-- A compet\u00EAncia \u00E9 `plg_financial_invoices.competence_date`, que \u00E9 onde a\n-- `invoices.invoice_date` do V1 foi parar. A dire\u00E7\u00E3o \u00E9 `fi.direction`\n-- (receivable | payable), que o writer `order` j\u00E1 grava certo \u2014 n\u00E3o \u00E9 preciso\n-- deduzir nada de `orders.direction`, que vem quase sempre nula.\n--\n-- \u2500\u2500 O que esta view N\u00C3O reproduz \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Tr\u00EAs defeitos do DRE do V1, provados no c\u00F3digo dele:\n--\n-- 1. subtrai o desconto DUAS vezes \u2014 `invoice_items.total_amount` j\u00E1 \u00E9 l\u00EDquido\n-- e a linha \"(\u2212) Descontos\" desconta de novo;\n-- 2. deixa passar nota `'canceled'` (1 L) pelo filtro de `'cancelled'` (2 L);\n-- 3. soma item cancelado individualmente (n\u00E3o filtra `canceled_at`).\n--\n-- Aqui `cancelled_at` \u00E9 filtrado nos dois n\u00EDveis e o desconto n\u00E3o \u00E9 subtra\u00EDdo\n-- duas vezes. Ou seja: a view bate com o DADO do V1, e vai divergir da TELA do\n-- V1 exatamente na medida desses tr\u00EAs. \u00C9 de prop\u00F3sito, e \u00E9 o motivo de a\n-- paridade comparar dado e n\u00E3o tela.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.plg_financial_rep_dre;\n\nCREATE VIEW public.plg_financial_rep_dre AS\nSELECT\n fi.tenant_id,\n fi.unit_id,\n -- Primeiro dia do m\u00EAs: \u00E9 assim que `v_financial_trial_balance` j\u00E1 expressa\n -- compet\u00EAncia, e duas conven\u00E7\u00F5es de m\u00EAs no mesmo plugin fariam um relat\u00F3rio\n -- n\u00E3o casar com o outro por um dia.\n date_trunc('month', fi.competence_date)::date AS competence_month,\n fi.direction,\n coa.id AS account_id,\n coa.code AS account_code,\n coa.name AS account_name,\n coa.account_type,\n coa.parent_id AS account_parent_id,\n -- A linha da DRE que n\u00E3o tem conta. O relat\u00F3rio do V1 chama de \"sem\n -- classifica\u00E7\u00E3o\" e ela domina a tela dele; mant\u00EA-la como uma linha de\n -- verdade \u2014 em vez de simplesmente n\u00E3o aparecer \u2014 \u00E9 o que deixa a soma da\n -- \u00E1rvore fechar com o cabe\u00E7alho.\n (coa.id IS NULL) AS unclassified,\n count(*) AS line_count,\n sum(it.total) AS amount,\n sum(it.discount) AS discount,\n sum(it.quantity) AS quantity\n FROM public.items it\n JOIN public.orders o ON o.id = it.order_id\n JOIN public.plg_financial_invoices fi ON fi.order_id = o.id\n LEFT JOIN public.plg_financial_chart_of_accounts coa\n ON coa.id = it.chart_account_id\n AND coa.tenant_id = fi.tenant_id\n WHERE fi.cancelled_at IS NULL\n AND it.cancelled_at IS NULL\n GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10;\n\nCOMMENT ON VIEW public.plg_financial_rep_dre IS\n 'DRE: soma das linhas de venda por conta cont\u00E1bil \u00D7 m\u00EAs de compet\u00EAncia \u00D7 '\n 'dire\u00E7\u00E3o. Liga o item ao t\u00EDtulo PELO PEDIDO (items_one_parent pro\u00EDbe o item '\n 'de ter os dois pais). Confere com o V1 em 376 de 376 chaves.';\n\nALTER VIEW public.plg_financial_rep_dre SET (security_invoker = true);\nGRANT SELECT ON public.plg_financial_rep_dre TO authenticated, service_role;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- O motor de analytics s\u00F3 alcan\u00E7a o que est\u00E1 na allowlist\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 view fora de `analytics_read_models` \u00E9 inalcan\u00E7\u00E1vel pelo `analytics_run`,\n-- por melhor que seja o card. Registrar aqui, junto com a view, \u00E9 o que impede\n-- as duas coisas de divergirem \u2014 a view sem registro \u00E9 um relat\u00F3rio que abre\n-- vazio e n\u00E3o diz por qu\u00EA.\nDO $$\nBEGIN\n IF to_regclass('public.analytics_read_models') IS NULL THEN\n RAISE NOTICE 'financial 034: motor de analytics n\u00E3o instalado \u2014 read model n\u00E3o registrado';\n RETURN;\n END IF;\n INSERT INTO public.analytics_read_models (name, date_column, tenant_column, description)\n VALUES ('plg_financial_rep_dre', 'competence_month', 'tenant_id',\n 'Receitas e despesas por plano de contas e m\u00EAs de compet\u00EAncia, da linha da venda.')\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";
42
+ export declare const MIGRATION_041_O_CAIXA_DO_V1_NAO_SEGURA_O_MOVIMENTO = "-- O caixa do V1 n\u00E3o segura o movimento.\n--\n-- `financial_movements.cash_session_id` estava no de-para como REFER\u00CANCIA DURA:\n--\n-- 'cash_session_id' \u2192 'cash_session_id@cash_register_sessions'\n--\n-- `cash_register_sessions` nunca entrou na allowlist. Toda linha que cita uma\n-- sess\u00E3o cai em quarentena `fk` \u2014 e n\u00E3o \u00E9 a sess\u00E3o que fica de fora, \u00E9 o\n-- DINHEIRO. Medido em 19/09/2026 no tenant Pertinho do C\u00E9u:\n--\n-- movimentos presos por `cash_session_id` 24\n-- contas a pagar que sumiam da lista por isso \u2248 R$ 2.319,46\n-- sess\u00F5es que os prendiam 2\n--\n-- Duas sess\u00F5es de confer\u00EAncia de gaveta seguravam dois mil reais de contas a\n-- pagar fora do relat\u00F3rio. E, medido na origem, essas duas sess\u00F5es n\u00E3o s\u00E3o\n-- opera\u00E7\u00E3o: uma abriu em 27/05 e fechou em 06/08 com diferen\u00E7a de R$ 20, a\n-- outra est\u00E1 aberta desde 18/09. N\u00E3o h\u00E1 fechamento de caixa acontecendo nesse\n-- tenant \u2014 h\u00E1 uma tela que algu\u00E9m abriu duas vezes.\n--\n-- \u2500\u2500 Por que metadata, e n\u00E3o portar a sess\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\n--\n-- Portar `cash_register_sessions` resolveria a quarentena e criaria dois\n-- registros de confer\u00EAncia de caixa que ningu\u00E9m conferiu, num plugin onde\n-- `v_financial_cash_session_expected` e `v_financial_cash_session_review` os\n-- mostrariam como opera\u00E7\u00E3o real. O V2 passaria a ter um caixa que o neg\u00F3cio n\u00E3o\n-- tem.\n--\n-- A sess\u00E3o vira o que ela \u00E9 aqui: um FATO da linha de origem, guardado onde os\n-- outros fatos sem destino j\u00E1 moram \u2014\n--\n-- 'cash_session_id' \u2192 'jsonb:v1_cash_session_ref'\n--\n-- O id do V1 continua na linha, consult\u00E1vel, e quando o fechamento de caixa for\n-- portar de verdade ele \u00E9 a chave que liga os dois lados. O que muda \u00E9 que ele\n-- deixa de ser uma condi\u00E7\u00E3o para o movimento existir.\n--\n-- Esta \u00E9 a mesma decis\u00E3o j\u00E1 tomada para `bank_account_id`\n-- (`jsonb:v1_bank_account_ref`) na mesma tabela: refer\u00EAncia que o V2 ainda n\u00E3o\n-- alcan\u00E7a \u00E9 registro, n\u00E3o trava.\n--\n-- Idempotente.\n\n-- The row is already frozen, and trg_allowlist_frozen refuses a plain write.\n-- A migration file is the one writer the hatch exists for (same idiom as\n-- 033/035); SET LOCAL so the unlock dies with this transaction.\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'financial_movements', 'public.plg_financial_movements', 'financial',\n 'fin_movement', '{}'::jsonb, ARRAY['amount', 'paid_amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'type', 'jsonb:v1.type',\n 'notes', 'notes',\n 'amount', 'amount',\n 'status', 'status',\n 'barcode', 'jsonb:barcode',\n 'due_date', 'due_date',\n 'direction', 'direction',\n 'card_brand', 'jsonb:card.brand',\n 'invoice_id', 'financial_invoice_id@invoices:public.plg_financial_invoices',\n 'external_id', 'external_id',\n 'paid_amount', 'paid_amount',\n 'confirmed_at', 'confirmed_at',\n 'is_confirmed', 'jsonb:confirmed',\n 'payment_date', 'paid_at',\n 'movement_type', 'movement_kind',\n 'order_guest_id', '-',\n 'bank_account_id', 'jsonb:v1_bank_account_ref',\n -- A mudan\u00E7a desta migration, e a \u00FAnica.\n 'cash_session_id', 'jsonb:v1_cash_session_ref',\n 'external_source', 'external_source',\n 'debit_account_id', 'debit_account_id@account_central',\n 'card_installments', 'jsonb:card.installments',\n 'credit_account_id', 'credit_account_id@account_central',\n 'payment_method_id', 'payment_method_id@payment_methods',\n 'installment_number', 'installment_number',\n 'parent_movement_id', 'parent_movement_id@financial_movements',\n 'payment_method_type_id','payment_method_type_id@payment_method_types'\n ),\n 'PRD 05 R7/R13: append-only two-leg movements (debit + credit account). Never '\n 'overwritten. Paid invoices without movements receive an identifiable synthetic '\n 'payment (Cr\u00E9dito Legado, R14) \u2014 the extractor emits it, the destination records '\n 'it as a normal row. `cash_session_id` \u00E9 REGISTRO (v1_cash_session_ref), n\u00E3o '\n 'refer\u00EAncia: o fechamento de caixa do V1 n\u00E3o \u00E9 opera\u00E7\u00E3o real neste porte e n\u00E3o '\n 'pode segurar o dinheiro fora do relat\u00F3rio.'\n);\n";
43
+ export declare const MIGRATION_042_O_CANCELADO_DO_V1_E_O_CANCELLED_DO_V2 = "-- O `canceled` do V1 \u00E9 o `cancelled` do V2.\n--\n-- `upsert_fin_movement` traduz o vocabul\u00E1rio de `movement_kind` com um CASE de\n-- dez linhas \u2014 conta, t\u00EDtulo, pagamento, recebimento \u2014 e entrega `status` CRU:\n--\n-- coalesce(p_row ->> 'status', 'pending')\n--\n-- O V1 escreve `canceled` (1 L). O plugin inteiro do V2 pergunta\n-- `status <> 'cancelled'` (2 L) \u2014 em `v_financial_cash_movements`, no balancete,\n-- no saldo da sess\u00E3o, no extrato. Uma letra, e o movimento cancelado passa por\n-- vivo em toda tela que o filtra pelo status.\n--\n-- Medido em 19/09/2026 no tenant Pertinho do C\u00E9u:\n--\n-- contas canceladas contando como vivas 8 R$ 24.362,90\n-- pagamentos cancelados contando como vivos 6 R$ 14.905,67\n--\n-- S\u00E3o as mesmas 8 vendas canceladas da migration 033 \u2014 l\u00E1 o que faltava era o\n-- carimbo no T\u00CDTULO, aqui \u00E9 a grafia no MOVIMENTO. O mesmo dinheiro achou dois\n-- caminhos diferentes para contar duas vezes, e nenhum dos dois aparecia numa\n-- confer\u00EAncia de contagem de linhas: as 14 linhas atravessaram, inteiras, com o\n-- valor certo.\n--\n-- \u2500\u2500 O que esta migration N\u00C3O consegue consertar, e por qu\u00EA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Os 6 PAGAMENTOS ficam como est\u00E3o. `trg_plg_financial_movements_append_only`\n-- deixa a conta (`bill`) mover colunas derivadas e recusa qualquer mudan\u00E7a de\n-- status num evento de dinheiro:\n--\n-- the status of money event % never changes \u2014 a reversal is a new movement\n--\n-- A regra est\u00E1 certa e n\u00E3o \u00E9 para ser contornada aqui: no modelo do V2, um\n-- pagamento que n\u00E3o aconteceu se desfaz com um ESTORNO \u2014 outro movimento, em\n-- sentido contr\u00E1rio \u2014 e n\u00E3o apagando o primeiro. Fazer isso pela migra\u00E7\u00E3o\n-- criaria 6 lan\u00E7amentos que o V1 n\u00E3o tem, e o espelho deixaria de ser espelho.\n-- Fica registrado aqui, com o valor, para decidir com quem \u00E9 do neg\u00F3cio.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. O que j\u00E1 entrou com a grafia da origem\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 `bill` e `commission`: \u00E9 exatamente o que o gatilho admite, e tentar o\n-- resto abortaria a migration inteira por causa das linhas que ele protege.\nUPDATE public.plg_financial_movements\n SET status = 'cancelled'\n WHERE status IN ('canceled', 'cancelado', 'cancelada')\n AND movement_kind IN ('bill', 'commission');\n\nDO $$\nDECLARE v_n integer; v_soma numeric;\nBEGIN\n SELECT count(*), coalesce(sum(amount), 0) INTO v_n, v_soma\n FROM public.plg_financial_movements\n WHERE status IN ('canceled', 'cancelado', 'cancelada');\n IF v_n > 0 THEN\n RAISE NOTICE 'financial 036: % evento(s) de dinheiro (R$ %) seguem com a grafia da origem \u2014 o append-only recusa mudar status de pagamento; o desfazimento deles e um ESTORNO, nao uma edicao', v_n, v_soma;\n END IF;\nEND $$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O que entrar daqui para a frente\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 tradu\u00E7\u00E3o mora ao lado da que j\u00E1 existe para `movement_kind`, e pela mesma\n-- raz\u00E3o: o de-para transporta a palavra da origem, e quem sabe o vocabul\u00E1rio do\n-- destino \u00E9 o destino. Valor desconhecido passa INTEIRO \u2014 status n\u00E3o \u00E9\n-- identidade, e recusar o movimento por causa de uma palavra nova perderia o\n-- dinheiro para proteger um r\u00F3tulo.\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_movement(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'movement_kind', ''))), '');\n v_kind text := CASE v_kind_raw\n WHEN 'bill' THEN 'bill' WHEN 'payment' THEN 'payment'\n WHEN 'transfer' THEN 'transfer' WHEN 'commission' THEN 'commission'\n -- V1's vocabulary\n WHEN 'conta' THEN 'bill' WHEN 'titulo' THEN 'bill' WHEN 't\u00EDtulo' THEN 'bill'\n WHEN 'pagamento' THEN 'payment' WHEN 'recebimento' THEN 'payment'\n WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\u00EAncia' THEN 'transfer'\n WHEN 'comissao' THEN 'commission' WHEN 'comiss\u00E3o' THEN 'commission'\n END;\n v_status_raw text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n -- A mudan\u00E7a desta migration. Mesma forma do CASE de `movement_kind`, tr\u00EAs\n -- linhas acima: o V1 fala uma l\u00EDngua, o V2 fala outra, e a tradu\u00E7\u00E3o \u00E9 aqui.\n v_status text := CASE v_status_raw\n WHEN 'canceled' THEN 'cancelled'\n WHEN 'cancelado' THEN 'cancelled'\n WHEN 'cancelada' THEN 'cancelled'\n WHEN 'pago' THEN 'paid'\n WHEN 'paga' THEN 'paid'\n WHEN 'pendente' THEN 'pending'\n WHEN 'vencido' THEN 'overdue'\n WHEN 'vencida' THEN 'overdue'\n WHEN 'atrasado' THEN 'overdue'\n WHEN 'parcial' THEN 'partial'\n ELSE coalesce(v_status_raw, 'pending')\n END;\n v_dir text := nullif(btrim(lower(coalesce(p_row ->> 'direction', ''))), '');\n v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);\n v_legs record;\n v_debit uuid := (p_row ->> 'debit_account_id')::uuid;\n v_credit uuid := (p_row ->> 'credit_account_id')::uuid;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a movement needs an id');\n END IF;\n IF v_kind IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown movement kind %L (expected bill|payment|transfer|commission, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));\n END IF;\n IF v_dir IS NOT NULL AND v_dir NOT IN ('debit', 'credit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown direction %L (expected debit|credit)', v_dir));\n END IF;\n -- A V1 pool that does not carry the column still knows the direction, through\n -- the invoice the movement settles: a receivable is money in, a payable money\n -- out. That is a derivation, not a guess. When there is no invoice either,\n -- refuse \u2014 \"which way did this money go\" is not something to default.\n IF v_dir IS NULL AND (p_row ->> 'financial_invoice_id') IS NOT NULL THEN\n SELECT CASE t.direction WHEN 'receivable' THEN 'credit' WHEN 'payable' THEN 'debit' END INTO v_dir\n FROM public.plg_financial_invoices t WHERE t.id = (p_row ->> 'financial_invoice_id')::uuid;\n END IF;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'the movement carries no direction and settles no invoice \u2014 which way the money went cannot be derived, and must not be defaulted');\n END IF;\n IF v_amount < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a money event cannot carry a negative amount \u2014 direction is the direction, not the sign');\n END IF;\n\n SELECT EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE id = p_id) INTO v_exists;\n IF v_exists THEN\n -- append-only: a second offer of the same money event is a replay\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'already in the ledger (append-only)');\n END IF;\n\n -- Legs, resolved exactly as the in-place backfill resolves them, so a migrated\n -- pool and an upgraded pool end up with the same postings.\n IF v_debit IS NULL OR v_credit IS NULL THEN\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n p_tenant, v_kind, v_dir, (p_row ->> 'bank_account_id')::uuid, (p_row ->> 'cash_session_id')::uuid,\n (p_row ->> 'financial_invoice_id')::uuid, v_meta);\n IF v_legs.o_mirror THEN\n -- an obligation mirror, not a money event: legs_check allows it legless\n v_meta := v_meta || jsonb_build_object('legacy_mirror', true);\n v_debit := NULL; v_credit := NULL;\n ELSE\n v_debit := coalesce(v_debit, v_legs.o_debit);\n v_credit := coalesce(v_credit, v_legs.o_credit);\n END IF;\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (id, tenant_id, financial_invoice_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, notes, metadata, debit_account_id, credit_account_id)\n VALUES (p_id, p_tenant, (p_row ->> 'financial_invoice_id')::uuid, v_dir, v_kind, v_amount,\n round(coalesce((p_row ->> 'paid_amount')::numeric, 0), 2),\n v_status,\n -- due_date is NOT NULL on the ledger. A V1 payment has no due date \u2014\n -- it is not an obligation \u2014 so it takes the date the money moved, which\n -- is the only date that means anything for it. A bill keeps its own.\n coalesce((p_row ->> 'due_date')::date, (p_row ->> 'paid_at')::date, current_date),\n (p_row ->> 'paid_at')::date,\n (p_row ->> 'installment_number')::integer, p_row ->> 'notes', v_meta, v_debit, v_credit);\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_movements', p_id, jsonb_build_object('reason', 'created')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 3. A baixa de um movimento apagado no V1\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `migration.retire_row` desce uma escada \u2014 `deleted_at`, `cancelled_at`,\n-- `is_active`, `active` \u2014 e, n\u00E3o achando nenhuma, faz DELETE. Em\n-- `plg_financial_movements` n\u00E3o h\u00E1 nenhuma das quatro, o DELETE bate no\n-- append-only, e a linha apagada no V1 fica VIVA no V2 para sempre.\n--\n-- O caso concreto: `financial_movements #359960`, apagado no V1 em 18/09 \u00E0s\n-- 20:10, R$ 5.439,05 vencendo em 17/09 \u2014 aparecia como a maior diferen\u00E7a de\n-- setembro na asser\u00E7\u00E3o de contas a pagar, com B maior que A. Dinheiro que o V1\n-- n\u00E3o tem mais e o V2 continuava cobrando.\n--\n-- A pol\u00EDtica diz onde a escada deve parar nesta tabela: o status \u00E9 o desligamento.\nINSERT INTO migration.retire_policy (target_table, mode, column_name, value, reason)\nVALUES ('public.plg_financial_movements', 'soft', 'status', 'cancelled',\n 'append-only: o movimento nunca \u00E9 apagado. A baixa \u00E9 o status \u2014 e vale '\n 'para a conta (bill/commission), que o gatilho deixa mover. Para um '\n 'evento de dinheiro o gatilho recusa, e a recusa vira quarentena com a '\n 'frase certa: o desfazimento de um pagamento \u00E9 um estorno.')\nON CONFLICT (target_table) DO UPDATE\n SET mode = EXCLUDED.mode, column_name = EXCLUDED.column_name,\n value = EXCLUDED.value, reason = EXCLUDED.reason;\n";
44
+ export declare const MIGRATION_043_O_MOVIMENTO_SABE_DE_QUE_UNIDADE_ELE_E = "-- O movimento sabe de que unidade ele \u00E9.\n--\n-- A tela de Contas a Pagar do V1 filtra por unidade, e o filtro \u00E9 a primeira\n-- coisa que a pessoa mexe. Est\u00E1 escrito no componente dele\n-- (`PayablesList.tsx:341`):\n--\n-- const unitColumn = type === 'payable' ? 'debit_unit_id' : 'credit_unit_id'\n-- query = query.eq(unitColumn, Number(filterUnitId))\n--\n-- O de-para n\u00E3o levava nenhuma das duas. Medido em 19/09/2026 no tenant\n-- Pertinho do C\u00E9u:\n--\n-- movimentos de setembro no V2 259\n-- com `unit_id` preenchido 0\n--\n-- O dado atravessou inteiro \u2014 o total do tenant bate, e `make parity` fecha em\n-- R$ 2.186.212,02 dos dois lados. O que n\u00E3o atravessou foi a capacidade de\n-- SEPARAR, e por isso a tela do V2 n\u00E3o conseguia reproduzir a do V1:\n--\n-- V1, setembro, unidade Pertinho do C\u00E9u 189 mov R$ 283.507,97\n-- V1, setembro, unidade PC Servi\u00E7os 70 mov R$ 71.422,29\n-- V2, setembro, sem como separar 259 mov R$ 354.930,26\n--\n-- Um total certo que n\u00E3o se deixa recortar \u00E9 um relat\u00F3rio que ningu\u00E9m confere:\n-- quem paga as contas paga as da SUA unidade.\n--\n-- \u2500\u2500 Onde a escolha \u00E9 feita, e por qu\u00EA l\u00E1 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 V1 tem duas colunas e escolhe pela dire\u00E7\u00E3o; o V2 tem uma. A ponte \u00E9\n-- declarada no extrator (`Step.augment` de `financial_movements`), com a mesma\n-- regra do componente:\n--\n-- case when t.type = 'D' then t.debit_unit_id else t.credit_unit_id end\n--\n-- \u00C9 deriva de de-para, que \u00E9 exatamente o que `augment` existe para declarar \u2014\n-- e some sozinha no dia em que a origem unificar as duas colunas. O writer\n-- apenas grava o que chega, resolvido pelo ledger de `companies`.\n--\n-- Idempotente.\n\n-- The row is already frozen, and trg_allowlist_frozen refuses a plain write.\n-- A migration file is the one writer the hatch exists for (same idiom as\n-- 033/035); SET LOCAL so the unlock dies with this transaction.\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'financial_movements', 'public.plg_financial_movements', 'financial',\n 'fin_movement', '{}'::jsonb, ARRAY['amount', 'paid_amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'type', 'jsonb:v1.type',\n 'notes', 'notes',\n 'amount', 'amount',\n 'status', 'status',\n 'barcode', 'jsonb:barcode',\n 'due_date', 'due_date',\n 'direction', 'direction',\n 'card_brand', 'jsonb:card.brand',\n 'invoice_id', 'financial_invoice_id@invoices:public.plg_financial_invoices',\n 'external_id', 'external_id',\n 'paid_amount', 'paid_amount',\n 'confirmed_at', 'confirmed_at',\n 'is_confirmed', 'jsonb:confirmed',\n 'payment_date', 'paid_at',\n 'movement_type', 'movement_kind',\n 'order_guest_id', '-',\n 'bank_account_id', 'jsonb:v1_bank_account_ref',\n 'cash_session_id', 'jsonb:v1_cash_session_ref',\n -- A coluna desta migration. Ela n\u00E3o existe na origem: \u00E9 derivada das duas\n -- pernas do V1 pelo `augment` do extrator, com a regra da tela dele.\n 'unit_id', 'unit_id@companies:app.units',\n 'debit_unit_id', 'jsonb:v1.debit_unit_ref',\n 'credit_unit_id', 'jsonb:v1.credit_unit_ref',\n 'external_source', 'external_source',\n 'debit_account_id', 'debit_account_id@account_central',\n 'card_installments', 'jsonb:card.installments',\n 'credit_account_id', 'credit_account_id@account_central',\n 'payment_method_id', 'payment_method_id@payment_methods',\n 'installment_number', 'installment_number',\n 'parent_movement_id', 'parent_movement_id@financial_movements',\n 'payment_method_type_id','payment_method_type_id@payment_method_types'\n ),\n 'PRD 05 R7/R13: append-only two-leg movements (debit + credit account). Never '\n 'overwritten. `cash_session_id` \u00E9 REGISTRO (v1_cash_session_ref), n\u00E3o '\n 'refer\u00EAncia. `unit_id` \u00E9 DERIVADA das duas pernas do V1 pela regra da tela '\n 'dele (D \u2192 debit_unit_id, C \u2192 credit_unit_id): sem ela o total fecha e n\u00E3o se '\n 'deixa recortar por unidade.'\n);\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- O writer grava a unidade\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Duas pernas mudam: a coluna entra no INSERT, e quando ela n\u00E3o vem resolvida a\n-- unidade DO T\u00CDTULO responde. A segunda n\u00E3o \u00E9 um palpite \u2014 um movimento quita um\n-- t\u00EDtulo, e o t\u00EDtulo j\u00E1 sabe de que unidade \u00E9 (2.374 dos 2.375 no tenant). Ela \u00E9\n-- o que faz o movimento antigo, que atravessou antes desta migration, ganhar a\n-- unidade na pr\u00F3xima oferta sem precisar de um backfill separado.\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_movement(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'movement_kind', ''))), '');\n v_kind text := CASE v_kind_raw\n WHEN 'bill' THEN 'bill' WHEN 'payment' THEN 'payment'\n WHEN 'transfer' THEN 'transfer' WHEN 'commission' THEN 'commission'\n WHEN 'conta' THEN 'bill' WHEN 'titulo' THEN 'bill' WHEN 't\u00EDtulo' THEN 'bill'\n WHEN 'pagamento' THEN 'payment' WHEN 'recebimento' THEN 'payment'\n WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\u00EAncia' THEN 'transfer'\n WHEN 'comissao' THEN 'commission' WHEN 'comiss\u00E3o' THEN 'commission'\n END;\n v_status_raw text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text := CASE v_status_raw\n WHEN 'canceled' THEN 'cancelled'\n WHEN 'cancelado' THEN 'cancelled'\n WHEN 'cancelada' THEN 'cancelled'\n WHEN 'pago' THEN 'paid'\n WHEN 'paga' THEN 'paid'\n WHEN 'pendente' THEN 'pending'\n WHEN 'vencido' THEN 'overdue'\n WHEN 'vencida' THEN 'overdue'\n WHEN 'atrasado' THEN 'overdue'\n WHEN 'parcial' THEN 'partial'\n ELSE coalesce(v_status_raw, 'pending')\n END;\n v_dir text := nullif(btrim(lower(coalesce(p_row ->> 'direction', ''))), '');\n v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);\n v_invoice uuid := (p_row ->> 'financial_invoice_id')::uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_legs record;\n v_debit uuid := (p_row ->> 'debit_account_id')::uuid;\n v_credit uuid := (p_row ->> 'credit_account_id')::uuid;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a movement needs an id');\n END IF;\n IF v_kind IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown movement kind %L (expected bill|payment|transfer|commission, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));\n END IF;\n IF v_dir IS NOT NULL AND v_dir NOT IN ('debit', 'credit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown direction %L (expected debit|credit)', v_dir));\n END IF;\n IF v_dir IS NULL AND v_invoice IS NOT NULL THEN\n SELECT CASE t.direction WHEN 'receivable' THEN 'credit' WHEN 'payable' THEN 'debit' END INTO v_dir\n FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'the movement carries no direction and settles no invoice \u2014 which way the money went cannot be derived, and must not be defaulted');\n END IF;\n IF v_amount < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a money event cannot carry a negative amount \u2014 direction is the direction, not the sign');\n END IF;\n\n -- A unidade do t\u00EDtulo responde pelo movimento que n\u00E3o trouxe a sua. N\u00E3o \u00E9\n -- palpite: o movimento quita AQUELE t\u00EDtulo, e o t\u00EDtulo \u00E9 de uma unidade s\u00F3.\n IF v_unit IS NULL AND v_invoice IS NOT NULL THEN\n SELECT t.unit_id INTO v_unit FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n\n SELECT EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE id = p_id) INTO v_exists;\n IF v_exists THEN\n -- append-only: a segunda oferta do mesmo evento \u00E9 replay. A unidade \u00E9 a\n -- exce\u00E7\u00E3o, e cabe: o gatilho n\u00E3o a protege (n\u00E3o \u00E9 valor, nem conta, nem\n -- v\u00EDnculo), e recus\u00E1-la deixaria todo movimento j\u00E1 portado sem unidade para\n -- sempre \u2014 um backfill eterno em vez de uma segunda passada.\n IF v_unit IS NOT NULL THEN\n UPDATE public.plg_financial_movements\n SET unit_id = v_unit\n WHERE id = p_id AND unit_id IS DISTINCT FROM v_unit;\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'already in the ledger (append-only)');\n END IF;\n\n IF v_debit IS NULL OR v_credit IS NULL THEN\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n p_tenant, v_kind, v_dir, (p_row ->> 'bank_account_id')::uuid, (p_row ->> 'cash_session_id')::uuid,\n v_invoice, v_meta);\n IF v_legs.o_mirror THEN\n v_meta := v_meta || jsonb_build_object('legacy_mirror', true);\n v_debit := NULL; v_credit := NULL;\n ELSE\n v_debit := coalesce(v_debit, v_legs.o_debit);\n v_credit := coalesce(v_credit, v_legs.o_credit);\n END IF;\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (id, tenant_id, unit_id, financial_invoice_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, notes, metadata, debit_account_id, credit_account_id)\n VALUES (p_id, p_tenant, v_unit, v_invoice, v_dir, v_kind, v_amount,\n round(coalesce((p_row ->> 'paid_amount')::numeric, 0), 2),\n v_status,\n coalesce((p_row ->> 'due_date')::date, (p_row ->> 'paid_at')::date, current_date),\n (p_row ->> 'paid_at')::date,\n (p_row ->> 'installment_number')::integer, p_row ->> 'notes', v_meta, v_debit, v_credit);\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_movements', p_id, jsonb_build_object('reason', 'created')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');\nEND $function$;\n";
45
+ export declare const MIGRATION_044_A_DRE_NAO_PAGA_PEDAGIO_POR_ITEM = "-- A DRE n\u00E3o paga ped\u00E1gio por item.\n--\n-- Conserto de um defeito que eu introduzi na 034: o relat\u00F3rio funcionava e\n-- derrubava a p\u00E1gina inteira de `/analytics`.\n--\n-- \u2500\u2500 O que estava acontecendo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `analytics_run_batch` roda os cards de uma p\u00E1gina em SEQU\u00CANCIA, num \u00FAnico\n-- request. Um card lento n\u00E3o fica lento sozinho \u2014 ele estoura o tempo de todos.\n-- Medido em 19/09/2026 no tenant Pertinho do C\u00E9u, card a card:\n--\n-- 12 dos 14 read models < 0,20 s\n-- plg_financial_rep_receivables 4,94 s\n-- plg_financial_rep_dre 15,06 s \u2190 este arquivo\n--\n-- \u2500\u2500 Por que 15 segundos para somar 425 linhas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 view tem `security_invoker = true`, ent\u00E3o l\u00EA `items` com a RLS de `items`.\n-- E a pol\u00EDtica de `items` faz DUAS chamadas CORRELACIONADAS por linha:\n--\n-- (SELECT app.has_unit(items.unit_id))\n-- (SELECT app.has_permission('orders.read', items.unit_id))\n--\n-- Correlacionada quer dizer que o planejador n\u00E3o pode i\u00E7\u00E1-la para fora do la\u00E7o:\n-- ela roda uma vez POR LINHA. O `EXPLAIN (ANALYZE, BUFFERS)` do simples\n-- `count(*) from items` mostra o pre\u00E7o:\n--\n-- SubPlan 2 loops=5582 0,208 ms cada\n-- SubPlan 10 loops=5582 0,274 ms cada\n-- Buffers: shared hit=119364 (21 buffers por linha, s\u00F3 de RLS)\n--\n-- E as duas dependem apenas de `unit_id`, que neste tenant tem DOIS valores\n-- distintos. S\u00E3o 11.164 respostas para duas perguntas.\n--\n-- N\u00E3o \u00E9 um problema da DRE: \u00E9 o pre\u00E7o de varrer uma tabela de fatos inteira sob\n-- essa pol\u00EDtica. A DRE \u00E9 s\u00F3 o primeiro relat\u00F3rio do produto que precisa varrer\n-- uma.\n--\n-- \u2500\u2500 O conserto, e o que ele preserva \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 view deixa de ser `security_invoker` e passa a carregar a pr\u00F3pria cerca \u2014\n-- que \u00E9 o padr\u00E3o de view de relat\u00F3rio, e \u00E9 MAIS expl\u00EDcito, n\u00E3o menos:\n--\n-- WHERE fi.tenant_id = app.current_tenant_id()\n-- AND (fi.unit_id IS NULL OR fi.unit_id = ANY (app.visible_units()))\n--\n-- As duas condi\u00E7\u00F5es s\u00E3o avaliadas UMA vez cada (s\u00E3o n\u00E3o-correlacionadas: o\n-- planejador as transforma em InitPlan) e no n\u00EDvel do T\u00CDTULO \u2014 2.421 linhas \u2014\n-- em vez do n\u00EDvel do item.\n--\n-- O que continua valendo:\n-- \u00B7 a cerca de tenant, expl\u00EDcita e obrigat\u00F3ria \u2014 sem claim, `current_tenant_id()`\n-- devolve NULL e a view devolve zero linha;\n-- \u00B7 o recorte por unidade, com a mesma regra de `app.has_unit` (escopo ligado\n-- \u2192 `user_unit_access`; desligado \u2192 as unidades do tenant);\n-- \u00B7 a allowlist `analytics_read_models`, que \u00E9 o que torna a view alcan\u00E7\u00E1vel\n-- pelo motor \u2014 uma view fora dela \u00E9 inalcan\u00E7\u00E1vel por melhor que seja o card.\n--\n-- O que deixa de valer, de prop\u00F3sito: o escopo por DONO (`owner_id`) do item.\n-- Um demonstrativo de resultado n\u00E3o \u00E9 um documento por dono \u2014 ele \u00E9 da unidade.\n-- Quem pode ver a DRE da unidade v\u00EA a DRE inteira da unidade; quem n\u00E3o pode ver\n-- a unidade n\u00E3o v\u00EA linha nenhuma dela.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. O conjunto de unidades vis\u00EDveis, calculado uma vez\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Mesma regra de `app.has_unit`, com uma diferen\u00E7a que \u00E9 o ponto: n\u00E3o recebe\n-- argumento. Fun\u00E7\u00E3o sem argumento \u00E9 i\u00E7ada para InitPlan e roda UMA vez por\n-- statement; a per-linha vira um teste de pertin\u00EAncia num array de dois\n-- elementos.\n--\n-- `app.current_unit_id()` (a unidade fixada no token) entra aqui tamb\u00E9m, para\n-- que o conjunto j\u00E1 venha estreitado quando houver uma.\nCREATE OR REPLACE FUNCTION app._units_of_member() RETURNS uuid[]\nLANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''\nAS $$\n SELECT CASE\n WHEN app.unit_scoping_on()\n THEN coalesce((SELECT array_agg(a.unit_id) FROM app.user_unit_access a\n WHERE a.tenant_id = app.current_tenant_id()\n AND a.user_id = auth.uid()), '{}'::uuid[])\n ELSE coalesce((SELECT array_agg(u.id) FROM app.units u\n WHERE u.tenant_id = app.current_tenant_id()), '{}'::uuid[])\n END;\n$$;\n\nCREATE OR REPLACE FUNCTION app.visible_units() RETURNS uuid[]\nLANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''\nAS $$\n SELECT CASE\n WHEN app.current_unit_id() IS NOT NULL\n THEN CASE WHEN app.current_unit_id() = ANY (app._units_of_member())\n THEN ARRAY[app.current_unit_id()] ELSE '{}'::uuid[] END\n ELSE app._units_of_member()\n END;\n$$;\n\nCOMMENT ON FUNCTION app.visible_units() IS\n 'As unidades que o chamador enxerga, como array. Mesma regra de app.has_unit, '\n 'sem argumento \u2014 e \u00E9 a aus\u00EAncia do argumento que importa: sem ela o predicado '\n '\u00E9 correlacionado e roda uma vez por linha.';\n\nGRANT EXECUTE ON FUNCTION app.visible_units() TO authenticated, service_role;\nGRANT EXECUTE ON FUNCTION app._units_of_member() TO authenticated, service_role;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. A view leva a pr\u00F3pria cerca\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nDROP VIEW IF EXISTS public.plg_financial_rep_dre;\n\nCREATE VIEW public.plg_financial_rep_dre AS\n-- `MATERIALIZED` n\u00E3o \u00E9 enfeite. `app.current_tenant_id()` \u00E9 STABLE e SECURITY\n-- DEFINER; escrita solta num `WHERE`, ela cai no *Filter* do plano e o Postgres\n-- a chama UMA VEZ POR LINHA \u2014 foi o que sobrou depois de tirar a RLS: 40.928\n-- buffers para varrer 272 blocos. A CTE materializada obriga o planejador a\n-- avali\u00E1-la uma vez e a carregar o resultado como constante, que \u00E9 a mesma\n-- raz\u00E3o de as pol\u00EDticas de RLS deste schema escreverem\n-- `(SELECT app.current_tenant_id())` e n\u00E3o `app.current_tenant_id()`.\nWITH cerca AS MATERIALIZED (\n SELECT app.current_tenant_id() AS tenant_id,\n app.visible_units() AS units\n)\nSELECT\n fi.tenant_id,\n fi.unit_id,\n date_trunc('month', fi.competence_date)::date AS competence_month,\n fi.direction,\n coa.id AS account_id,\n coa.code AS account_code,\n coa.name AS account_name,\n coa.account_type,\n coa.parent_id AS account_parent_id,\n (coa.id IS NULL) AS unclassified,\n count(*) AS line_count,\n sum(it.total) AS amount,\n sum(it.discount) AS discount,\n sum(it.quantity) AS quantity\n FROM cerca\n JOIN public.plg_financial_invoices fi ON fi.tenant_id = cerca.tenant_id\n JOIN public.orders o ON o.id = fi.order_id\n JOIN public.items it ON it.order_id = o.id\n LEFT JOIN public.plg_financial_chart_of_accounts coa\n ON coa.id = it.chart_account_id\n AND coa.tenant_id = fi.tenant_id\n WHERE fi.cancelled_at IS NULL\n AND it.cancelled_at IS NULL\n -- A cerca, no n\u00EDvel do T\u00CDTULO (2.421 linhas) e n\u00E3o do item (5.670), e com o\n -- valor j\u00E1 resolvido pela CTE acima.\n AND (fi.unit_id IS NULL OR fi.unit_id = ANY (cerca.units))\n GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10;\n\nCOMMENT ON VIEW public.plg_financial_rep_dre IS\n 'DRE: soma das linhas de venda por conta cont\u00E1bil \u00D7 m\u00EAs de compet\u00EAncia \u00D7 '\n 'dire\u00E7\u00E3o. Liga o item ao t\u00EDtulo PELO PEDIDO (items_one_parent pro\u00EDbe o item '\n 'de ter os dois pais). Confere com o V1 em 376 de 376 chaves. N\u00C3O \u00E9 '\n 'security_invoker: carrega a pr\u00F3pria cerca de tenant e de unidade, porque '\n 'herdar a RLS de `items` custava uma chamada de fun\u00E7\u00E3o por linha \u2014 15 s para '\n 'somar 425 linhas, e o batch de analytics inteiro junto.';\n\n-- `security_invoker` fica DESLIGADO de prop\u00F3sito. Expl\u00EDcito, para que um\n-- `ALTER ... SET (security_invoker = true)` bem-intencionado no futuro n\u00E3o\n-- reintroduza o problema sem que ningu\u00E9m entenda o que mudou.\nALTER VIEW public.plg_financial_rep_dre SET (security_invoker = false);\nGRANT SELECT ON public.plg_financial_rep_dre TO authenticated, service_role;\n\nDO $$\nBEGIN\n IF to_regclass('public.analytics_read_models') IS NULL THEN\n RAISE NOTICE 'financial 038: motor de analytics n\u00E3o instalado \u2014 read model n\u00E3o registrado';\n RETURN;\n END IF;\n INSERT INTO public.analytics_read_models (name, date_column, tenant_column, description)\n VALUES ('plg_financial_rep_dre', 'competence_month', 'tenant_id',\n 'Receitas e despesas por plano de contas e m\u00EAs de compet\u00EAncia, da linha da venda.')\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";
46
+ export declare const MIGRATION_045_A_CONTA_SO_CLASSIFICA_SE_A_NATUREZA_CASA = "-- A conta s\u00F3 classifica se a natureza casa com o sentido.\n--\n-- A tela de DRE do V1 n\u00E3o coloca o item na conta dele s\u00F3 porque ele tem conta.\n-- Est\u00E1 escrito no componente (`src/pages/reports/DRE.tsx`):\n--\n-- const isRev = it.invoice_type === 'C'\n-- const node = nodeIndex.get(`coa-${it.account_id}`)\n-- if (node && node.section === (isRev ? 'revenue' : 'expense')) { ...coloca }\n-- // sen\u00E3o: cai no balde sint\u00E9tico \"sem classifica\u00E7\u00E3o\"\n--\n-- Uma despesa apontando para conta de RECEITA n\u00E3o vira receita e n\u00E3o vira\n-- despesa classificada: vira \"Despesas sem classifica\u00E7\u00E3o\". E isso n\u00E3o \u00E9 canto\n-- raro. Medido em 19/09/2026 no tenant Pertinho do C\u00E9u:\n--\n-- itens em nota D apontando para conta de receita 345\n-- valor R$ 125.990,38\n--\n-- A view devolvia `account_code` sempre que havia conta, ent\u00E3o o demonstrativo\n-- do V2 classificava esses 345 como despesa de uma conta de receita \u2014 uma linha\n-- que a tela do V1 n\u00E3o tem, num ramo em que ela n\u00E3o existe.\n--\n-- \u2500\u2500 O que muda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Duas colunas novas, `statement_code` e `statement_name`: o c\u00F3digo e o nome da\n-- conta QUANDO a natureza casa com o sentido, e NULL quando n\u00E3o casa. As\n-- colunas antigas continuam, cruas, para quem quiser a classifica\u00E7\u00E3o sem a\n-- regra do demonstrativo.\n--\n-- A regra mora aqui, em SQL, e n\u00E3o no componente de tela, porque ela \u00E9 da\n-- CONTABILIDADE e n\u00E3o da apresenta\u00E7\u00E3o: qualquer outra tela que leia este read\n-- model precisa da mesma resposta.\n--\n-- \u2500\u2500 A prova \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Reproduzida na origem e no destino, unidade Matriz, exerc\u00EDcio 2026:\n--\n-- A (V1) B (V2)\n-- receita sem classif. 5.847,86 5.847,86 \u2713\n-- despesa classificada 1.384.146,65 1.360.407,63\n-- despesa sem classif. 231.822,74 231.822,73 \u2713\n--\n-- A diferen\u00E7a de R$ 23.739,02 nas despesas classificadas s\u00E3o 7 notas com status\n-- `canceled` (1 L) que o filtro do V1 \u2014 `status <> 'cancelled'`, 2 L \u2014 deixa\n-- passar. Medido: 7 itens, R$ 23.739,00, todos em conta de despesa. O V2 as\n-- exclui por `cancelled_at`, de prop\u00F3sito, como a migration 034 j\u00E1 declara.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.plg_financial_rep_dre;\n\nCREATE VIEW public.plg_financial_rep_dre AS\nWITH cerca AS MATERIALIZED (\n SELECT app.current_tenant_id() AS tenant_id,\n app.visible_units() AS units\n)\nSELECT\n fi.tenant_id,\n fi.unit_id,\n date_trunc('month', fi.competence_date)::date AS competence_month,\n fi.direction,\n coa.id AS account_id,\n coa.code AS account_code,\n coa.name AS account_name,\n coa.account_type,\n coa.parent_id AS account_parent_id,\n -- A conta pela regra do demonstrativo: presente quando a natureza dela casa\n -- com o sentido do t\u00EDtulo, ausente quando n\u00E3o casa. O NULL aqui \u00E9 a\n -- resposta certa \u2014 \"esta linha n\u00E3o pertence a nenhum ramo da \u00E1rvore\" \u2014 e \u00E9\n -- o que manda o valor para a linha sem classifica\u00E7\u00E3o, que \u00E9 onde a tela do\n -- V1 tamb\u00E9m o coloca.\n CASE WHEN (fi.direction = 'receivable' AND coa.account_type = 'revenue')\n OR (fi.direction = 'payable' AND coa.account_type = 'expense')\n THEN coa.code END AS statement_code,\n CASE WHEN (fi.direction = 'receivable' AND coa.account_type = 'revenue')\n OR (fi.direction = 'payable' AND coa.account_type = 'expense')\n THEN coa.name END AS statement_name,\n (coa.id IS NULL) AS unclassified,\n count(*) AS line_count,\n sum(it.total) AS amount,\n sum(it.discount) AS discount,\n sum(it.quantity) AS quantity\n FROM cerca\n JOIN public.plg_financial_invoices fi ON fi.tenant_id = cerca.tenant_id\n JOIN public.orders o ON o.id = fi.order_id\n JOIN public.items it ON it.order_id = o.id\n LEFT JOIN public.plg_financial_chart_of_accounts coa\n ON coa.id = it.chart_account_id\n AND coa.tenant_id = fi.tenant_id\n WHERE fi.cancelled_at IS NULL\n AND it.cancelled_at IS NULL\n AND (fi.unit_id IS NULL OR fi.unit_id = ANY (cerca.units))\n GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12;\n\nCOMMENT ON VIEW public.plg_financial_rep_dre IS\n 'DRE: soma das linhas de venda por conta cont\u00E1bil \u00D7 m\u00EAs de compet\u00EAncia \u00D7 '\n 'dire\u00E7\u00E3o. `statement_code` \u00E9 a conta PELA REGRA DO DEMONSTRATIVO (a natureza '\n 'tem que casar com o sentido); `account_code` \u00E9 a classifica\u00E7\u00E3o crua. Confere '\n 'com o V1 em 376 de 376 chaves. N\u00C3O \u00E9 security_invoker: carrega a pr\u00F3pria '\n 'cerca de tenant e de unidade \u2014 herdar a RLS de `items` custava uma chamada '\n 'de fun\u00E7\u00E3o por linha, 15 s para somar 425 linhas.';\n\nALTER VIEW public.plg_financial_rep_dre SET (security_invoker = false);\nGRANT SELECT ON public.plg_financial_rep_dre TO authenticated, service_role;\n\nDO $$\nBEGIN\n IF to_regclass('public.analytics_read_models') IS NULL THEN\n RAISE NOTICE 'financial 039: motor de analytics n\u00E3o instalado \u2014 read model n\u00E3o registrado';\n RETURN;\n END IF;\n INSERT INTO public.analytics_read_models (name, date_column, tenant_column, description)\n VALUES ('plg_financial_rep_dre', 'competence_month', 'tenant_id',\n 'Receitas e despesas por plano de contas e m\u00EAs de compet\u00EAncia, da linha da venda.')\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";
47
+ export declare const MIGRATION_046_O_DEMONSTRATIVO_MOSTRA_O_PLANO_INTEIRO = "-- O demonstrativo mostra o plano inteiro, n\u00E3o s\u00F3 o que teve movimento.\n--\n-- A DRE do V1 desenha a \u00C1RVORE DO PLANO DE CONTAS e depois derrama os valores\n-- nela. Conta sem lan\u00E7amento no exerc\u00EDcio aparece assim mesmo, com travess\u00E3o em\n-- todos os meses \u2014 est\u00E1 na tela dele, medida em 18/09/2026:\n--\n-- 1 RECEITAS \u2014 \u2014 \u2014 \u2026 \u2014\n-- 1.1 Receita Bruta \u2014 \u2014 \u2014 \u2026 \u2014\n-- 1.2 Dedu\u00E7\u00F5es da Receita \u2014 \u2014 \u2014 \u2026 \u2014\n-- 5.3 Hora Extra \u2014 \u2014 \u2014 \u2026 \u2014\n-- 5.5 FGTS \u2014 \u2014 \u2014 \u2026 \u2014\n-- 9.9.99 Quebra de Caixa \u2014 \u2014 \u2014 \u2026 \u2014\n--\n-- Esta view fazia o contr\u00E1rio: partia dos LAN\u00C7AMENTOS e s\u00F3 conhecia a conta que\n-- algum item citou. O ramo 1 inteiro \u2014 as 45 contas de receita do tenant \u2014\n-- simplesmente n\u00E3o existia na tela, e a \u00E1rvore come\u00E7ava no 2.\n--\n-- A diferen\u00E7a n\u00E3o \u00E9 cosm\u00E9tica. Um plano de contas \u00E9 uma DECLARA\u00C7\u00C3O do que o\n-- neg\u00F3cio acompanha; a conta zerada \u00E9 uma resposta (\"n\u00E3o houve\"), e a conta\n-- ausente \u00E9 uma pergunta que ningu\u00E9m fez. Quem l\u00EA um demonstrativo precisa\n-- distinguir as duas, e \u00E9 por isso que o V1 as distingue.\n--\n-- \u2500\u2500 Como \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 view vira a uni\u00E3o de dois ramos:\n--\n-- ESTRUTURA uma linha por conta ativa do plano, com `competence_month` NULO\n-- e valor zero. \u00C9 o esqueleto: existe, n\u00E3o teve movimento.\n--\n-- MOVIMENTO o que a view j\u00E1 fazia \u2014 a soma dos itens por conta \u00D7 m\u00EAs.\n--\n-- `competence_month` NULO \u00E9 o que separa os dois, e \u00E9 deliberado: quem monta o\n-- demonstrativo cria o n\u00F3 e N\u00C3O cria coluna para uma linha sem m\u00EAs. Um zero com\n-- m\u00EAs seria outra coisa \u2014 seria afirmar que houve movimento de zero naquele m\u00EAs.\n--\n-- O esqueleto tamb\u00E9m conserta um efeito colateral feio: as contas-m\u00E3e ('2',\n-- '5.4') quase nunca t\u00EAm lan\u00E7amento pr\u00F3prio, ent\u00E3o a \u00E1rvore as inventava s\u00F3 com\n-- o c\u00F3digo e a tela mostrava `2 2`, `5.4 5.4`. Agora elas v\u00EAm do plano, com\n-- nome.\n--\n-- S\u00F3 `revenue` e `expense`. \u00C9 o mesmo recorte do V1 (`DRE.tsx:260`): uma conta\n-- de ativo ou passivo n\u00E3o tem linha num demonstrativo de RESULTADO.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.plg_financial_rep_dre;\n\nCREATE VIEW public.plg_financial_rep_dre AS\nWITH cerca AS MATERIALIZED (\n SELECT app.current_tenant_id() AS tenant_id,\n app.visible_units() AS units\n)\n-- \u2500\u2500 1. A estrutura: o plano de contas, com ou sem movimento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nSELECT\n coa.tenant_id,\n NULL::uuid AS unit_id,\n NULL::date AS competence_month,\n CASE coa.account_type WHEN 'revenue' THEN 'receivable' ELSE 'payable' END AS direction,\n coa.id AS account_id,\n coa.code AS account_code,\n coa.name AS account_name,\n coa.account_type,\n coa.parent_id AS account_parent_id,\n coa.code AS statement_code,\n coa.name AS statement_name,\n false AS unclassified,\n 0::bigint AS line_count,\n 0::numeric AS amount,\n 0::numeric AS discount,\n 0::numeric AS quantity\n FROM cerca\n JOIN public.plg_financial_chart_of_accounts coa ON coa.tenant_id = cerca.tenant_id\n WHERE coa.is_active\n AND coa.account_type IN ('revenue', 'expense')\n\nUNION ALL\n\n-- \u2500\u2500 2. O movimento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\n fi.tenant_id,\n fi.unit_id,\n date_trunc('month', fi.competence_date)::date AS competence_month,\n fi.direction,\n coa.id AS account_id,\n coa.code AS account_code,\n coa.name AS account_name,\n coa.account_type,\n coa.parent_id AS account_parent_id,\n -- A conta pela regra do demonstrativo: presente quando a natureza dela casa\n -- com o sentido do t\u00EDtulo, ausente quando n\u00E3o casa (migration 039).\n CASE WHEN (fi.direction = 'receivable' AND coa.account_type = 'revenue')\n OR (fi.direction = 'payable' AND coa.account_type = 'expense')\n THEN coa.code END AS statement_code,\n CASE WHEN (fi.direction = 'receivable' AND coa.account_type = 'revenue')\n OR (fi.direction = 'payable' AND coa.account_type = 'expense')\n THEN coa.name END AS statement_name,\n (coa.id IS NULL) AS unclassified,\n count(*) AS line_count,\n sum(it.total) AS amount,\n sum(it.discount) AS discount,\n sum(it.quantity) AS quantity\n FROM cerca\n JOIN public.plg_financial_invoices fi ON fi.tenant_id = cerca.tenant_id\n JOIN public.orders o ON o.id = fi.order_id\n JOIN public.items it ON it.order_id = o.id\n LEFT JOIN public.plg_financial_chart_of_accounts coa\n ON coa.id = it.chart_account_id\n AND coa.tenant_id = fi.tenant_id\n WHERE fi.cancelled_at IS NULL\n AND it.cancelled_at IS NULL\n AND (fi.unit_id IS NULL OR fi.unit_id = ANY (cerca.units))\n GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12;\n\nCOMMENT ON VIEW public.plg_financial_rep_dre IS\n 'DRE: a \u00C1RVORE do plano de contas (linhas com competence_month nulo, valor '\n 'zero \u2014 a conta existe e n\u00E3o teve movimento) somada ao MOVIMENTO por conta \u00D7 '\n 'm\u00EAs. `statement_code` \u00E9 a conta pela regra do demonstrativo (a natureza tem '\n 'que casar com o sentido); `account_code` \u00E9 a classifica\u00E7\u00E3o crua. Confere com '\n 'o V1 em 376 de 376 chaves. N\u00C3O \u00E9 security_invoker: carrega a pr\u00F3pria cerca '\n 'de tenant e de unidade \u2014 herdar a RLS de `items` custava uma chamada de '\n 'fun\u00E7\u00E3o por linha, 15 s para somar 425 linhas.';\n\nALTER VIEW public.plg_financial_rep_dre SET (security_invoker = false);\nGRANT SELECT ON public.plg_financial_rep_dre TO authenticated, service_role;\n\nDO $$\nBEGIN\n IF to_regclass('public.analytics_read_models') IS NULL THEN\n RAISE NOTICE 'financial 040: motor de analytics n\u00E3o instalado \u2014 read model n\u00E3o registrado';\n RETURN;\n END IF;\n -- `include_undated`: as linhas de ESTRUTURA t\u00EAm `competence_month` nulo e o\n -- recorte de per\u00EDodo as descartaria \u2014 a \u00E1rvore do plano sumiria assim que\n -- algu\u00E9m escolhesse um m\u00EAs. Ver a migration 052 do spine.\n INSERT INTO public.analytics_read_models (name, date_column, tenant_column, description, include_undated)\n VALUES ('plg_financial_rep_dre', 'competence_month', 'tenant_id',\n 'Receitas e despesas por plano de contas e m\u00EAs de compet\u00EAncia, com a \u00E1rvore inteira do plano.',\n true)\n ON CONFLICT (name) DO UPDATE\n SET date_column = EXCLUDED.date_column,\n tenant_column = EXCLUDED.tenant_column,\n description = EXCLUDED.description,\n include_undated = EXCLUDED.include_undated;\nEND $$;\n";
48
+ export declare const MIGRATION_047_A_CONTA_RECONHECE_A_CONTRAPARTE_NA_SEGUNDA_OFERTA = "-- A conta reconhece a contraparte na segunda oferta.\n--\n-- O extrato de uma PESSOA \u2014 profissional, funcion\u00E1rio, fornecedor \u2014 \u00E9 a lista\n-- dos movimentos que citam a conta dela. No V2 esse extrato vinha pela metade.\n-- Medido em 19/09/2026, profissional ALBANEZ EVANI SANTANA DA SILVA\n-- (`professionals #655`, `account_central #654140`):\n--\n-- V1 V2\n-- bill pago 22 R$ 11.061,44 0\n-- bill pendente 13 R$ 8.100,70 0\n-- pagamento 20 R$ 8.629,23 20 R$ 8.629,23 \u2713\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- total 55 R$ 27.791,37 20 R$ 8.629,23\n--\n-- Os pagamentos atravessaram exatos. As CONTAS atravessaram \u2014 o valor est\u00E1\n-- certo, `make parity` fecha \u2014 mas chegaram sem NENHUMA das duas pernas, ent\u00E3o\n-- n\u00E3o citam ningu\u00E9m e n\u00E3o aparecem no extrato de quem devem. No tenant inteiro:\n--\n-- bills sem perna alguma 2.574 R$ 2.224.751,46\n-- payments com as duas pernas 2.018 R$ 1.594.810,09\n--\n-- \u2500\u2500 Por que s\u00F3 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\n--\n-- Ordem. `financial_movements` \u00E9 oferecido antes de `account_central` terminar\n-- de migrar; na primeira oferta as refer\u00EAncias ainda n\u00E3o resolvem e a linha\n-- entra sem pernas. Na segunda oferta elas resolvem \u2014 e o writer devolve:\n--\n-- already in the ledger (append-only)\n--\n-- sem olhar para o que chegou. O `append-only` est\u00E1 certo quanto ao DINHEIRO:\n-- valor, esp\u00E9cie e sentido de um evento n\u00E3o se reescrevem. Ele n\u00E3o deveria\n-- valer para uma perna AUSENTE, e o gatilho da tabela concorda \u2014 ele admite\n-- preencher uma perna nula exatamente uma vez:\n--\n-- OR (OLD.debit_account_id IS NOT NULL AND NEW.debit_account_id IS DISTINCT FROM OLD.debit_account_id)\n--\n-- Quem recusava era o writer, n\u00E3o o modelo. Uma perna nula n\u00E3o \u00E9 um fato: \u00E9 uma\n-- resposta que ainda n\u00E3o chegou.\n--\n-- \u2500\u2500 O que muda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Na repeti\u00E7\u00E3o, a linha que j\u00E1 existe recebe as pernas que vierem resolvidas \u2014\n-- e S\u00D3 as que estiverem vazias. Perna preenchida continua intoc\u00E1vel, que \u00E9 o\n-- que o append-only protege de verdade.\n--\n-- A segunda mudan\u00E7a \u00E9 a que impede o mesmo buraco de voltar: ao montar as\n-- pernas de um `bill`, `plg_financial_legacy_legs` responde \"isto \u00E9 espelho de\n-- obriga\u00E7\u00E3o\" e devolve as duas nulas. O writer aceitava essa resposta mesmo\n-- quando a origem TINHA dado a contraparte, e apagava o que j\u00E1 sabia. Agora ele\n-- s\u00F3 pergunta quando n\u00E3o sabe.\n--\n-- Idempotente. Depois de aplicar, uma passada de `financial_movements`\n-- preenche o que j\u00E1 est\u00E1 l\u00E1.\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_movement(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'movement_kind', ''))), '');\n v_kind text := CASE v_kind_raw\n WHEN 'bill' THEN 'bill' WHEN 'payment' THEN 'payment'\n WHEN 'transfer' THEN 'transfer' WHEN 'commission' THEN 'commission'\n WHEN 'conta' THEN 'bill' WHEN 'titulo' THEN 'bill' WHEN 't\u00EDtulo' THEN 'bill'\n WHEN 'pagamento' THEN 'payment' WHEN 'recebimento' THEN 'payment'\n WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\u00EAncia' THEN 'transfer'\n WHEN 'comissao' THEN 'commission' WHEN 'comiss\u00E3o' THEN 'commission'\n END;\n v_status_raw text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text := CASE v_status_raw\n WHEN 'canceled' THEN 'cancelled'\n WHEN 'cancelado' THEN 'cancelled'\n WHEN 'cancelada' THEN 'cancelled'\n WHEN 'pago' THEN 'paid'\n WHEN 'paga' THEN 'paid'\n WHEN 'pendente' THEN 'pending'\n WHEN 'vencido' THEN 'overdue'\n WHEN 'vencida' THEN 'overdue'\n WHEN 'atrasado' THEN 'overdue'\n WHEN 'parcial' THEN 'partial'\n ELSE coalesce(v_status_raw, 'pending')\n END;\n v_dir text := nullif(btrim(lower(coalesce(p_row ->> 'direction', ''))), '');\n v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);\n v_invoice uuid := (p_row ->> 'financial_invoice_id')::uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_legs record;\n v_debit uuid := (p_row ->> 'debit_account_id')::uuid;\n v_credit uuid := (p_row ->> 'credit_account_id')::uuid;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a movement needs an id');\n END IF;\n IF v_kind IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown movement kind %L (expected bill|payment|transfer|commission, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));\n END IF;\n IF v_dir IS NOT NULL AND v_dir NOT IN ('debit', 'credit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown direction %L (expected debit|credit)', v_dir));\n END IF;\n IF v_dir IS NULL AND v_invoice IS NOT NULL THEN\n SELECT CASE t.direction WHEN 'receivable' THEN 'credit' WHEN 'payable' THEN 'debit' END INTO v_dir\n FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'the movement carries no direction and settles no invoice \u2014 which way the money went cannot be derived, and must not be defaulted');\n END IF;\n IF v_amount < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a money event cannot carry a negative amount \u2014 direction is the direction, not the sign');\n END IF;\n\n IF v_unit IS NULL AND v_invoice IS NOT NULL THEN\n SELECT t.unit_id INTO v_unit FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n\n -- \u2500\u2500 As pernas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 PERGUNTA quando n\u00E3o sabe. A origem que entregou a contraparte tem a\n -- palavra final: `plg_financial_legacy_legs` existe para DEDUZIR a postagem\n -- de um pool que n\u00E3o a tem, n\u00E3o para descartar a que tem.\n IF v_debit IS NULL OR v_credit IS NULL THEN\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n p_tenant, v_kind, v_dir, (p_row ->> 'bank_account_id')::uuid, (p_row ->> 'cash_session_id')::uuid,\n v_invoice, v_meta);\n IF v_legs.o_mirror THEN\n -- Espelho de obriga\u00E7\u00E3o: `legs_check` o admite sem pernas. Mas o que a\n -- origem deu FICA \u2014 apagar a contraparte de uma conta a pagar \u00E9 apagar a\n -- quem se deve, e \u00E9 o extrato da pessoa que some junto.\n v_meta := v_meta || jsonb_build_object('legacy_mirror', true);\n ELSE\n v_debit := coalesce(v_debit, v_legs.o_debit);\n v_credit := coalesce(v_credit, v_legs.o_credit);\n END IF;\n END IF;\n\n SELECT EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE id = p_id) INTO v_exists;\n IF v_exists THEN\n -- Repeti\u00E7\u00E3o. O DINHEIRO n\u00E3o se reescreve \u2014 valor, esp\u00E9cie e sentido ficam\n -- como entraram. O que se completa \u00E9 o que ainda estava vazio: a unidade e\n -- as pernas que a primeira oferta n\u00E3o conseguiu resolver porque\n -- `account_central` ainda n\u00E3o tinha migrado. O gatilho append-only admite\n -- exatamente isto \u2014 preencher uma perna nula uma vez \u2014 e recusa reescrever\n -- uma preenchida, que \u00E9 a trava que importa.\n UPDATE public.plg_financial_movements SET\n unit_id = coalesce(unit_id, v_unit),\n debit_account_id = coalesce(debit_account_id, v_debit),\n credit_account_id = coalesce(credit_account_id, v_credit)\n WHERE id = p_id\n AND ( (unit_id IS NULL AND v_unit IS NOT NULL)\n OR (debit_account_id IS NULL AND v_debit IS NOT NULL)\n OR (credit_account_id IS NULL AND v_credit IS NOT NULL));\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', 'already in the ledger (append-only; empty legs and unit completed)');\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (id, tenant_id, unit_id, financial_invoice_id, direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, notes, metadata, debit_account_id, credit_account_id)\n VALUES (p_id, p_tenant, v_unit, v_invoice, v_dir, v_kind, v_amount,\n round(coalesce((p_row ->> 'paid_amount')::numeric, 0), 2),\n v_status,\n coalesce((p_row ->> 'due_date')::date, (p_row ->> 'paid_at')::date, current_date),\n (p_row ->> 'paid_at')::date,\n (p_row ->> 'installment_number')::integer, p_row ->> 'notes', v_meta, v_debit, v_credit);\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_movements', p_id, jsonb_build_object('reason', 'created')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');\nEND $function$;\n";
49
+ export declare const MIGRATION_048_A_PARCELA_E_A_CONTA_DO_V1_E_A_BAIXA_E_A_ALOCACAO = "-- A parcela \u00E9 a conta do V1, e a baixa \u00E9 a aloca\u00E7\u00E3o.\n--\n-- Nenhuma conta a pagar aparecia como PAGA no V2. Todas, sem exce\u00E7\u00E3o, liam\n-- \"Aberto\" ou \"Vencido\" \u2014 inclusive as 22 contas de ALBANEZ que o V1 mostra\n-- quitadas, inclusive R$ 161.301,71 de pagamentos de setembro.\n--\n-- N\u00E3o era o dado: `make parity` fecha, e o movimento de conta chega com\n-- `status = 'paid'` e `paid_amount` preenchido. Era a CADEIA que a tela l\u00EA:\n--\n-- v_financial_invoice_balances o que a lista mostra\n-- \u2192 v_financial_installment_balances\n-- \u2192 plg_financial_invoice_installments as parcelas\n-- \u2192 plg_financial_allocations o que liga pagamento \u00E0 parcela\n--\n-- E `plg_financial_allocations` estava VAZIA. O V1 tem 2.021 linhas de\n-- `payment_allocations`, R$ 1.588.723,76, e a tabela n\u00E3o tinha linha nenhuma na\n-- `migration.allowlist`. Sem aloca\u00E7\u00E3o, `paid` \u00E9 zero para toda parcela, e o\n-- t\u00EDtulo inteiro l\u00EA como n\u00E3o pago \u2014 por mais que o movimento diga o contr\u00E1rio.\n--\n-- \u2500\u2500 A ponte, e por que ela \u00E9 o id \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `payment_allocations` liga PAGAMENTO a CONTA (`bill_movement_id`); o V2 liga\n-- pagamento a PARCELA (`installment_id`). Para atravessar, a conta do V1 tem\n-- que virar uma parcela do V2 \u2014 e ela \u00C9 uma: no V1 uma compra em 4\u00D7 s\u00E3o quatro\n-- linhas de `financial_movements`, cada uma com o seu vencimento e o seu valor.\n-- \u00C9 a defini\u00E7\u00E3o de parcela.\n--\n-- Ent\u00E3o a parcela nasce com o MESMO id do movimento de conta. Um para um, sem\n-- tabela de tradu\u00E7\u00E3o: a aloca\u00E7\u00E3o resolve `bill_movement_id` pelo ledger e o\n-- `target_id` que volta j\u00E1 \u00E9 o `installment_id`. Nada a adivinhar e nada a\n-- guardar de lado.\n--\n-- \u2500\u2500 A parcela \u00FAnica que j\u00E1 estava l\u00E1 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 `extra` do writer de venda cria UMA parcela por t\u00EDtulo, com o valor cheio.\n-- Para 2.331 das 2.420 faturas do tenant isso est\u00E1 certo \u2014 elas t\u00EAm uma parcela\n-- s\u00F3. Para as 89 restantes est\u00E1 errado: a fatura #1869 tem 6 parcelas de\n-- R$ 966,67 no V1 e virava uma de R$ 5.800,00.\n--\n-- Quando a conta do V1 chega, ela manda: a parcela dela entra e a parcela\n-- gen\u00E9rica do t\u00EDtulo sai \u2014 reconhec\u00EDvel porque n\u00E3o tem `metadata.migration`.\n-- Sai s\u00F3 a que ningu\u00E9m referencia; a que j\u00E1 tem baixa fica, e o problema\n-- aparece em vez de sumir.\n--\n-- Idempotente. Depois de aplicar, `mirror-remap financial_movements` e uma\n-- passada refazem as parcelas; `payment_allocations` entra na passada seguinte.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. A conta do V1 vira a parcela do V2\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration._fin_installment_from_bill(\n p_tenant uuid, p_id uuid, p_invoice uuid, p_unit uuid,\n p_number integer, p_due date, p_amount numeric\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE v_number integer := greatest(coalesce(p_number, 1), 1);\nBEGIN\n IF p_invoice IS NULL THEN RETURN; END IF;\n\n -- A parcela gen\u00E9rica do t\u00EDtulo sai para dar lugar \u00E0 do V1 \u2014 mas s\u00F3 a que n\u00E3o\n -- veio da migra\u00E7\u00E3o E que ningu\u00E9m referencia. Uma parcela com baixa fica onde\n -- est\u00E1: apag\u00E1-la levaria a baixa junto (ON DELETE CASCADE) e o dinheiro\n -- sumiria sem ningu\u00E9m pedir.\n DELETE FROM public.plg_financial_invoice_installments i\n WHERE i.invoice_id = p_invoice\n AND i.id <> p_id\n AND NOT (i.metadata ? 'migration')\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_allocations a WHERE a.installment_id = i.id)\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_movements m WHERE m.financial_invoice_installment_id = i.id);\n\n INSERT INTO public.plg_financial_invoice_installments\n (id, tenant_id, unit_id, invoice_id, number, due_date, amount, metadata)\n VALUES (p_id, p_tenant, p_unit, p_invoice, v_number, p_due, greatest(coalesce(p_amount, 0), 0),\n jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements')))\n ON CONFLICT (id) DO UPDATE\n SET number = EXCLUDED.number,\n due_date = EXCLUDED.due_date,\n amount = EXCLUDED.amount,\n unit_id = coalesce(public.plg_financial_invoice_installments.unit_id, EXCLUDED.unit_id),\n metadata = public.plg_financial_invoice_installments.metadata || EXCLUDED.metadata;\nEXCEPTION\n -- `UNIQUE (invoice_id, number)`: duas contas do V1 com o mesmo n\u00FAmero de\n -- parcela no mesmo t\u00EDtulo existem \u2014 a fatura #1869 tem duas com vencimento\n -- 19/08. Recusar a segunda perderia dinheiro; ela entra no pr\u00F3ximo n\u00FAmero\n -- livre, que \u00E9 o que a tela precisa para somar certo.\n WHEN unique_violation THEN\n INSERT INTO public.plg_financial_invoice_installments\n (id, tenant_id, unit_id, invoice_id, number, due_date, amount, metadata)\n SELECT p_id, p_tenant, p_unit, p_invoice,\n coalesce(max(i.number), 0) + 1, p_due, greatest(coalesce(p_amount, 0), 0),\n jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements', 'renumbered', true))\n FROM public.plg_financial_invoice_installments i\n WHERE i.invoice_id = p_invoice\n ON CONFLICT (id) DO NOTHING;\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O writer do movimento cria a parcela e se liga a ela\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_movement(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'movement_kind', ''))), '');\n v_kind text := CASE v_kind_raw\n WHEN 'bill' THEN 'bill' WHEN 'payment' THEN 'payment'\n WHEN 'transfer' THEN 'transfer' WHEN 'commission' THEN 'commission'\n WHEN 'conta' THEN 'bill' WHEN 'titulo' THEN 'bill' WHEN 't\u00EDtulo' THEN 'bill'\n WHEN 'pagamento' THEN 'payment' WHEN 'recebimento' THEN 'payment'\n WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\u00EAncia' THEN 'transfer'\n WHEN 'comissao' THEN 'commission' WHEN 'comiss\u00E3o' THEN 'commission'\n END;\n v_status_raw text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text := CASE v_status_raw\n WHEN 'canceled' THEN 'cancelled'\n WHEN 'cancelado' THEN 'cancelled'\n WHEN 'cancelada' THEN 'cancelled'\n WHEN 'pago' THEN 'paid'\n WHEN 'paga' THEN 'paid'\n WHEN 'pendente' THEN 'pending'\n WHEN 'vencido' THEN 'overdue'\n WHEN 'vencida' THEN 'overdue'\n WHEN 'atrasado' THEN 'overdue'\n WHEN 'parcial' THEN 'partial'\n ELSE coalesce(v_status_raw, 'pending')\n END;\n v_dir text := nullif(btrim(lower(coalesce(p_row ->> 'direction', ''))), '');\n v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);\n v_invoice uuid := (p_row ->> 'financial_invoice_id')::uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_due date;\n v_legs record;\n v_debit uuid := (p_row ->> 'debit_account_id')::uuid;\n v_credit uuid := (p_row ->> 'credit_account_id')::uuid;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a movement needs an id');\n END IF;\n IF v_kind IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown movement kind %L (expected bill|payment|transfer|commission, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));\n END IF;\n IF v_dir IS NOT NULL AND v_dir NOT IN ('debit', 'credit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown direction %L (expected debit|credit)', v_dir));\n END IF;\n IF v_dir IS NULL AND v_invoice IS NOT NULL THEN\n SELECT CASE t.direction WHEN 'receivable' THEN 'credit' WHEN 'payable' THEN 'debit' END INTO v_dir\n FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'the movement carries no direction and settles no invoice \u2014 which way the money went cannot be derived, and must not be defaulted');\n END IF;\n IF v_amount < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a money event cannot carry a negative amount \u2014 direction is the direction, not the sign');\n END IF;\n\n IF v_unit IS NULL AND v_invoice IS NOT NULL THEN\n SELECT t.unit_id INTO v_unit FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n\n v_due := coalesce((p_row ->> 'due_date')::date, (p_row ->> 'paid_at')::date, current_date);\n\n -- S\u00F3 pergunta as pernas quando n\u00E3o sabe (migration 041).\n IF v_debit IS NULL OR v_credit IS NULL THEN\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n p_tenant, v_kind, v_dir, (p_row ->> 'bank_account_id')::uuid, (p_row ->> 'cash_session_id')::uuid,\n v_invoice, v_meta);\n IF v_legs.o_mirror THEN\n v_meta := v_meta || jsonb_build_object('legacy_mirror', true);\n ELSE\n v_debit := coalesce(v_debit, v_legs.o_debit);\n v_credit := coalesce(v_credit, v_legs.o_credit);\n END IF;\n END IF;\n\n -- A conta do V1 \u00C9 a parcela do V2, com o mesmo id.\n IF v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN\n PERFORM migration._fin_installment_from_bill(\n p_tenant, p_id, v_invoice, v_unit,\n (p_row ->> 'installment_number')::integer, v_due, v_amount);\n END IF;\n\n SELECT EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE id = p_id) INTO v_exists;\n IF v_exists THEN\n UPDATE public.plg_financial_movements SET\n unit_id = coalesce(unit_id, v_unit),\n debit_account_id = coalesce(debit_account_id, v_debit),\n credit_account_id = coalesce(credit_account_id, v_credit),\n financial_invoice_installment_id = coalesce(\n financial_invoice_installment_id,\n CASE WHEN v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN p_id END)\n WHERE id = p_id;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', 'already in the ledger (append-only; empty legs, unit and installment completed)');\n END IF;\n\n INSERT INTO public.plg_financial_movements\n (id, tenant_id, unit_id, financial_invoice_id, financial_invoice_installment_id,\n direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, notes, metadata, debit_account_id, credit_account_id)\n VALUES (p_id, p_tenant, v_unit, v_invoice,\n CASE WHEN v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN p_id END,\n v_dir, v_kind, v_amount,\n round(coalesce((p_row ->> 'paid_amount')::numeric, 0), 2),\n v_status, v_due, (p_row ->> 'paid_at')::date,\n (p_row ->> 'installment_number')::integer, p_row ->> 'notes', v_meta, v_debit, v_credit);\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_movements', p_id, jsonb_build_object('reason', 'created')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 3. A baixa: quem pagou o qu\u00EA\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_allocation(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_payment uuid := (p_row ->> 'payment_movement_id')::uuid;\n v_bill uuid := (p_row ->> 'installment_id')::uuid;\n v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);\n v_unit uuid;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_payment IS NULL OR v_bill IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma baixa precisa do pagamento e da conta que ela quita');\n END IF;\n -- Zero n\u00E3o \u00E9 baixa. O CHECK da tabela exige `amount > 0` e recusar aqui, com a\n -- frase, \u00E9 melhor que o erro do banco no meio do lote.\n IF v_amount <= 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', 'uma baixa de valor zero ou negativo n\u00E3o quita nada');\n END IF;\n\n -- A parcela tem o id do movimento de conta (parte 1 deste arquivo). Se ela\n -- n\u00E3o existe, a conta ainda n\u00E3o atravessou \u2014 quarentena de `fk`, e a segunda\n -- passada resolve. Inventar a parcela aqui criaria d\u00EDvida que o V1 n\u00E3o tem.\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_invoice_installments i\n WHERE i.id = v_bill AND i.tenant_id = p_tenant) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a conta que esta baixa quita ainda n\u00E3o virou parcela (%s)', v_bill));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_movements m\n WHERE m.id = v_payment AND m.tenant_id = p_tenant) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('o pagamento desta baixa ainda n\u00E3o atravessou (%s)', v_payment));\n END IF;\n\n SELECT m.unit_id INTO v_unit FROM public.plg_financial_movements m WHERE m.id = v_payment;\n\n INSERT INTO public.plg_financial_allocations\n (id, tenant_id, unit_id, payment_movement_id, installment_id, amount, metadata)\n VALUES (p_id, p_tenant, v_unit, v_payment, v_bill, v_amount,\n coalesce(p_row -> 'metadata', '{}'::jsonb))\n ON CONFLICT (payment_movement_id, installment_id) DO UPDATE\n SET amount = EXCLUDED.amount;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_allocations', p_id, jsonb_build_object('reason', 'created')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');\nEND $function$;\n\n-- O vocabul\u00E1rio de writers \u00E9 fechado por CHECK \u2014 de prop\u00F3sito: um writer\n-- escrito errado na allowlist seria uma tabela inteira caindo em sil\u00EAncio. O\n-- nome novo entra na lista antes de ser usado.\nDO $$\nDECLARE v_def text;\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n IF v_def IS NULL OR strpos(v_def, 'fin_allocation') > 0 THEN RETURN; END IF;\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''fin_movement''::text',\n '''fin_movement''::text, ''fin_allocation''::text');\nEND $$;\n\n-- A exclus\u00E3o foi ADIAMENTO, e o motivo dela acabou.\n--\n-- Deferred: V1 links a payment movement to a BILL MOVEMENT; V2 links it to\n-- an INSTALLMENT\n--\n-- Era verdade e era o impedimento certo: sem a ponte, a baixa n\u00E3o tinha para\n-- onde apontar. A ponte existe agora \u2014 a conta do V1 \u00E9 a parcela do V2, com o\n-- mesmo id \u2014 ent\u00E3o a linha sai da lista de exclu\u00EDdas em vez de continuar l\u00E1\n-- como uma decis\u00E3o que ningu\u00E9m revisita.\nDELETE FROM migration.excluded_tables WHERE source_table = 'payment_allocations';\n\n-- The row is already frozen, and trg_allowlist_frozen refuses a plain write.\n-- A migration file is the one writer the hatch exists for (same idiom as\n-- 033/035); SET LOCAL so the unlock dies with this transaction.\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'payment_allocations', 'public.plg_financial_allocations', 'financial',\n 'fin_allocation', '{}'::jsonb, ARRAY['amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'payment_movement_id', 'payment_movement_id@financial_movements',\n -- A conta do V1 \u00E9 a parcela do V2, e as duas t\u00EAm o mesmo id: resolver pelo\n -- ledger de `financial_movements` devolve exatamente o `installment_id`.\n 'bill_movement_id', 'installment_id@financial_movements',\n 'allocated_amount', 'amount',\n 'origin', 'jsonb:v1.origin',\n 'origin_description', 'jsonb:v1.origin_description',\n 'origin_invoice_item_id', 'jsonb:v1.origin_invoice_item_ref',\n 'origin_service_id', 'jsonb:v1.origin_service_ref',\n 'origin_product_id', 'jsonb:v1.origin_product_ref',\n 'created_by', '-'\n ),\n 'A baixa: quanto de QUAL pagamento quitou QUAL parcela. Sem ela toda parcela '\n 'l\u00EA `paid = 0` e todo t\u00EDtulo aparece em aberto, por mais que o movimento diga '\n 'que foi pago \u2014 era R$ 161.301,71 de setembro invis\u00EDveis na tela.'\n);\n";
50
+ export declare const MIGRATION_049_A_PARCELA_CARREGA_O_CANCELAMENTO = "-- A parcela carrega o cancelamento.\n--\n-- Conserto de uma regress\u00E3o que a migration 042 introduziu, achada pela\n-- asser\u00E7\u00E3o de paridade no mesmo dia: depois de portar as baixas, agosto passou\n-- a ter R$ 13.939,00 A MAIS no V2 que no V1.\n--\n-- A (V1) 662.018,65\n-- B (V2) 675.957,65 \u0394 +13.939,00 em 5 movimentos\n--\n-- As cinco contas est\u00E3o `canceled` no V1 e apareceram `paid` no V2. Elas n\u00E3o\n-- foram inventadas \u2014 o valor estava certo e o cancelamento \u00E9 que sumiu.\n--\n-- \u2500\u2500 Quem apagou o cancelamento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_sync_mirror` recalcula o estado da conta a partir da PARCELA,\n-- toda vez que uma baixa entra:\n--\n-- status = CASE WHEN v_cancelled THEN 'cancelled'\n-- WHEN v_paid >= v_amount THEN 'paid'\n-- WHEN v_paid > 0 THEN 'partial'\n-- ...\n--\n-- e `v_cancelled` vem de `installment.cancelled_at`. A 042 criou a parcela a\n-- partir da conta do V1 e n\u00E3o levou o cancelamento junto: parcela viva, baixa\n-- cheia, conta \"paga\". O gatilho fez exatamente o que devia com a informa\u00E7\u00E3o\n-- que tinha.\n--\n-- O modelo est\u00E1 certo e o erro era meu. Num t\u00EDtulo parcelado quem se cancela \u00E9\n-- a PARCELA \u2014 d\u00E1 para cancelar a terceira e manter as duas primeiras \u2014, ent\u00E3o \u00E9\n-- ela que carrega o carimbo, e o movimento \u00E9 espelho dela. Carimbar o movimento\n-- e deixar a parcela viva \u00E9 escrever a resposta em dois lugares e deixar um\n-- deles desatualizado.\n--\n-- \u2500\u2500 O conserto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 parcela nasce cancelada quando a conta do V1 est\u00E1 cancelada, e o writer\n-- chama o sincronizador logo em seguida, para que o espelho acompanhe sem\n-- esperar uma baixa que talvez nunca venha.\n--\n-- Idempotente. Depois de aplicar: `mirror-remap financial_movements` e uma\n-- passada carimbam as parcelas que j\u00E1 est\u00E3o l\u00E1.\n\nCREATE OR REPLACE FUNCTION migration._fin_installment_from_bill(\n p_tenant uuid, p_id uuid, p_invoice uuid, p_unit uuid,\n p_number integer, p_due date, p_amount numeric, p_cancelled boolean DEFAULT false\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_number integer := greatest(coalesce(p_number, 1), 1);\n -- `now()` seria a hora desta migration, n\u00E3o a do cancelamento. O V1 n\u00E3o\n -- guarda quando a conta foi cancelada \u2014 guarda s\u00F3 que foi \u2014, ent\u00E3o o carimbo\n -- fica na data de VENCIMENTO: \u00E9 a data que a linha j\u00E1 tem e a \u00FAnica que n\u00E3o\n -- inventa um fato novo. Quem precisar do instante exato vai ao V1.\n v_cancel timestamptz := CASE WHEN coalesce(p_cancelled, false)\n THEN coalesce(p_due, current_date)::timestamptz END;\nBEGIN\n IF p_invoice IS NULL THEN RETURN; END IF;\n\n DELETE FROM public.plg_financial_invoice_installments i\n WHERE i.invoice_id = p_invoice\n AND i.id <> p_id\n AND NOT (i.metadata ? 'migration')\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_allocations a WHERE a.installment_id = i.id)\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_movements m WHERE m.financial_invoice_installment_id = i.id);\n\n BEGIN\n INSERT INTO public.plg_financial_invoice_installments\n (id, tenant_id, unit_id, invoice_id, number, due_date, amount, cancelled_at, metadata)\n VALUES (p_id, p_tenant, p_unit, p_invoice, v_number, p_due,\n greatest(coalesce(p_amount, 0), 0), v_cancel,\n jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements')))\n ON CONFLICT (id) DO UPDATE\n SET number = EXCLUDED.number,\n due_date = EXCLUDED.due_date,\n amount = EXCLUDED.amount,\n -- O cancelamento \u00E9 da ORIGEM e ela manda nos dois sentidos: uma conta\n -- que voltou a valer no V1 volta a valer aqui.\n cancelled_at = EXCLUDED.cancelled_at,\n unit_id = coalesce(public.plg_financial_invoice_installments.unit_id, EXCLUDED.unit_id),\n metadata = public.plg_financial_invoice_installments.metadata || EXCLUDED.metadata;\n EXCEPTION WHEN unique_violation THEN\n INSERT INTO public.plg_financial_invoice_installments\n (id, tenant_id, unit_id, invoice_id, number, due_date, amount, cancelled_at, metadata)\n SELECT p_id, p_tenant, p_unit, p_invoice,\n coalesce(max(i.number), 0) + 1, p_due, greatest(coalesce(p_amount, 0), 0), v_cancel,\n jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements', 'renumbered', true))\n FROM public.plg_financial_invoice_installments i\n WHERE i.invoice_id = p_invoice\n ON CONFLICT (id) DO NOTHING;\n END;\n\n -- O espelho acompanha agora, e n\u00E3o na pr\u00F3xima baixa. Uma conta cancelada sem\n -- baixa nenhuma nunca receberia a visita do gatilho \u2014 e \u00E9 justamente ela que\n -- n\u00E3o pode ficar contando como viva.\n PERFORM public.plg_financial_sync_mirror(p_id);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- O writer passa o cancelamento adiante\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 linha muda no corpo da 042: o oitavo argumento. O resto \u00E9 igual.\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_movement(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'movement_kind', ''))), '');\n v_kind text := CASE v_kind_raw\n WHEN 'bill' THEN 'bill' WHEN 'payment' THEN 'payment'\n WHEN 'transfer' THEN 'transfer' WHEN 'commission' THEN 'commission'\n WHEN 'conta' THEN 'bill' WHEN 'titulo' THEN 'bill' WHEN 't\u00EDtulo' THEN 'bill'\n WHEN 'pagamento' THEN 'payment' WHEN 'recebimento' THEN 'payment'\n WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\u00EAncia' THEN 'transfer'\n WHEN 'comissao' THEN 'commission' WHEN 'comiss\u00E3o' THEN 'commission'\n END;\n v_status_raw text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text := CASE v_status_raw\n WHEN 'canceled' THEN 'cancelled'\n WHEN 'cancelado' THEN 'cancelled'\n WHEN 'cancelada' THEN 'cancelled'\n WHEN 'pago' THEN 'paid'\n WHEN 'paga' THEN 'paid'\n WHEN 'pendente' THEN 'pending'\n WHEN 'vencido' THEN 'overdue'\n WHEN 'vencida' THEN 'overdue'\n WHEN 'atrasado' THEN 'overdue'\n WHEN 'parcial' THEN 'partial'\n ELSE coalesce(v_status_raw, 'pending')\n END;\n v_dir text := nullif(btrim(lower(coalesce(p_row ->> 'direction', ''))), '');\n v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);\n v_invoice uuid := (p_row ->> 'financial_invoice_id')::uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_due date;\n v_legs record;\n v_debit uuid := (p_row ->> 'debit_account_id')::uuid;\n v_credit uuid := (p_row ->> 'credit_account_id')::uuid;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a movement needs an id');\n END IF;\n IF v_kind IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown movement kind %L (expected bill|payment|transfer|commission, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));\n END IF;\n IF v_dir IS NOT NULL AND v_dir NOT IN ('debit', 'credit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown direction %L (expected debit|credit)', v_dir));\n END IF;\n IF v_dir IS NULL AND v_invoice IS NOT NULL THEN\n SELECT CASE t.direction WHEN 'receivable' THEN 'credit' WHEN 'payable' THEN 'debit' END INTO v_dir\n FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'the movement carries no direction and settles no invoice \u2014 which way the money went cannot be derived, and must not be defaulted');\n END IF;\n IF v_amount < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a money event cannot carry a negative amount \u2014 direction is the direction, not the sign');\n END IF;\n\n IF v_unit IS NULL AND v_invoice IS NOT NULL THEN\n SELECT t.unit_id INTO v_unit FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n\n v_due := coalesce((p_row ->> 'due_date')::date, (p_row ->> 'paid_at')::date, current_date);\n\n IF v_debit IS NULL OR v_credit IS NULL THEN\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n p_tenant, v_kind, v_dir, (p_row ->> 'bank_account_id')::uuid, (p_row ->> 'cash_session_id')::uuid,\n v_invoice, v_meta);\n IF v_legs.o_mirror THEN\n v_meta := v_meta || jsonb_build_object('legacy_mirror', true);\n ELSE\n v_debit := coalesce(v_debit, v_legs.o_debit);\n v_credit := coalesce(v_credit, v_legs.o_credit);\n END IF;\n END IF;\n\n SELECT EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE id = p_id) INTO v_exists;\n IF NOT v_exists THEN\n INSERT INTO public.plg_financial_movements\n (id, tenant_id, unit_id, financial_invoice_id, financial_invoice_installment_id,\n direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, notes, metadata, debit_account_id, credit_account_id)\n VALUES (p_id, p_tenant, v_unit, v_invoice,\n CASE WHEN v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN p_id END,\n v_dir, v_kind, v_amount,\n round(coalesce((p_row ->> 'paid_amount')::numeric, 0), 2),\n v_status, v_due, (p_row ->> 'paid_at')::date,\n (p_row ->> 'installment_number')::integer, p_row ->> 'notes', v_meta, v_debit, v_credit);\n ELSE\n UPDATE public.plg_financial_movements SET\n unit_id = coalesce(unit_id, v_unit),\n debit_account_id = coalesce(debit_account_id, v_debit),\n credit_account_id = coalesce(credit_account_id, v_credit),\n financial_invoice_installment_id = coalesce(\n financial_invoice_installment_id,\n CASE WHEN v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN p_id END)\n WHERE id = p_id;\n END IF;\n\n -- DEPOIS do movimento existir: a parcela referencia o t\u00EDtulo e o\n -- sincronizador procura o movimento por `financial_invoice_installment_id`.\n -- Na 042 isto vinha antes, e no primeiro porte a sincronia n\u00E3o achava nada\n -- para espelhar.\n IF v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN\n PERFORM migration._fin_installment_from_bill(\n p_tenant, p_id, v_invoice, v_unit,\n (p_row ->> 'installment_number')::integer, v_due, v_amount,\n v_status = 'cancelled');\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_movements', p_id, jsonb_build_object('reason', CASE WHEN v_exists THEN 'updated' ELSE 'created' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_exists THEN 'already in the ledger (append-only; legs, unit, installment and cancellation completed)' ELSE 'created' END);\nEND $function$;\n";
51
+ export declare const MIGRATION_050_O_SALDO_DO_TITULO_SE_CALCULA_UMA_VEZ = "-- O saldo do t\u00EDtulo se calcula uma vez, n\u00E3o uma vez por t\u00EDtulo.\n--\n-- `v_financial_invoice_balances` \u00E9 a view que a lista de Contas a Pagar l\u00EA. Ela\n-- pendurava um `LEFT JOIN LATERAL` por t\u00EDtulo sobre\n-- `v_financial_installment_balances` \u2014 e essa view come\u00E7a com:\n--\n-- WITH paid AS (\n-- SELECT a.installment_id, sum(a.amount)\n-- FROM plg_financial_allocations a JOIN plg_financial_movements m ...\n-- GROUP BY a.installment_id) -- \u2190 TODAS as baixas do pool\n--\n-- Dentro de um LATERAL, esse agregado \u00E9 refeito para cada t\u00EDtulo. Enquanto a\n-- tabela de baixas estava vazia ningu\u00E9m sentiu; no dia em que ela recebeu as\n-- 2.020 baixas do V1 (migration 042), a conta virou quadr\u00E1tica e a tela passou\n-- a responder 500:\n--\n-- canceling statement due to statement timeout\n--\n-- Medido em 19/09/2026, tenant Pertinho do C\u00E9u, 2.375 t\u00EDtulos:\n--\n-- p\u00E1gina de 50 (o que a tela pede) 0,10 \u2013 0,69 s\n-- contagem exata (o `count` do PostgREST) 1,55 \u2013 2,76 s\n-- a view inteira estoura\n--\n-- A p\u00E1gina passava porque o planejador s\u00F3 avalia o LATERAL das 50 linhas que\n-- sobrevivem ao `LIMIT`. A contagem n\u00E3o tem limite \u2014 e \u00E9 ela que o PostgREST\n-- pede junto, no mesmo request.\n--\n-- \u2500\u2500 O conserto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 passada. As baixas viram um agregado por parcela; as parcelas viram um\n-- agregado por t\u00EDtulo; o t\u00EDtulo junta. Nada \u00E9 recalculado por linha, e o\n-- resultado \u00E9 o MESMO \u2014 as mesmas colunas, a mesma aritm\u00E9tica, as mesmas\n-- palavras de status.\n--\n-- `v_financial_installment_balances` fica como est\u00E1: ela \u00E9 a granularidade de\n-- parcela e outras telas a leem. O que muda \u00E9 que o saldo do T\u00CDTULO deixa de\n-- passar por dentro dela.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_balances CASCADE;\n\nCREATE VIEW public.v_financial_invoice_balances AS\n-- O que cada parcela recebeu. Uma vez, para o pool inteiro.\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),\n-- O estado de cada parcela, com a mesma regra de\n-- `v_financial_installment_balances` \u2014 se as duas divergirem, a lista e o\n-- detalhe passam a contar hist\u00F3rias diferentes sobre a mesma conta.\nparcela AS (\n SELECT i.invoice_id,\n i.amount,\n i.due_date,\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 coalesce(p.paid, 0)::numeric AS paid\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),\n-- E o que o t\u00EDtulo tem, somando as parcelas dele.\ntitulo 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 parcela\n GROUP BY invoice_id\n)\nSELECT 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 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 titulo ib ON ib.invoice_id = t.id\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_balances IS\n 'Saldo e estado de cada t\u00EDtulo, das parcelas dele. UMA passada: as baixas '\n 'agregam por parcela, as parcelas por t\u00EDtulo. A forma anterior pendurava um '\n 'LATERAL por t\u00EDtulo sobre uma view que somava TODAS as baixas do pool \u2014 '\n 'quadr\u00E1tico, e a lista de contas a pagar passou a dar timeout no dia em que a '\n 'tabela de baixas deixou de estar vazia.';\n\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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A view que dependia dela volta igual\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `v_invoice_balances` (o saldo por PEDIDO, que `plg_financial_rep_receivables`\n-- l\u00EA) \u00E9 derrubada pelo CASCADE acima. Corpo extra\u00EDdo do pr\u00F3prio cluster com\n-- `pg_get_viewdef` e recriado sem uma v\u00EDrgula de diferen\u00E7a \u2014 o que muda debaixo\n-- dela \u00E9 s\u00F3 a velocidade.\n\nCREATE 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'::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 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 orders o ON o.id = tb.order_id;\n\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated, service_role;\n";
52
+ export declare const MIGRATION_051_O_SALDO_SE_CALCULA_SO_NA_LINHA_QUE_A_TELA_MOSTRA = "-- O saldo se calcula s\u00F3 na linha que a tela mostra.\n--\n-- Segunda passada no mesmo problema. A migration 044 achatou\n-- `v_financial_invoice_balances` em CTEs para matar o custo quadr\u00E1tico \u2014 e\n-- matou, para a CONTAGEM. Mas trocou o problema de lugar: uma CTE \u00E9\n-- materializada inteira antes de o `LIMIT` existir, ent\u00E3o a consulta que a tela\n-- realmente faz voltou a custar caro.\n--\n-- Medido em 19/09/2026, tenant Pertinho do C\u00E9u, 2.375 t\u00EDtulos, com estat\u00EDsticas\n-- frescas:\n--\n-- antes (044)\n-- contagem exata, sem colunas 1,0 s\n-- select=* order by competence_date limit 50 7,1 s \u2190 a tela\n--\n-- Sete segundos contra um `statement_timeout` de 8 s no papel `authenticated`:\n-- a lista abria vazia de vez em quando, sem dizer por qu\u00EA. A forma achatada\n-- calcula o saldo dos 2.375 t\u00EDtulos para mostrar 50.\n--\n-- \u2500\u2500 A terceira forma \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Nem o LATERAL original nem as CTEs: LATERAL **correlacionado at\u00E9 o fim**.\n--\n-- O LATERAL de antes era caro porque o que morava dentro dele n\u00E3o era\n-- correlacionado \u2014 `v_financial_installment_balances` soma TODAS as baixas do\n-- pool num `WITH`, e um agregado sem correla\u00E7\u00E3o dentro de um la\u00E7o \u00E9 refeito a\n-- cada volta. Aqui cada n\u00EDvel filtra pela linha de fora:\n--\n-- parcelas WHERE i.invoice_id = t.id \u2192 \u00EDndice (invoice_id, number)\n-- baixas WHERE a.installment_id = i.id \u2192 \u00EDndice (installment_id)\n--\n-- Assim o planejador s\u00F3 visita as parcelas do t\u00EDtulo que sobreviveu ao `LIMIT`,\n-- e s\u00F3 as baixas daquelas parcelas. Cinquenta t\u00EDtulos \u00D7 poucas parcelas \u00D7\n-- poucas baixas.\n--\n-- A aritm\u00E9tica e as palavras de status seguem id\u00EAnticas \u00E0s da 044 e \u00E0s de\n-- `v_financial_installment_balances` \u2014 se as tr\u00EAs divergirem, a lista, o\n-- detalhe e o relat\u00F3rio passam a contar hist\u00F3rias diferentes sobre a mesma\n-- conta.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_balances CASCADE;\n\nCREATE VIEW public.v_financial_invoice_balances AS\nSELECT 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 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 LATERAL (\n SELECT count(*) FILTER (WHERE s.st <> 'cancelled') AS installment_count,\n sum(i.amount) FILTER (WHERE s.st <> 'cancelled') AS billed,\n sum(s.paid) AS paid,\n bool_or(s.st = 'overdue') AS has_overdue,\n min(i.due_date) FILTER (WHERE s.st = ANY (ARRAY['pending','partial','overdue'])) AS next_due_date,\n max(i.due_date) FILTER (WHERE s.st <> 'cancelled') AS last_due_date\n FROM public.plg_financial_invoice_installments i\n -- Correlacionado em `i.id`: s\u00F3 as baixas DESTA parcela, pelo \u00EDndice.\n LEFT JOIN LATERAL (\n SELECT 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 a.installment_id = i.id\n AND m.status <> 'cancelled'\n AND m.reversed_by_id IS NULL\n ) pa ON true\n CROSS JOIN LATERAL (\n SELECT COALESCE(pa.paid, 0::numeric) AS paid,\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(pa.paid, 0::numeric) >= i.amount THEN 'paid'\n WHEN COALESCE(pa.paid, 0::numeric) > 0::numeric THEN 'partial'\n WHEN i.due_date < CURRENT_DATE THEN 'overdue'\n ELSE 'pending'\n END AS st\n ) s\n WHERE i.invoice_id = t.id\n ) ib ON true\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_balances IS\n 'Saldo e estado de cada t\u00EDtulo, das parcelas dele. LATERAL correlacionado em '\n 'todos os n\u00EDveis (parcela por invoice_id, baixa por installment_id), para que '\n 'o c\u00E1lculo aconte\u00E7a s\u00F3 nas linhas que a consulta pediu \u2014 a forma anterior '\n 'somava os 2.375 t\u00EDtulos para mostrar 50, e a tela levava 7 s contra um '\n 'statement_timeout de 8 s.';\n\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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A view que dependia dela volta igual\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `v_invoice_balances` (o saldo por PEDIDO, que `plg_financial_rep_receivables`\n-- l\u00EA) \u00E9 derrubada pelo CASCADE acima. Corpo extra\u00EDdo do pr\u00F3prio cluster com\n-- `pg_get_viewdef` e recriado sem uma v\u00EDrgula de diferen\u00E7a \u2014 o que muda debaixo\n-- dela \u00E9 s\u00F3 a velocidade.\n\nCREATE 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'::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 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 orders o ON o.id = tb.order_id;\n\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated, service_role;\n";
53
+ export declare const MIGRATION_052_A_LISTA_DE_CONTAS_FALA_VENCIMENTO = "-- A lista de contas fala VENCIMENTO.\n--\n-- A tela de Contas a Pagar do V1 \u00E9 constru\u00EDda em torno do vencimento: \u00E9 a\n-- coluna que ela mostra, \u00E9 por ele que ordena e \u00E9 ele que o filtro de per\u00EDodo\n-- recorta (`PayablesList.tsx`: `.gte('due_date', \u2026)`).\n--\n-- A do V2 mostrava a COMPET\u00CANCIA. As duas datas existem e s\u00E3o diferentes, e a\n-- confus\u00E3o que isso cria \u00E9 concreta: sete t\u00EDtulos aparecem no V2 sob a data\n-- 11/09 \u2014 a compet\u00EAncia deles \u2014 enquanto o V1, na mesma data, mostra outros\n-- seis, que s\u00E3o os que VENCEM em 11/09. Comparar as duas telas pelo \"dia 11/09\"\n-- compara conjuntos diferentes, e a conclus\u00E3o natural \u00E9 que o porte falhou.\n-- Conferido um a um: os sete est\u00E3o `pending` no V1 tamb\u00E9m.\n--\n-- A compet\u00EAncia \u00E9 do RAZ\u00C3O \u2014 \u00E9 o eixo do DRE, e l\u00E1 ela continua mandando. O\n-- vencimento \u00E9 da OPERA\u00C7\u00C3O: quem abre esta tela est\u00E1 perguntando o que paga\n-- esta semana.\n--\n-- Esta migration s\u00F3 acrescenta a coluna `due_date` \u00E0 view; quem passa a us\u00E1-la\n-- \u00E9 a tela.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_balances CASCADE;\n\nCREATE VIEW public.v_financial_invoice_balances AS\nSELECT 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 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 -- O VENCIMENTO do t\u00EDtulo, numa coluna s\u00F3.\n --\n -- `next_due_date` \u00E9 o pr\u00F3ximo em aberto e fica NULO quando tudo j\u00E1 foi\n -- pago; `last_due_date` \u00E9 o \u00FAltimo de todos. Ordenar ou filtrar por\n -- `next_due_date` sozinho esconderia justamente os t\u00EDtulos quitados \u2014 que\n -- \u00E9 a metade da lista que a pessoa mais confere.\n COALESCE(ib.next_due_date, ib.last_due_date) AS 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 LATERAL (\n SELECT count(*) FILTER (WHERE s.st <> 'cancelled') AS installment_count,\n sum(i.amount) FILTER (WHERE s.st <> 'cancelled') AS billed,\n sum(s.paid) AS paid,\n bool_or(s.st = 'overdue') AS has_overdue,\n min(i.due_date) FILTER (WHERE s.st = ANY (ARRAY['pending','partial','overdue'])) AS next_due_date,\n max(i.due_date) FILTER (WHERE s.st <> 'cancelled') AS last_due_date\n FROM public.plg_financial_invoice_installments i\n -- Correlacionado em `i.id`: s\u00F3 as baixas DESTA parcela, pelo \u00EDndice.\n LEFT JOIN LATERAL (\n SELECT 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 a.installment_id = i.id\n AND m.status <> 'cancelled'\n AND m.reversed_by_id IS NULL\n ) pa ON true\n CROSS JOIN LATERAL (\n SELECT COALESCE(pa.paid, 0::numeric) AS paid,\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(pa.paid, 0::numeric) >= i.amount THEN 'paid'\n WHEN COALESCE(pa.paid, 0::numeric) > 0::numeric THEN 'partial'\n WHEN i.due_date < CURRENT_DATE THEN 'overdue'\n ELSE 'pending'\n END AS st\n ) s\n WHERE i.invoice_id = t.id\n ) ib ON true\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_balances IS\n 'Saldo e estado de cada t\u00EDtulo, das parcelas dele. LATERAL correlacionado em '\n 'todos os n\u00EDveis (parcela por invoice_id, baixa por installment_id), para que '\n 'o c\u00E1lculo aconte\u00E7a s\u00F3 nas linhas que a consulta pediu \u2014 a forma anterior '\n 'somava os 2.375 t\u00EDtulos para mostrar 50, e a tela levava 7 s contra um '\n 'statement_timeout de 8 s.';\n\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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A view que dependia dela volta igual\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `v_invoice_balances` (o saldo por PEDIDO, que `plg_financial_rep_receivables`\n-- l\u00EA) \u00E9 derrubada pelo CASCADE acima. Corpo extra\u00EDdo do pr\u00F3prio cluster com\n-- `pg_get_viewdef` e recriado sem uma v\u00EDrgula de diferen\u00E7a \u2014 o que muda debaixo\n-- dela \u00E9 s\u00F3 a velocidade.\n\nCREATE 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'::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 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 orders o ON o.id = tb.order_id;\n\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated, service_role;\n";
54
+ export declare const MIGRATION_053_UM_TITULO_PARCELADO_VENCE_VARIAS_VEZES = "-- Um t\u00EDtulo com quatro parcelas vence quatro vezes.\n--\n-- Filtrar \"vencimento de 11/09 a 11/09\" no V2 n\u00E3o achava nada, e no V1 achava\n-- seis contas. N\u00E3o \u00E9 dado faltando \u2014 \u00E9 a unidade da lista.\n--\n-- O V1 lista PARCELAS: uma compra em 4\u00D7 s\u00E3o quatro linhas, cada uma com o seu\n-- vencimento, e o filtro casa com a linha. O V2 lista T\u00CDTULOS: a compra \u00E9 uma\n-- linha s\u00F3, e uma linha s\u00F3 tem uma data. Das seis contas que vencem em 11/09\n-- no V1, tr\u00EAs pertencem a t\u00EDtulos parcelados cujo `due_date` representativo cai\n-- em outro dia \u2014 ent\u00E3o o recorte de um dia perdia metade delas.\n--\n-- A pergunta certa para um t\u00EDtulo n\u00E3o \u00E9 \"qual \u00E9 a sua data\" e sim \"alguma\n-- parcela sua cai neste per\u00EDodo\". Isso \u00E9 um teste de SOBREPOSI\u00C7\u00C3O, e ele\n-- precisa das duas pontas:\n--\n-- first_due_date <= fim AND last_due_date >= in\u00EDcio\n--\n-- `first_due_date` \u00E9 o que faltava. Com as duas, o filtro do dia 11/09 devolve\n-- as seis \u2014 e continua sendo uma consulta que o PostgREST expressa sem RPC.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_balances CASCADE;\n\nCREATE VIEW public.v_financial_invoice_balances AS\nSELECT 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 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.first_due_date,\n ib.last_due_date,\n -- O VENCIMENTO do t\u00EDtulo, numa coluna s\u00F3.\n --\n -- `next_due_date` \u00E9 o pr\u00F3ximo em aberto e fica NULO quando tudo j\u00E1 foi\n -- pago; `last_due_date` \u00E9 o \u00FAltimo de todos. Ordenar ou filtrar por\n -- `next_due_date` sozinho esconderia justamente os t\u00EDtulos quitados \u2014 que\n -- \u00E9 a metade da lista que a pessoa mais confere.\n COALESCE(ib.next_due_date, ib.last_due_date) AS 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 LATERAL (\n SELECT count(*) FILTER (WHERE s.st <> 'cancelled') AS installment_count,\n sum(i.amount) FILTER (WHERE s.st <> 'cancelled') AS billed,\n sum(s.paid) AS paid,\n bool_or(s.st = 'overdue') AS has_overdue,\n min(i.due_date) FILTER (WHERE s.st = ANY (ARRAY['pending','partial','overdue'])) AS next_due_date,\n min(i.due_date) FILTER (WHERE s.st <> 'cancelled') AS first_due_date,\n max(i.due_date) FILTER (WHERE s.st <> 'cancelled') AS last_due_date\n FROM public.plg_financial_invoice_installments i\n -- Correlacionado em `i.id`: s\u00F3 as baixas DESTA parcela, pelo \u00EDndice.\n LEFT JOIN LATERAL (\n SELECT 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 a.installment_id = i.id\n AND m.status <> 'cancelled'\n AND m.reversed_by_id IS NULL\n ) pa ON true\n CROSS JOIN LATERAL (\n SELECT COALESCE(pa.paid, 0::numeric) AS paid,\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(pa.paid, 0::numeric) >= i.amount THEN 'paid'\n WHEN COALESCE(pa.paid, 0::numeric) > 0::numeric THEN 'partial'\n WHEN i.due_date < CURRENT_DATE THEN 'overdue'\n ELSE 'pending'\n END AS st\n ) s\n WHERE i.invoice_id = t.id\n ) ib ON true\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_balances IS\n 'Saldo e estado de cada t\u00EDtulo, das parcelas dele. LATERAL correlacionado em '\n 'todos os n\u00EDveis (parcela por invoice_id, baixa por installment_id), para que '\n 'o c\u00E1lculo aconte\u00E7a s\u00F3 nas linhas que a consulta pediu \u2014 a forma anterior '\n 'somava os 2.375 t\u00EDtulos para mostrar 50, e a tela levava 7 s contra um '\n 'statement_timeout de 8 s.';\n\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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A view que dependia dela volta igual\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `v_invoice_balances` (o saldo por PEDIDO, que `plg_financial_rep_receivables`\n-- l\u00EA) \u00E9 derrubada pelo CASCADE acima. Corpo extra\u00EDdo do pr\u00F3prio cluster com\n-- `pg_get_viewdef` e recriado sem uma v\u00EDrgula de diferen\u00E7a \u2014 o que muda debaixo\n-- dela \u00E9 s\u00F3 a velocidade.\n\nCREATE 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'::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 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 orders o ON o.id = tb.order_id;\n\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated, service_role;\n";
55
+ export declare const MIGRATION_054_AS_DATAS_EM_QUE_O_TITULO_VENCE = "-- As datas em que o t\u00EDtulo vence, todas elas.\n--\n-- A 047 tentou o recorte por SOBREPOSI\u00C7\u00C3O de janela \u2014 `first_due_date <= fim`\n-- e `last_due_date >= in\u00EDcio`. Funciona para um m\u00EAs; erra para um dia.\n--\n-- Medido: o filtro \"11/09 a 11/09\" trazia PAG-001842, cujas duas parcelas\n-- vencem em 12/08 e 12/09. Nenhuma das duas \u00E9 11/09 \u2014 mas a janela 12/08\u219212/09\n-- cont\u00E9m o dia, e a sobreposi\u00E7\u00E3o de janelas n\u00E3o sabe que entre as pontas n\u00E3o h\u00E1\n-- nada. Catorze t\u00EDtulos vinham assim.\n--\n-- Uma janela s\u00F3 sabe onde come\u00E7a e onde termina. A pergunta \u00E9 outra: \"alguma\n-- parcela vence NESTE dia\", e ela precisa das datas, n\u00E3o dos extremos.\n--\n-- `due_dates` \u00E9 o vetor dos vencimentos n\u00E3o cancelados do t\u00EDtulo. O recorte\n-- vira um teste de interse\u00E7\u00E3o de conjuntos (`&&`), que o PostgREST expressa\n-- direto (`due_dates=ov.{\u2026}`) e o Postgres resolve sem RPC.\n--\n-- Para um per\u00EDodo longo a lista de dias fica grande, e a\u00ED a janela volta a\n-- servir: ela erra para mais, nunca para menos, e num recorte de um ano a\n-- diferen\u00E7a some. O corte est\u00E1 no provider.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_balances CASCADE;\n\nCREATE VIEW public.v_financial_invoice_balances AS\nSELECT 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 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.first_due_date,\n ib.last_due_date,\n COALESCE(ib.due_dates, ARRAY[]::date[]) AS due_dates,\n -- O VENCIMENTO do t\u00EDtulo, numa coluna s\u00F3.\n --\n -- `next_due_date` \u00E9 o pr\u00F3ximo em aberto e fica NULO quando tudo j\u00E1 foi\n -- pago; `last_due_date` \u00E9 o \u00FAltimo de todos. Ordenar ou filtrar por\n -- `next_due_date` sozinho esconderia justamente os t\u00EDtulos quitados \u2014 que\n -- \u00E9 a metade da lista que a pessoa mais confere.\n COALESCE(ib.next_due_date, ib.last_due_date) AS 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 LATERAL (\n SELECT count(*) FILTER (WHERE s.st <> 'cancelled') AS installment_count,\n sum(i.amount) FILTER (WHERE s.st <> 'cancelled') AS billed,\n sum(s.paid) AS paid,\n bool_or(s.st = 'overdue') AS has_overdue,\n min(i.due_date) FILTER (WHERE s.st = ANY (ARRAY['pending','partial','overdue'])) AS next_due_date,\n min(i.due_date) FILTER (WHERE s.st <> 'cancelled') AS first_due_date,\n array_agg(i.due_date ORDER BY i.due_date)\n FILTER (WHERE s.st <> 'cancelled') AS due_dates,\n max(i.due_date) FILTER (WHERE s.st <> 'cancelled') AS last_due_date\n FROM public.plg_financial_invoice_installments i\n -- Correlacionado em `i.id`: s\u00F3 as baixas DESTA parcela, pelo \u00EDndice.\n LEFT JOIN LATERAL (\n SELECT 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 a.installment_id = i.id\n AND m.status <> 'cancelled'\n AND m.reversed_by_id IS NULL\n ) pa ON true\n CROSS JOIN LATERAL (\n SELECT COALESCE(pa.paid, 0::numeric) AS paid,\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(pa.paid, 0::numeric) >= i.amount THEN 'paid'\n WHEN COALESCE(pa.paid, 0::numeric) > 0::numeric THEN 'partial'\n WHEN i.due_date < CURRENT_DATE THEN 'overdue'\n ELSE 'pending'\n END AS st\n ) s\n WHERE i.invoice_id = t.id\n ) ib ON true\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_balances IS\n 'Saldo e estado de cada t\u00EDtulo, das parcelas dele. LATERAL correlacionado em '\n 'todos os n\u00EDveis (parcela por invoice_id, baixa por installment_id), para que '\n 'o c\u00E1lculo aconte\u00E7a s\u00F3 nas linhas que a consulta pediu \u2014 a forma anterior '\n 'somava os 2.375 t\u00EDtulos para mostrar 50, e a tela levava 7 s contra um '\n 'statement_timeout de 8 s.';\n\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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A view que dependia dela volta igual\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `v_invoice_balances` (o saldo por PEDIDO, que `plg_financial_rep_receivables`\n-- l\u00EA) \u00E9 derrubada pelo CASCADE acima. Corpo extra\u00EDdo do pr\u00F3prio cluster com\n-- `pg_get_viewdef` e recriado sem uma v\u00EDrgula de diferen\u00E7a \u2014 o que muda debaixo\n-- dela \u00E9 s\u00F3 a velocidade.\n\nCREATE 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'::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 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 orders o ON o.id = tb.order_id;\n\nALTER VIEW public.v_invoice_balances SET (security_invoker = true);\nGRANT SELECT ON public.v_invoice_balances TO authenticated, service_role;\n";
56
+ export declare const MIGRATION_055_A_LISTA_DE_CONTAS_LISTA_PARCELAS = "-- A lista de contas lista parcelas.\n--\n-- Tr\u00EAs queixas diferentes na mesma tela, e uma causa s\u00F3.\n--\n-- \u00B7 \"dia 11/09 est\u00E1 tudo pago e aqui aparece em aberto\"\n-- \u00B7 \"filtro 11/09 n\u00E3o acha nada\"\n-- \u00B7 \"contas a pagar est\u00E1 muito lento\"\n--\n-- A tela do V2 listava T\u00CDTULOS; a do V1 lista PARCELAS. Uma compra em 4\u00D7 \u00E9 uma\n-- linha l\u00E1 e quatro linhas no V1 \u2014 e quase tudo que incomoda vem da\u00ED:\n--\n-- O ESTADO. A parcela de 11/09 est\u00E1 paga; as de 18, 25 e 02/10 n\u00E3o. O t\u00EDtulo\n-- inteiro, ent\u00E3o, \u00E9 \"parcial\" \u2014 e a pessoa que pagou a de 11/09 v\u00EA\n-- \"parcial\" onde o V1 escreve \"Pago\".\n--\n-- O RECORTE. Um t\u00EDtulo tem uma data; quatro parcelas t\u00EAm quatro. Filtrar um\n-- DIA num t\u00EDtulo obriga a escolher qual das quatro datas o\n-- representa, e a escolha erra tr\u00EAs vezes em quatro.\n--\n-- O PRE\u00C7O. N\u00E3o d\u00E1 para indexar \"alguma das minhas parcelas cai neste dia\"\n-- quando as parcelas s\u00E3o um agregado calculado por t\u00EDtulo. Medido,\n-- tenant Pertinho do C\u00E9u:\n--\n-- por t\u00EDtulo por parcela\n-- dia 11/09 7,48 s 0,15 s\n-- m\u00EAs corrente 4,27 s 0,61 s\n--\n-- Cinquenta vezes, e n\u00E3o por esperteza: a parcela filtra por\n-- `plg_financial_invoice_installments (tenant_id, due_date)`, que\n-- \u00E9 um \u00EDndice que j\u00E1 existia.\n--\n-- E o n\u00FAmero certo aparece junto: no dia 11/09 esta view devolve 6 parcelas,\n-- R$ 9.157,82, todas pagas \u2014 exatamente o que a tela do V1 mostra. A forma por\n-- t\u00EDtulo devolvia 6 t\u00EDtulos e R$ 16.890,07, porque somava parcelas de outros\n-- dias.\n--\n-- \u2500\u2500 O que a view \u00E9 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 linha por parcela, com o que a linha da lista precisa: a contraparte, a\n-- descri\u00E7\u00E3o, o valor, o pago, o estado e \"3 de 4\". O `invoice_id` vai junto\n-- para que clicar na linha abra o t\u00EDtulo \u2014 a lista \u00E9 de parcelas, o documento\n-- continua sendo o t\u00EDtulo.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_lines_due;\n\nCREATE VIEW public.v_financial_invoice_lines_due AS\nSELECT\n i.id AS installment_id,\n i.tenant_id,\n i.unit_id,\n i.invoice_id,\n t.direction,\n t.number,\n t.competence_date,\n t.issue_date,\n t.description,\n t.notes,\n t.metadata,\n t.order_id,\n t.counterparty_person_id,\n p.name AS counterparty_name,\n i.number AS installment_number,\n it.total AS installment_count,\n i.due_date,\n i.amount,\n COALESCE(pa.paid, 0::numeric)::numeric(14,2) AS paid,\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(pa.paid, 0::numeric), 0::numeric)\n END::numeric(14,2) AS balance,\n -- Mesmas palavras de `v_financial_installment_balances`. Se as duas\n -- divergirem, a lista e o detalhe passam a dizer coisas diferentes sobre a\n -- mesma parcela.\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(pa.paid, 0::numeric) >= i.amount THEN 'paid'\n WHEN COALESCE(pa.paid, 0::numeric) > 0::numeric THEN 'partial'\n WHEN i.due_date < CURRENT_DATE THEN 'overdue'\n ELSE 'pending'\n END AS status,\n i.cancelled_at,\n t.cancel_reason,\n t.created_by,\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 -- Correlacionado: s\u00F3 as baixas DESTA parcela, pelo \u00EDndice (installment_id).\n LEFT JOIN LATERAL (\n SELECT 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 a.installment_id = i.id\n AND m.status <> 'cancelled'\n AND m.reversed_by_id IS NULL\n ) pa ON true\n -- \"3 de 4\": o denominador \u00E9 do t\u00EDtulo, e sem ele a coluna de parcela mente.\n LEFT JOIN LATERAL (\n SELECT count(*) AS total\n FROM public.plg_financial_invoice_installments x\n WHERE x.invoice_id = i.invoice_id AND x.cancelled_at IS NULL\n ) it ON true\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_lines_due IS\n 'Uma linha por PARCELA \u2014 a granularidade da lista de contas a pagar do V1, e '\n 'a \u00FAnica em que \"o que vence no dia 11\" tem resposta. Filtra pelo \u00EDndice '\n '(tenant_id, due_date) das parcelas: 0,15 s contra 7,5 s da forma por t\u00EDtulo.';\n\nALTER VIEW public.v_financial_invoice_lines_due SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_invoice_lines_due TO authenticated, service_role;\n";
57
+ export declare const MIGRATION_056_WHAT_THE_PORT_LEFT_OPEN = "-- ---------------------------------------------------------------------------\n-- 056_what_the_port_left_open.sql \u2014 the posture the V1 financial wave skipped.\n--\n-- Three findings of the chain's own suites (900_verification/001_posture and\n-- 090_migration_ledger/001_schema_contract), each live on the cluster since\n-- the wave was applied:\n--\n-- 1. `v_financial_payables` read every tenant's payables. 033 created it with\n-- CREATE OR REPLACE and no options, so it ran with its owner's rights \u2014\n-- RLS off \u2014 and carries no tenant filter in its body; it is also granted\n-- to `authenticated`. Any signed-in user of any tenant could list every\n-- other tenant's bills and the names of who they pay. It becomes\n-- security_invoker, so the base tables' RLS decides again.\n--\n-- 2. `plg_financial_recurring_bill_occurrences.amount` and `.paid_amount` were\n-- bare `numeric` (N25, #182: a money column declares its scale). The view\n-- above reads both, and PostgreSQL will not retype a column a view\n-- depends on \u2014 so the view is dropped only when the retype is actually\n-- needed, and recreated below either way.\n--\n-- 3. Functions this plugin added to schemas `migration` and `app` kept the\n-- default EXECUTE TO PUBLIC, i.e. `anon`: the V1 port's writers\n-- (`upsert_fin_*`, `_fin_installment_from_bill`) and the DRE's two fence\n-- helpers (`app.visible_units`, `app._units_of_member`). Spine 066 sweeps\n-- what the spine created; this sweeps what this plugin created after it.\n--\n-- 4. `migration.upsert_fin_recurring_bill` (032) writes `items` and `metadata`\n-- on `plg_financial_recurring_bills`, and no file ever added either column:\n-- 014 created the table without them. PL/pgSQL resolves columns when the\n-- function runs, not when it is created, so 032 applied cleanly and every\n-- V1 recurring bill then landed as a `42703` quarantine. They are added\n-- here, with the shapes the two writers use (an array of items, an object\n-- of provenance).\n--\n-- The files that introduced these are applied on the cluster (their bytes\n-- are in the ledger), so the fix is this file, not an edit to them.\n-- ---------------------------------------------------------------------------\n\n-- \u2500\u2500 4: the columns the port writes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_recurring_bills\n ADD COLUMN IF NOT EXISTS items jsonb NOT NULL DEFAULT '[]'::jsonb,\n ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;\n\n-- \u2500\u2500 2 first: the retype needs the view out of the way \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nDO $$\nBEGIN\n IF (SELECT format_type(a.atttypid, a.atttypmod)\n FROM pg_attribute a\n WHERE a.attrelid = 'public.plg_financial_recurring_bill_occurrences'::regclass\n AND a.attname = 'amount') <> 'numeric(14,2)'\n OR (SELECT format_type(a.atttypid, a.atttypmod)\n FROM pg_attribute a\n WHERE a.attrelid = 'public.plg_financial_recurring_bill_occurrences'::regclass\n AND a.attname = 'paid_amount') <> 'numeric(14,2)' THEN\n DROP VIEW IF EXISTS public.v_financial_payables;\n ALTER TABLE public.plg_financial_recurring_bill_occurrences\n ALTER COLUMN amount TYPE numeric(14,2),\n ALTER COLUMN paid_amount TYPE numeric(14,2);\n END IF;\nEND $$;\n\n-- \u2500\u2500 1: the same read, under the caller's rights \u2500\u2500\u2500\u2500\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-- Body unchanged from 033; only the WITH clause is new.\nCREATE OR REPLACE VIEW public.v_financial_payables WITH (security_invoker = true) AS\nSELECT\n o.tenant_id,\n o.unit_id,\n o.id AS occurrence_id,\n b.id AS recurring_bill_id,\n b.description,\n b.counterparty_person_id,\n p.name AS counterparty_name,\n b.frequency,\n o.seq,\n o.competence_date,\n o.due_date,\n o.amount,\n -- Payment comes from the invoice's movements; `o.paid_amount` is 0 on\n -- everything that came from V1. Summing the two will count a payment twice\n -- the day V2 starts writing the occurrence as well \u2014 a limit 033 named and\n -- this file keeps as it was.\n coalesce(o.paid_amount, 0) + coalesce(m.pago, 0) AS paid_amount,\n o.amount - (coalesce(o.paid_amount, 0) + coalesce(m.pago, 0)) AS open_amount,\n o.status,\n o.overridden,\n o.invoice_id,\n CASE\n WHEN o.status = 'cancelled' THEN 'cancelado'\n WHEN o.amount <= coalesce(o.paid_amount, 0) + coalesce(m.pago, 0) THEN 'pago'\n WHEN coalesce(m.pago, 0) > 0 THEN 'parcial'\n WHEN o.due_date < current_date THEN 'vencido'\n ELSE 'em aberto'\n END AS situacao\nFROM public.plg_financial_recurring_bill_occurrences o\nJOIN public.plg_financial_recurring_bills b ON b.id = o.recurring_bill_id\nLEFT JOIN public.people p ON p.id = b.counterparty_person_id\nLEFT JOIN LATERAL (\n SELECT sum(mv.paid_amount) AS pago\n FROM public.plg_financial_movements mv\n WHERE mv.financial_invoice_id = o.invoice_id\n) m ON o.invoice_id IS NOT NULL;\n\nCOMMENT ON VIEW public.v_financial_payables IS\n 'Payables by due date, with what was paid summed from the invoice movements. '\n 'security_invoker: the base tables'' RLS scopes it to the caller''s tenant (056).';\n\nGRANT SELECT ON public.v_financial_payables TO authenticated, service_role;\n\n-- \u2500\u2500 3: nobody without a session calls these \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 f regprocedure;\nBEGIN\n FOR f IN\n SELECT p.oid::regprocedure\n FROM pg_proc p\n JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'migration'\n AND (has_function_privilege('anon', p.oid, 'EXECUTE')\n OR has_function_privilege('authenticated', p.oid, 'EXECUTE'))\n LOOP\n EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated', f);\n EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', f);\n END LOOP;\nEND $$;\n\n-- The DRE's fence helpers stay callable by a session (the view evaluates them\n-- as its owner, and RLS elsewhere may too) \u2014 just not by `anon`.\nDO $$\nBEGIN\n IF to_regprocedure('app.visible_units()') IS NOT NULL THEN\n REVOKE ALL ON FUNCTION app.visible_units() FROM PUBLIC, anon;\n GRANT EXECUTE ON FUNCTION app.visible_units() TO authenticated, service_role;\n END IF;\n IF to_regprocedure('app._units_of_member()') IS NOT NULL THEN\n REVOKE ALL ON FUNCTION app._units_of_member() FROM PUBLIC, anon;\n GRANT EXECUTE ON FUNCTION app._units_of_member() TO authenticated, service_role;\n END IF;\nEND $$;\n";
58
+ export declare const MIGRATION_057_A_LINHA_DIZ_QUE_TEM_RECIBO = "-- A linha diz que tem recibo.\n--\n-- Os 1.192 anexos das contas a pagar atravessaram (migration 053 do spine) e\n-- ficaram invis\u00EDveis: a lista n\u00E3o tinha por onde saber que existem. No V1 o\n-- clipe com o n\u00FAmero est\u00E1 na linha, e \u00E9 por ele que se sabe que a conta tem\n-- comprovante sem abrir a conta.\n--\n-- O anexo pertence ao T\u00CDTULO, n\u00E3o \u00E0 parcela \u2014 um recibo comprova a conta, n\u00E3o a\n-- terceira parcela dela. Ent\u00E3o as quatro parcelas de uma conta em 4\u00D7 mostram o\n-- mesmo n\u00FAmero, que \u00E9 o que a tela do V1 faz.\n--\n-- Medido em 19/09/2026 no tenant Pertinho do C\u00E9u: 854 contas com anexo, sendo\n-- 627 com um, 165 com dois, e at\u00E9 seis numa s\u00F3.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_lines_due;\n\nCREATE VIEW public.v_financial_invoice_lines_due AS\nSELECT\n i.id AS installment_id,\n i.tenant_id,\n i.unit_id,\n i.invoice_id,\n t.direction,\n t.number,\n t.competence_date,\n t.issue_date,\n t.description,\n t.notes,\n t.metadata,\n t.order_id,\n t.counterparty_person_id,\n p.name AS counterparty_name,\n i.number AS installment_number,\n it.total AS installment_count,\n i.due_date,\n i.amount,\n COALESCE(pa.paid, 0::numeric)::numeric(14,2) AS paid,\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(pa.paid, 0::numeric), 0::numeric)\n END::numeric(14,2) AS balance,\n -- Mesmas palavras de `v_financial_installment_balances`. Se as duas\n -- divergirem, a lista e o detalhe passam a dizer coisas diferentes sobre a\n -- mesma parcela.\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(pa.paid, 0::numeric) >= i.amount THEN 'paid'\n WHEN COALESCE(pa.paid, 0::numeric) > 0::numeric THEN 'partial'\n WHEN i.due_date < CURRENT_DATE THEN 'overdue'\n ELSE 'pending'\n END AS status,\n -- O clipe da linha. O anexo pertence ao T\u00CDTULO \u2014 um recibo comprova a\n -- conta, n\u00E3o a terceira parcela dela \u2014 ent\u00E3o todas as parcelas de uma conta\n -- mostram o mesmo n\u00FAmero, que \u00E9 o que a tela do V1 faz.\n COALESCE(ax.n, 0)::integer AS attachment_count,\n i.cancelled_at,\n t.cancel_reason,\n t.created_by,\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 -- Correlacionado: s\u00F3 as baixas DESTA parcela, pelo \u00EDndice (installment_id).\n LEFT JOIN LATERAL (\n SELECT 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 a.installment_id = i.id\n AND m.status <> 'cancelled'\n AND m.reversed_by_id IS NULL\n ) pa ON true\n -- \"3 de 4\": o denominador \u00E9 do t\u00EDtulo, e sem ele a coluna de parcela mente.\n LEFT JOIN LATERAL (\n SELECT count(*) AS total\n FROM public.plg_financial_invoice_installments x\n WHERE x.invoice_id = i.invoice_id AND x.cancelled_at IS NULL\n ) it ON true\n -- Correlacionado, como o resto: s\u00F3 os documentos DESTE t\u00EDtulo.\n LEFT JOIN LATERAL (\n SELECT count(*) AS n\n FROM public.documents d\n WHERE d.subject_type = 'financial_invoice'\n AND d.subject_id = t.id\n AND d.is_active\n ) ax ON true\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_lines_due IS\n 'Uma linha por PARCELA \u2014 a granularidade da lista de contas a pagar do V1, e '\n 'a \u00FAnica em que \"o que vence no dia 11\" tem resposta. Filtra pelo \u00EDndice '\n '(tenant_id, due_date) das parcelas: 0,15 s contra 7,5 s da forma por t\u00EDtulo.';\n\nALTER VIEW public.v_financial_invoice_lines_due SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_invoice_lines_due TO authenticated, service_role;\n";
59
+ export declare const MIGRATION_058_O_CLIPE_USA_O_INDICE = "-- O clipe passa a usar o \u00EDndice que j\u00E1 existia.\n--\n-- A migration 050 acrescentou a contagem de anexos \u00E0 linha e a consulta da tela\n-- foi de 0,2 s para 2,5 s. O `LATERAL` filtrava por `subject_type` e\n-- `subject_id` e n\u00E3o por `tenant_id` \u2014 e o \u00EDndice \u00E9\n-- `documents_subject (tenant_id, subject_type, subject_id)`.\n--\n-- Um \u00EDndice composto s\u00F3 serve a partir da coluna da frente. A RLS at\u00E9 imp\u00F5e o\n-- tenant, mas como filtro por linha, depois de o \u00EDndice j\u00E1 n\u00E3o ter sido usado.\n-- Escrever a condi\u00E7\u00E3o explicitamente \u00E9 o que a torna aproveit\u00E1vel.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_lines_due;\n\nCREATE VIEW public.v_financial_invoice_lines_due AS\nSELECT\n i.id AS installment_id,\n i.tenant_id,\n i.unit_id,\n i.invoice_id,\n t.direction,\n t.number,\n t.competence_date,\n t.issue_date,\n t.description,\n t.notes,\n t.metadata,\n t.order_id,\n t.counterparty_person_id,\n p.name AS counterparty_name,\n i.number AS installment_number,\n it.total AS installment_count,\n i.due_date,\n i.amount,\n COALESCE(pa.paid, 0::numeric)::numeric(14,2) AS paid,\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(pa.paid, 0::numeric), 0::numeric)\n END::numeric(14,2) AS balance,\n -- Mesmas palavras de `v_financial_installment_balances`. Se as duas\n -- divergirem, a lista e o detalhe passam a dizer coisas diferentes sobre a\n -- mesma parcela.\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(pa.paid, 0::numeric) >= i.amount THEN 'paid'\n WHEN COALESCE(pa.paid, 0::numeric) > 0::numeric THEN 'partial'\n WHEN i.due_date < CURRENT_DATE THEN 'overdue'\n ELSE 'pending'\n END AS status,\n -- O clipe da linha. O anexo pertence ao T\u00CDTULO \u2014 um recibo comprova a\n -- conta, n\u00E3o a terceira parcela dela \u2014 ent\u00E3o todas as parcelas de uma conta\n -- mostram o mesmo n\u00FAmero, que \u00E9 o que a tela do V1 faz.\n COALESCE(ax.n, 0)::integer AS attachment_count,\n i.cancelled_at,\n t.cancel_reason,\n t.created_by,\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 -- Correlacionado: s\u00F3 as baixas DESTA parcela, pelo \u00EDndice (installment_id).\n LEFT JOIN LATERAL (\n SELECT 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 a.installment_id = i.id\n AND m.status <> 'cancelled'\n AND m.reversed_by_id IS NULL\n ) pa ON true\n -- \"3 de 4\": o denominador \u00E9 do t\u00EDtulo, e sem ele a coluna de parcela mente.\n LEFT JOIN LATERAL (\n SELECT count(*) AS total\n FROM public.plg_financial_invoice_installments x\n WHERE x.invoice_id = i.invoice_id AND x.cancelled_at IS NULL\n ) it ON true\n -- Correlacionado, como o resto: s\u00F3 os documentos DESTE t\u00EDtulo.\n LEFT JOIN LATERAL (\n SELECT count(*) AS n\n FROM public.documents d\n -- `tenant_id` PRIMEIRO, e n\u00E3o por zelo: o \u00EDndice \u00E9\n -- `documents_subject (tenant_id, subject_type, subject_id)`, e sem a\n -- coluna da frente ele n\u00E3o serve. Sem ela a consulta da tela ia de 0,2 s\n -- para 2,5 s \u2014 o clipe custando dez vezes o resto da linha.\n WHERE d.tenant_id = t.tenant_id\n AND d.subject_type = 'financial_invoice'\n AND d.subject_id = t.id\n AND d.is_active\n ) ax ON true\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_lines_due IS\n 'Uma linha por PARCELA \u2014 a granularidade da lista de contas a pagar do V1, e '\n 'a \u00FAnica em que \"o que vence no dia 11\" tem resposta. Filtra pelo \u00EDndice '\n '(tenant_id, due_date) das parcelas: 0,15 s contra 7,5 s da forma por t\u00EDtulo.';\n\nALTER VIEW public.v_financial_invoice_lines_due SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_invoice_lines_due TO authenticated, service_role;\n";
60
+ export declare const MIGRATION_059_O_CLIPE_SAI_DA_VIEW = "-- O clipe sai da view e vira uma segunda pergunta.\n--\n-- A 050 pendurou a contagem de anexos num LATERAL sobre `documents`. Correto no\n-- resultado e caro no pre\u00E7o: `documents` \u00E9 `security_invoker`, e a pol\u00EDtica\n-- dela faz as mesmas chamadas correlacionadas por linha que j\u00E1 custaram caro no\n-- DRE. Cinquenta avalia\u00E7\u00F5es do LATERAL, cinquenta vezes esse pre\u00E7o.\n--\n-- Medido em 19/09/2026, a consulta que a tela faz:\n--\n-- sem o clipe na view 0,20 s\n-- com o clipe na view 3,84 s (pico de 6,61 s)\n--\n-- Acrescentar o \u00EDndice `tenant_id` na condi\u00E7\u00E3o ajudou pouco (2,5 \u2192 2,0 s no\n-- caso de varredura): o custo n\u00E3o \u00E9 achar o documento, \u00E9 a RLS que roda para\n-- cada um.\n--\n-- A lista j\u00E1 tem as cinquenta linhas na m\u00E3o. Perguntar \"quais destes cinquenta\n-- t\u00EDtulos t\u00EAm anexo\" \u00E9 UMA consulta por p\u00E1gina, pelo \u00EDndice\n-- `documents_subject`, e o cliente junta. Uma pergunta a mais na p\u00E1gina \u00E9 mais\n-- barato que cinquenta perguntas dentro do plano.\n--\n-- Idempotente.\n\nDROP VIEW IF EXISTS public.v_financial_invoice_lines_due;\n\nCREATE VIEW public.v_financial_invoice_lines_due AS\nSELECT\n i.id AS installment_id,\n i.tenant_id,\n i.unit_id,\n i.invoice_id,\n t.direction,\n t.number,\n t.competence_date,\n t.issue_date,\n t.description,\n t.notes,\n t.metadata,\n t.order_id,\n t.counterparty_person_id,\n p.name AS counterparty_name,\n i.number AS installment_number,\n it.total AS installment_count,\n i.due_date,\n i.amount,\n COALESCE(pa.paid, 0::numeric)::numeric(14,2) AS paid,\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(pa.paid, 0::numeric), 0::numeric)\n END::numeric(14,2) AS balance,\n -- Mesmas palavras de `v_financial_installment_balances`. Se as duas\n -- divergirem, a lista e o detalhe passam a dizer coisas diferentes sobre a\n -- mesma parcela.\n CASE\n WHEN i.cancelled_at IS NOT NULL OR t.cancelled_at IS NOT NULL THEN 'cancelled'\n WHEN COALESCE(pa.paid, 0::numeric) >= i.amount THEN 'paid'\n WHEN COALESCE(pa.paid, 0::numeric) > 0::numeric THEN 'partial'\n WHEN i.due_date < CURRENT_DATE THEN 'overdue'\n ELSE 'pending'\n END AS status,\n i.cancelled_at,\n t.cancel_reason,\n t.created_by,\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 -- Correlacionado: s\u00F3 as baixas DESTA parcela, pelo \u00EDndice (installment_id).\n LEFT JOIN LATERAL (\n SELECT 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 a.installment_id = i.id\n AND m.status <> 'cancelled'\n AND m.reversed_by_id IS NULL\n ) pa ON true\n -- \"3 de 4\": o denominador \u00E9 do t\u00EDtulo, e sem ele a coluna de parcela mente.\n LEFT JOIN LATERAL (\n SELECT count(*) AS total\n FROM public.plg_financial_invoice_installments x\n WHERE x.invoice_id = i.invoice_id AND x.cancelled_at IS NULL\n ) it ON true\n LEFT JOIN public.people p ON p.id = t.counterparty_person_id;\n\nCOMMENT ON VIEW public.v_financial_invoice_lines_due IS\n 'Uma linha por PARCELA \u2014 a granularidade da lista de contas a pagar do V1, e '\n 'a \u00FAnica em que \"o que vence no dia 11\" tem resposta. Filtra pelo \u00EDndice '\n '(tenant_id, due_date) das parcelas: 0,15 s contra 7,5 s da forma por t\u00EDtulo.';\n\nALTER VIEW public.v_financial_invoice_lines_due SET (security_invoker = true);\nGRANT SELECT ON public.v_financial_invoice_lines_due TO authenticated, service_role;\n";
61
+ export declare const MIGRATION_060_O_RECEBIVEL_VOLTA_DA_ARVORE_DERRUBADA = "-- O receb\u00EDvel volta da \u00E1rvore derrubada.\n--\n-- Mesma causa da 007 da agenda: o `DROP VIEW \u2026 CASCADE` das migrations 044 e\n-- 045 derrubou tudo o que pendurava em `v_invoice_balances`, e elas s\u00F3\n-- recriaram as duas views que o autor tinha em mente.\n--\n-- `plg_financial_rep_receivables` \u00E9 a que o plugin-reports l\u00EA para o relat\u00F3rio\n-- de contas a receber \u2014 o alias `plg_*` \u00E9 o nome pelo qual o cat\u00E1logo de\n-- relat\u00F3rios a encontra. Ela sumiu junto e ningu\u00E9m percebeu, porque quem abre\n-- contas a receber no dia a dia abre a LISTA, n\u00E3o o relat\u00F3rio.\n--\n-- Corpo id\u00EAntico ao do baseline: nenhuma coluna muda, nenhuma regra muda.\n-- `security_invoker` e GRANT expl\u00EDcitos, porque uma view recriada n\u00E3o herda\n-- nem as op\u00E7\u00F5es nem as permiss\u00F5es da que morreu.\n--\n-- Idempotente.\n\nCREATE OR REPLACE 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\nGRANT SELECT ON TABLE public.plg_financial_rep_receivables TO authenticated, service_role;\n";
62
+ export declare const MIGRATION_061_CONTAS_A_PAGAR_GANHAM_ONDE_MORAR = "-- Contas a pagar ganham onde morar.\n--\n-- `recurring_bills`, `recurring_bill_items` e `recurring_bill_occurrences` do V1\n-- estavam em `migration.excluded_tables` com este motivo: *\"no destination\n-- contract \u2026 plg_financial_recurring_bills nunca foi constru\u00EDda (#188)\"*.\n--\n-- **A nota est\u00E1 vencida.** `public.plg_financial_recurring_bills` existe, com\n-- forma pr\u00F3pria e diferente da do V1 \u2014 e \u00E9 essa forma que manda:\n--\n-- V1 V2\n-- recurring_bills plg_financial_recurring_bills\n-- recurring_bill_items (tabela) .items (jsonb na pr\u00F3pria conta)\n-- recurring_bill_occurrences (tabela) \u2014 n\u00E3o existe\n-- interval_unit + interval_count frequency (weekly|monthly|quarterly|yearly)\n-- first_due_date starts_on + due_day (1..31)\n-- account_central_id \u2192 contacts counterparty_person_id \u2192 public.people\n-- (soma dos itens) amount NOT NULL, CHECK (amount > 0)\n-- (primeira descri\u00E7\u00E3o de item) description NOT NULL\n--\n-- Este arquivo faz tr\u00EAs coisas, e s\u00F3 a terceira cria tabela:\n--\n-- 1. escreve os writers que levam a conta e os itens para a forma do V2\n-- 2. escreve o de-para e tira as tr\u00EAs tabelas da lista de adiadas\n-- 3. cria `plg_financial_recurring_bill_occurrences`, que \u00E9 a \u00FAnica lacuna\n-- DE VERDADE\n--\n-- \u2500\u2500 Por que a ocorr\u00EAncia precisa existir \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 V2 deriva os vencimentos de `frequency` + `due_day`: a conta \u00E9 a regra, e o\n-- calend\u00E1rio sai dela. Isso funciona enquanto ningu\u00E9m mexe num vencimento \u2014 e o\n-- V1 deixa mexer. `recurring_bill_occurrences.overridden` marca o vencimento que\n-- algu\u00E9m editou \u00E0 m\u00E3o (mudou o valor de mar\u00E7o sem mexer na regra), e\n-- `paid_value` guarda o que de fato foi pago. Nenhum dos dois \u00E9 deriv\u00E1vel: a\n-- f\u00F3rmula devolve o valor da regra, n\u00E3o o que aconteceu.\n--\n-- Medido na origem em 18/09/2026 (licen\u00E7a 8, Pertinho do C\u00E9u): 35 contas, 35\n-- itens, 833 ocorr\u00EAncias, R$ 1.937.827,67 entre 25/05/2026 e 02/09/2028, 111 j\u00E1\n-- consolidadas. Descartar as ocorr\u00EAncias apagaria o hist\u00F3rico de pagamento e\n-- toda edi\u00E7\u00E3o manual junto.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. A \u00FAnica tabela que falta\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 IF NOT EXISTS public.plg_financial_recurring_bill_occurrences (\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 REFERENCES app.units(id),\n recurring_bill_id uuid NOT NULL\n REFERENCES public.plg_financial_recurring_bills(id) ON DELETE CASCADE,\n seq integer NOT NULL,\n competence_date date NOT NULL,\n due_date date NOT NULL,\n amount numeric NOT NULL,\n paid_amount numeric NOT NULL DEFAULT 0,\n status text NOT NULL DEFAULT 'provisioned'\n CHECK (status IN ('provisioned', 'consolidated', 'cancelled')),\n -- Algu\u00E9m editou ESTE vencimento, fora da regra. \u00C9 a raz\u00E3o de a ocorr\u00EAncia ser\n -- uma linha e n\u00E3o um c\u00E1lculo.\n overridden boolean NOT NULL DEFAULT false,\n -- O t\u00EDtulo que o vencimento virou, quando virou. Fica nulo at\u00E9 consolidar, e\n -- n\u00E3o vem do V1: l\u00E1 o campo aponta para `invoices`, cuja contraparte no V2 \u00E9\n -- uma VENDA, e ligar uma conta a pagar a uma venda inverteria o sentido do\n -- dinheiro para ganhar um link. A refer\u00EAncia crua do V1 fica em metadata.\n invoice_id uuid REFERENCES public.plg_financial_invoices(id) ON DELETE SET NULL,\n notes text,\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 (recurring_bill_id, seq)\n);\n\n-- A pergunta que a tela faz: \"o que vence neste per\u00EDodo?\". Sem este \u00EDndice ela\n-- varre as ocorr\u00EAncias de todos os tenants.\nCREATE INDEX IF NOT EXISTS plg_financial_recurring_bill_occurrences_due_idx\n ON public.plg_financial_recurring_bill_occurrences (tenant_id, due_date, status);\n\nDROP TRIGGER IF EXISTS plg_financial_recurring_bill_occurrences_updated_at\n ON public.plg_financial_recurring_bill_occurrences;\nCREATE TRIGGER plg_financial_recurring_bill_occurrences_updated_at\n BEFORE UPDATE ON public.plg_financial_recurring_bill_occurrences\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nGRANT SELECT, INSERT, UPDATE, DELETE\n ON public.plg_financial_recurring_bill_occurrences TO authenticated, service_role;\n\n-- O scaffold \u00E9 quem sabe desenhar a pol\u00EDtica deste cluster (tenant, unidade,\n-- permiss\u00E3o do plugin, escotilhas de compartilhamento). Escrever a policy \u00E0 m\u00E3o\n-- criaria a segunda regra de autoriza\u00E7\u00E3o do financeiro, que diverge da primeira\n-- na pr\u00F3xima mudan\u00E7a do scaffold.\n--\n-- `unit_optional`, e n\u00E3o `unit`: `unit_id` aceita NULL aqui porque a conta pode\n-- n\u00E3o ter unidade. Declarar `unit` numa coluna anul\u00E1vel \u00E9 o erro que j\u00E1 escondeu\n-- todo movimento financeiro sem unidade e zerou um relat\u00F3rio inteiro.\nSELECT app.scaffold_table('public.plg_financial_recurring_bill_occurrences'::regclass,\n 'financial.recurring_bill_occurrence', 'financial', false, 'enforce');\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. Os writers\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 vocabul\u00E1rio do de-para \u00E9 lista fechada de prop\u00F3sito: uma linha que nomeia um\n-- writer inexistente falha na hora, n\u00E3o na terceira hora da onda.\n--\n-- A LISTA \u00C9 A UNI\u00C3O, E N\u00C3O UMA LISTA LITERAL. Este CHECK \u00E9 de um recurso\n-- COMPARTILHADO: cada onda de porte acrescenta os seus writers a ele. Escrito\n-- como literal, o \u00FAltimo arquivo a rodar apaga os writers dos outros \u2014 e foi\n-- exatamente o que aconteceu aqui: este arquivo nasceu antes de o workforce\n-- declarar os `wf_*`, e ao ser replayed derrubava todos eles, violando o\n-- pr\u00F3prio CHECK com as linhas que j\u00E1 existiam (\"is violated by some row\").\n--\n-- Somar o que EST\u00C1 EM USO n\u00E3o afrouxa nada: um writer j\u00E1 gravado na allowlist\n-- j\u00E1 passou por este mesmo port\u00E3o alguma vez. O que o CHECK continua pegando \u00E9\n-- o writer NOVO com nome errado, que \u00E9 para o que ele existe.\nDO $$\nDECLARE v_list text;\nBEGIN\n SELECT string_agg(DISTINCT quote_literal(w), ', ') INTO v_list FROM (\n SELECT unnest(ARRAY[\n 'person', 'unit', 'category', 'product', 'order', 'invoice', 'price_table',\n 'price_table_item', 'package', 'package_item', 'field_rule', 'holiday', 'facet',\n 'fin_account', 'fin_payment_method_type', 'fin_payment_method', 'fin_card_brand',\n 'fin_chart_account', 'fin_cost_center', 'inv_measurement_unit', 'inv_unit_conversion',\n 'inv_location', 'inv_movement', 'order_item', 'fin_movement', 'authz_role',\n 'authz_role_permission', 'person_registration',\n -- Os tr\u00EAs que esta migration traz.\n 'fin_recurring_bill', 'fin_recurring_bill_item', 'fin_recurring_bill_occurrence']) AS w\n UNION\n SELECT a.writer FROM migration.allowlist a WHERE a.writer IS NOT NULL\n ) s;\n\n ALTER TABLE migration.allowlist DROP CONSTRAINT IF EXISTS allowlist_writer_check;\n EXECUTE format(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check CHECK (writer IS NULL OR writer = ANY (ARRAY[%s]))',\n v_list);\nEND $$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_recurring_bill(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_dir_v1 text := nullif(btrim(upper(coalesce(p_row ->> 'direction', ''))), '');\n v_dir text;\n v_unit_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'interval_unit', ''))), '');\n v_count integer := coalesce(nullif(btrim(coalesce(p_row ->> 'interval_count', '')), '')::integer, 1);\n v_freq text;\n v_amount numeric := (p_row ->> 'amount')::numeric;\n v_desc text := nullif(btrim(coalesce(p_row ->> 'description', '')), '');\n v_due_day integer;\n v_starts date := (p_row ->> 'starts_on')::date;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_starts IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma conta recorrente precisa de id e de primeiro vencimento');\n END IF;\n\n -- Sem corre\u00E7\u00E3o silenciosa: o V1 grava 'D' (despesa) e 'R' (receita) em `type`.\n -- Um vencimento com o sentido do dinheiro trocado some do contas a pagar e\n -- aparece no contas a receber, e ningu\u00E9m confere um n\u00FAmero que parece plaus\u00EDvel.\n v_dir := CASE v_dir_v1 WHEN 'D' THEN 'payable' WHEN 'R' THEN 'receivable' ELSE NULL END;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('sentido do dinheiro desconhecido: type=%L (o V1 usa D=despesa, R=receita)', v_dir_v1));\n END IF;\n\n -- Duas colunas do V1 (unidade + quantidade) viram uma palavra no V2. A tradu\u00E7\u00E3o\n -- \u00E9 exaustiva de prop\u00F3sito: \"a cada 2 meses\" n\u00E3o tem nome no vocabul\u00E1rio do V2,\n -- e arredondar para 'monthly' dobraria a previs\u00E3o de caixa daquela conta.\n v_freq := CASE\n WHEN v_unit_v1 = 'week' AND v_count = 1 THEN 'weekly'\n WHEN v_unit_v1 = 'month' AND v_count = 1 THEN 'monthly'\n WHEN v_unit_v1 = 'month' AND v_count = 3 THEN 'quarterly'\n WHEN v_unit_v1 = 'month' AND v_count = 12 THEN 'yearly'\n WHEN v_unit_v1 = 'year' AND v_count = 1 THEN 'yearly'\n ELSE NULL END;\n IF v_freq IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('periodicidade sem equivalente no V2: a cada %s %s (o V2 conhece '\n 'weekly, monthly, quarterly e yearly)', v_count, v_unit_v1));\n END IF;\n\n -- `description` e `amount` s\u00E3o NOT NULL no V2 e n\u00E3o existem na conta do V1:\n -- l\u00E1 eles moram nos itens. O plano de extra\u00E7\u00E3o os reconstr\u00F3i a partir da\n -- pr\u00F3pria origem (Step.augment) \u2014 se vierem vazios, a conta n\u00E3o tem item, e\n -- isso \u00E9 uma linha quebrada na origem, n\u00E3o um default a inventar.\n IF v_desc IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'conta recorrente sem descri\u00E7\u00E3o: no V1 ela vem do item, e esta conta n\u00E3o tem item');\n END IF;\n IF v_amount IS NULL OR v_amount <= 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('valor da conta recorrente \u00E9 %s: o V2 exige amount > 0, e no V1 ele \u00E9 a '\n 'soma dos itens', coalesce(v_amount::text, 'nulo')));\n END IF;\n\n v_due_day := extract(day from v_starts)::integer;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_recurring_bills WHERE id = p_id) THEN\n INSERT INTO public.plg_financial_recurring_bills\n (id, tenant_id, unit_id, description, direction, counterparty_person_id, amount,\n chart_account_id, cost_center_id, frequency, due_day, starts_on, ends_on,\n is_active, notes, items, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_desc, v_dir,\n (p_row ->> 'counterparty_person_id')::uuid, v_amount,\n (p_row ->> 'chart_account_id')::uuid, (p_row ->> 'cost_center_id')::uuid,\n v_freq, v_due_day, v_starts, (p_row ->> 'ends_on')::date,\n coalesce((p_row ->> 'is_active')::boolean, true), p_row ->> 'notes',\n '[]'::jsonb, v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_recurring_bills SET\n unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,\n description = v_desc,\n direction = v_dir,\n counterparty_person_id = CASE WHEN p_row ? 'counterparty_person_id' THEN (p_row ->> 'counterparty_person_id')::uuid ELSE counterparty_person_id END,\n amount = v_amount,\n chart_account_id = CASE WHEN p_row ? 'chart_account_id' THEN (p_row ->> 'chart_account_id')::uuid ELSE chart_account_id END,\n cost_center_id = CASE WHEN p_row ? 'cost_center_id' THEN (p_row ->> 'cost_center_id')::uuid ELSE cost_center_id END,\n frequency = v_freq,\n due_day = v_due_day,\n starts_on = v_starts,\n ends_on = CASE WHEN p_row ? 'ends_on' THEN (p_row ->> 'ends_on')::date ELSE ends_on END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_recurring_bills', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- O item do V1 \u00E9 uma LINHA; no V2 ele \u00E9 um elemento do jsonb `items` da conta.\n-- Por isso este writer n\u00E3o tem tabela pr\u00F3pria e devolve o id da CONTA: o ledger\n-- passa a apontar a linha de item do V1 para a conta que a cont\u00E9m, que \u00E9 onde o\n-- dado foi parar de verdade.\nCREATE OR REPLACE FUNCTION migration.upsert_fin_recurring_bill_item(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_bill uuid := (p_row ->> 'recurring_bill_id')::uuid;\n v_item jsonb;\n v_items jsonb;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_bill IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'um item precisa de id e da conta recorrente a que pertence');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_recurring_bills\n WHERE id = v_bill AND tenant_id = p_tenant) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a conta recorrente %s n\u00E3o existe neste tenant', v_bill));\n END IF;\n\n v_item := jsonb_strip_nulls(jsonb_build_object(\n 'id', p_id,\n 'description', p_row ->> 'description',\n 'quantity', (p_row ->> 'quantity')::numeric,\n 'unit_price', (p_row ->> 'unit_price')::numeric,\n 'discount', (p_row ->> 'discount')::numeric,\n 'addition', (p_row ->> 'addition')::numeric,\n 'total_amount', (p_row ->> 'total_amount')::numeric,\n 'chart_account_id', p_row ->> 'chart_account_id',\n 'cost_center_id', p_row ->> 'cost_center_id',\n -- No V2 um servi\u00E7o \u00C9 um produto: as duas colunas do V1 desaguam aqui.\n 'product_id', coalesce(p_row ->> 'product_id', p_row ->> 'service_product_id')\n ));\n\n -- Substitui o item de mesmo id em vez de acrescentar: reoferecer a mesma linha\n -- numa onda seguinte tem que atualizar, n\u00E3o duplicar o valor da conta.\n SELECT coalesce(jsonb_agg(x), '[]'::jsonb) INTO v_items\n FROM (SELECT value AS x FROM jsonb_array_elements(\n (SELECT items FROM public.plg_financial_recurring_bills WHERE id = v_bill))\n WHERE value ->> 'id' IS DISTINCT FROM p_id::text) s;\n\n UPDATE public.plg_financial_recurring_bills\n SET items = v_items || jsonb_build_array(v_item)\n WHERE id = v_bill;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_recurring_bills', v_bill, '{}'::jsonb); END IF;\n -- target_id \u00E9 a CONTA: \u00E9 l\u00E1 que o item mora no V2.\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_bill,\n 'reason', 'item gravado em plg_financial_recurring_bills.items');\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_recurring_bill_occurrence(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_bill uuid := (p_row ->> 'recurring_bill_id')::uuid;\n v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text;\n v_unit uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_bill IS NULL OR p_row ->> 'due_date' IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma ocorr\u00EAncia precisa de id, da conta recorrente e de vencimento');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_financial_recurring_bills\n WHERE id = v_bill AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a conta recorrente %s n\u00E3o existe neste tenant', v_bill));\n END IF;\n\n -- O V1 usa provisioned | consolidated; 'cancelled' existe do lado do V2 para o\n -- vencimento cancelado DEPOIS da virada. Estado desconhecido vira quarentena\n -- com a palavra \u00E0 vista, n\u00E3o um estado aproximado.\n v_status := CASE v_status_v1\n WHEN 'provisioned' THEN 'provisioned'\n WHEN 'consolidated' THEN 'consolidated'\n WHEN 'cancelled' THEN 'cancelled'\n WHEN 'canceled' THEN 'cancelled'\n ELSE NULL END;\n IF v_status IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('estado de vencimento desconhecido: %L', v_status_v1));\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_recurring_bill_occurrences WHERE id = p_id) THEN\n INSERT INTO public.plg_financial_recurring_bill_occurrences\n (id, tenant_id, unit_id, recurring_bill_id, seq, competence_date, due_date,\n amount, paid_amount, status, overridden, notes, metadata)\n VALUES (p_id, p_tenant, v_unit, v_bill,\n coalesce((p_row ->> 'seq')::integer, 1),\n coalesce((p_row ->> 'competence_date')::date, (p_row ->> 'due_date')::date),\n (p_row ->> 'due_date')::date,\n coalesce((p_row ->> 'amount')::numeric, 0),\n coalesce((p_row ->> 'paid_amount')::numeric, 0),\n v_status, coalesce((p_row ->> 'overridden')::boolean, false),\n p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_recurring_bill_occurrences SET\n unit_id = v_unit,\n seq = CASE WHEN p_row ? 'seq' THEN coalesce((p_row ->> 'seq')::integer, seq) ELSE seq END,\n competence_date = CASE WHEN p_row ? 'competence_date' THEN coalesce((p_row ->> 'competence_date')::date, competence_date) ELSE competence_date END,\n due_date = (p_row ->> 'due_date')::date,\n amount = CASE WHEN p_row ? 'amount' THEN coalesce((p_row ->> 'amount')::numeric, amount) ELSE amount END,\n paid_amount = CASE WHEN p_row ? 'paid_amount' THEN coalesce((p_row ->> 'paid_amount')::numeric, paid_amount) ELSE paid_amount END,\n status = v_status,\n overridden = CASE WHEN p_row ? 'overridden' THEN coalesce((p_row ->> 'overridden')::boolean, overridden) ELSE overridden END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_recurring_bill_occurrences', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 3. O de-para\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Saem da lista de adiadas: a nota delas dizia que o destino n\u00E3o existia, e ele\n-- existe (duas das tr\u00EAs) ou passou a existir (a terceira, acima).\nDELETE FROM migration.excluded_tables\n WHERE source_table IN ('recurring_bills', 'recurring_bill_items', 'recurring_bill_occurrences');\n\n-- `_allow` \u00E9 o construtor oficial: a allowlist tem gatilho de congelamento, e um\n-- INSERT direto por cima de uma linha `frozen` seria recusado.\n--\n-- As quatro colunas que o V2 exige e o V1 n\u00E3o tem na conta \u2014 description, amount,\n-- starts_on e o comprador \u2014 s\u00E3o reconstru\u00EDdas a partir da PR\u00D3PRIA origem pelo\n-- plano de extra\u00E7\u00E3o (`Step.augment` em fayz_etl/sources/beautysoft_v1.py), que \u00E9\n-- a ponte declarada para isso. Nada aqui inventa valor.\n-- \u2500\u2500 a allowlist est\u00E1 CONGELADA, e destravar \u00E9 o caminho previsto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `trg_allowlist_frozen` recusa UPDATE numa linha `frozen`: mudan\u00E7a de core-A\n-- \u00E9 arquivo de migration, n\u00E3o escrita solta \u2014 e esta \u00C9 um arquivo de migration.\n-- `migration.allowlist_unlock` \u00E9 a escotilha que o pr\u00F3prio gatilho l\u00EA, e ela \u00E9\n-- `true` no terceiro argumento: vale s\u00F3 at\u00E9 o fim desta transa\u00E7\u00E3o, ent\u00E3o n\u00E3o\n-- fica uma porta aberta atr\u00E1s deste arquivo.\n--\n-- Sem isto, `_allow` falha ao reescrever `recurring_bills`, que uma onda\n-- anterior j\u00E1 havia declarado.\nSELECT set_config('migration.allowlist_unlock', 'on', true);\n\nSELECT migration._allow(\n 'recurring_bills', 'public.plg_financial_recurring_bills', 'financial',\n 'fin_recurring_bill', '{}'::jsonb, ARRAY['amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies:app.units',\n 'type', 'direction',\n 'first_due_date', 'starts_on',\n 'end_date', 'ends_on',\n 'interval_count', 'interval_count',\n 'interval_unit', 'interval_unit',\n 'active', 'is_active',\n 'observations', 'notes',\n -- Derivadas pelo plano, a partir dos itens e do account_central da conta.\n 'description', 'description',\n 'amount', 'amount',\n 'counterparty_contact_id','counterparty_person_id@contacts:public.people',\n 'chart_account_id', 'chart_account_id@chart_of_accounts',\n 'cost_center_id', 'cost_center_id@cost_centers',\n -- A conta financeira da contraparte continua registrada, ainda que o V2\n -- prefira a PESSOA: \u00E9 por ela que o V1 alcan\u00E7a quem recebe.\n 'account_central_id', 'jsonb:v1.account_central_id',\n -- O autor \u00E9 um login do V1, e nada liga login do V1 a usu\u00E1rio do V2 (#115).\n -- A refer\u00EAncia crua vale mais que perd\u00EA-la e mais que fingir que resolve.\n 'created_by', 'jsonb:v1.created_by',\n 'payment_method_id', 'jsonb:v1.payment_method_id'\n ),\n 'Regra de conta recorrente. description/amount/contraparte s\u00E3o derivados dos itens e do account_central pelo plano de extra\u00E7\u00E3o.'\n);\n\nSELECT migration._allow(\n 'recurring_bill_items', 'public.plg_financial_recurring_bills', 'financial',\n 'fin_recurring_bill_item', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'recurring_bill_id', 'recurring_bill_id@recurring_bills',\n 'description', 'description',\n 'quantity', 'quantity',\n 'unit_price', 'unit_price',\n 'discount', 'discount',\n 'additional', 'addition',\n 'total_amount', 'total_amount',\n 'account_id', 'chart_account_id@chart_of_accounts',\n 'cost_center_id', 'cost_center_id@cost_centers',\n 'product_id', 'product_id@products',\n 'service_id', 'service_product_id@services:public.products',\n -- \u00DAnico valor medido: 'other'. Traduzi-lo exigiria um vocabul\u00E1rio de destino\n -- que n\u00E3o existe; no metadata ele fica recuper\u00E1vel sem inventar nada.\n 'item_type', 'jsonb:v1.item_type'\n ),\n 'Item da conta. No V2 ele \u00E9 elemento do jsonb `items` da pr\u00F3pria conta \u2014 n\u00E3o tem tabela.'\n);\n\nSELECT migration._allow(\n 'recurring_bill_occurrences', 'public.plg_financial_recurring_bill_occurrences',\n 'financial',\n 'fin_recurring_bill_occurrence', '{}'::jsonb, ARRAY['amount', 'paid_amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'recurring_bill_id', 'recurring_bill_id@recurring_bills',\n 'seq', 'seq',\n 'competence_date', 'competence_date',\n 'due_date', 'due_date',\n 'value', 'amount',\n 'paid_value', 'paid_amount',\n 'status', 'status',\n 'overridden', 'overridden',\n 'observations', 'notes',\n -- N\u00C3O vira FK: no V1 aponta para `invoices`, cuja contraparte no V2 \u00E9 uma\n -- VENDA. Ligar uma conta a pagar a uma venda inverteria o sentido do\n -- dinheiro para ganhar um link. O estado `consolidated` j\u00E1 diz o que importa.\n 'invoice_id', 'jsonb:v1.invoice_id'\n ),\n 'O vencimento. `overridden` marca o que algu\u00E9m editou fora da regra \u2014 n\u00E3o \u00E9 deriv\u00E1vel.'\n);\n\n-- Fecha a escotilha antes de qualquer outra coisa neste arquivo tocar a\n-- allowlist sem querer.\nSELECT set_config('migration.allowlist_unlock', 'off', true);\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_fin_recurring_bill(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_recurring_bill(uuid, uuid, jsonb, jsonb) TO service_role;\nREVOKE ALL ON FUNCTION migration.upsert_fin_recurring_bill_item(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_recurring_bill_item(uuid, uuid, jsonb, jsonb) TO service_role;\nREVOKE ALL ON FUNCTION migration.upsert_fin_recurring_bill_occurrence(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_recurring_bill_occurrence(uuid, uuid, jsonb, jsonb) TO service_role;\n";
63
+ export declare const MIGRATION_062_O_VENCIMENTO_APONTA_PARA_O_TITULO = "-- O vencimento aponta para o t\u00EDtulo.\n--\n-- Corre\u00E7\u00E3o de um mapeamento da 046. L\u00E1, `recurring_bill_occurrences.invoice_id`\n-- foi jogado em `metadata` com esta justificativa:\n--\n-- \"N\u00C3O vira FK: no V1 aponta para `invoices`, cuja contraparte no V2 \u00E9 uma\n-- VENDA. Ligar uma conta a pagar a uma venda inverteria o sentido do\n-- dinheiro para ganhar um link.\"\n--\n-- A primeira metade est\u00E1 certa e a segunda \u00E9 falsa. `invoices` do V1 tem DOIS\n-- destinos no V2, n\u00E3o um: o writer `order` grava a Venda em `public.orders` e\n-- devolve, em `extra`, o T\u00CDTULO em `public.plg_financial_invoices`. O ledger\n-- guarda as duas linhas. Medido no tenant 8: 2.365 para `public.orders` e 2.365\n-- para `public.plg_financial_invoices`.\n--\n-- E o t\u00EDtulo \u00E9 exatamente o objeto certo: das 111 faturas que as ocorr\u00EAncias\n-- consolidadas referenciam, as 111 t\u00EAm t\u00EDtulo no V2, e **todas com\n-- `direction = 'payable'`**. N\u00E3o h\u00E1 invers\u00E3o nenhuma a evitar.\n--\n-- \u2500\u2500 O que o erro custava \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `recurring_bill_occurrences.paid_value` \u00E9 0 nas 833 linhas da origem \u2014 no V1 o\n-- pagamento n\u00E3o mora no vencimento, mora no movimento financeiro ligado ao\n-- t\u00EDtulo. Com `invoice_id` em metadata, a ocorr\u00EAncia consolidada chegava ao V2\n-- sem nenhum caminho at\u00E9 o dinheiro: 123 vencimentos marcados `consolidated` e\n-- indistingu\u00EDveis, na tela, de 710 em aberto. A tela dizia a verdade sobre o\n-- campo e mentia sobre o fato.\n--\n-- Com a FK, o caminho existe:\n--\n-- ocorr\u00EAncia \u2192 plg_financial_invoices \u2192 plg_financial_movements (paid_amount)\n--\n-- S\u00E3o R$ 311.006,00 pagos em 181 movimentos, que passam a ser alcan\u00E7\u00E1veis a\n-- partir do vencimento.\n--\n-- Idempotente.\n\n-- A linha j\u00E1 est\u00E1 `frozen`, e o gatilho `trg_allowlist_frozen` recusa a escrita\n-- com a frase certa: \"core-A allowlist changes are a migration file, not a\n-- write\". Este \u00C9 o arquivo de migration, e a escotilha existe exatamente para\n-- ele \u2014 `SET LOCAL`, para a destravada morrer com a transa\u00E7\u00E3o e n\u00E3o vazar para a\n-- sess\u00E3o seguinte.\nSET LOCAL migration.allowlist_unlock = 'on';\n\n-- `_allow` reescreve a linha inteira (ON CONFLICT DO UPDATE), ent\u00E3o o mapa vai\n-- completo \u2014 inclusive as colunas que n\u00E3o mudaram.\nSELECT migration._allow(\n 'recurring_bill_occurrences', 'public.plg_financial_recurring_bill_occurrences',\n 'financial',\n 'fin_recurring_bill_occurrence', '{}'::jsonb, ARRAY['amount', 'paid_amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'recurring_bill_id', 'recurring_bill_id@recurring_bills',\n 'seq', 'seq',\n 'competence_date', 'competence_date',\n 'due_date', 'due_date',\n 'value', 'amount',\n 'paid_value', 'paid_amount',\n 'status', 'status',\n 'overridden', 'overridden',\n 'observations', 'notes',\n -- O T\u00CDTULO, n\u00E3o a venda. `invoices` tem dois destinos no V2 e este \u00E9 o\n -- financeiro; o `:public.plg_financial_invoices` escolhe qual dos dois o\n -- ledger deve resolver.\n 'invoice_id', 'invoice_id@invoices:public.plg_financial_invoices'\n ),\n 'O vencimento. `overridden` marca o que algu\u00E9m editou fora da regra \u2014 n\u00E3o \u00E9 '\n 'deriv\u00E1vel. `invoice_id` liga o vencimento consolidado ao t\u00EDtulo, que \u00E9 onde o '\n 'pagamento mora: no V1 paid_value \u00E9 sempre 0.'\n);\n\n-- O writer precisa gravar a coluna que o de-para agora entrega. Sem isto o mapa\n-- resolveria a refer\u00EAncia e o writer a jogaria fora em sil\u00EAncio \u2014 que \u00E9 a forma\n-- mais cara de errar aqui, porque tudo parece ter funcionado.\nCREATE OR REPLACE FUNCTION migration.upsert_fin_recurring_bill_occurrence(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_bill uuid := (p_row ->> 'recurring_bill_id')::uuid;\n v_invoice uuid := (p_row ->> 'invoice_id')::uuid;\n v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text;\n v_unit uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_bill IS NULL OR p_row ->> 'due_date' IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma ocorr\u00EAncia precisa de id, da conta recorrente e de vencimento');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_financial_recurring_bills\n WHERE id = v_bill AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a conta recorrente %s n\u00E3o existe neste tenant', v_bill));\n END IF;\n\n -- O V1 usa provisioned | consolidated; 'cancelled' existe do lado do V2 para o\n -- vencimento cancelado DEPOIS da virada. Estado desconhecido vira quarentena\n -- com a palavra \u00E0 vista, n\u00E3o um estado aproximado.\n v_status := CASE v_status_v1\n WHEN 'provisioned' THEN 'provisioned'\n WHEN 'consolidated' THEN 'consolidated'\n WHEN 'cancelled' THEN 'cancelled'\n WHEN 'canceled' THEN 'cancelled'\n ELSE NULL END;\n IF v_status IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('estado de vencimento desconhecido: %L', v_status_v1));\n END IF;\n\n -- Um t\u00EDtulo que n\u00E3o existe (ou \u00E9 de outro tenant) n\u00E3o derruba o vencimento: o\n -- vencimento \u00E9 o dado, o v\u00EDnculo \u00E9 o enriquecimento. Perder o v\u00EDnculo \u00E9 ruim;\n -- perder o vencimento por causa dele seria pior.\n IF v_invoice IS NOT NULL AND NOT EXISTS (\n SELECT 1 FROM public.plg_financial_invoices\n WHERE id = v_invoice AND tenant_id = p_tenant) THEN\n v_meta := migration._deep_set(v_meta, ARRAY['v1', 'invoice_unresolved'], to_jsonb(v_invoice));\n v_invoice := NULL;\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_financial_recurring_bill_occurrences WHERE id = p_id) THEN\n INSERT INTO public.plg_financial_recurring_bill_occurrences\n (id, tenant_id, unit_id, recurring_bill_id, seq, competence_date, due_date,\n amount, paid_amount, status, overridden, invoice_id, notes, metadata)\n VALUES (p_id, p_tenant, v_unit, v_bill,\n coalesce((p_row ->> 'seq')::integer, 1),\n coalesce((p_row ->> 'competence_date')::date, (p_row ->> 'due_date')::date),\n (p_row ->> 'due_date')::date,\n coalesce((p_row ->> 'amount')::numeric, 0),\n coalesce((p_row ->> 'paid_amount')::numeric, 0),\n v_status, coalesce((p_row ->> 'overridden')::boolean, false),\n v_invoice, p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_recurring_bill_occurrences SET\n unit_id = v_unit,\n seq = CASE WHEN p_row ? 'seq' THEN coalesce((p_row ->> 'seq')::integer, seq) ELSE seq END,\n competence_date = CASE WHEN p_row ? 'competence_date' THEN coalesce((p_row ->> 'competence_date')::date, competence_date) ELSE competence_date END,\n due_date = (p_row ->> 'due_date')::date,\n amount = CASE WHEN p_row ? 'amount' THEN coalesce((p_row ->> 'amount')::numeric, amount) ELSE amount END,\n paid_amount = CASE WHEN p_row ? 'paid_amount' THEN coalesce((p_row ->> 'paid_amount')::numeric, paid_amount) ELSE paid_amount END,\n status = v_status,\n overridden = CASE WHEN p_row ? 'overridden' THEN coalesce((p_row ->> 'overridden')::boolean, overridden) ELSE overridden END,\n invoice_id = coalesce(v_invoice, invoice_id),\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_recurring_bill_occurrences', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A leitura de contas a pagar, num lugar s\u00F3\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 pergunta que a tela faz \u00E9 \"o que vence, e o que j\u00E1 foi pago?\", e respond\u00EA-la\n-- exige tr\u00EAs jun\u00E7\u00F5es e o conhecimento de que o pagamento mora no movimento, n\u00E3o\n-- no vencimento. Uma view p\u00F5e esse conhecimento no banco em vez de em cada tela\n-- que vier a perguntar.\n-- `security_invoker` porque uma view sem ele roda com os direitos do DONO e\n-- atravessa a RLS das tabelas por baixo: quem abrisse esta leitura pelo\n-- PostgREST leria as contas a pagar de TODOS os inquilinos do cluster. \u00C9 a\n-- postura N16 da bancada, e ela existe porque essa falha j\u00E1 aconteceu aqui.\nCREATE OR REPLACE VIEW public.v_financial_payables WITH (security_invoker = true) AS\nSELECT\n o.tenant_id,\n o.unit_id,\n o.id AS occurrence_id,\n b.id AS recurring_bill_id,\n b.description,\n b.counterparty_person_id,\n p.name AS counterparty_name,\n b.frequency,\n o.seq,\n o.competence_date,\n o.due_date,\n o.amount,\n -- O pagamento vem do movimento do t\u00EDtulo; `o.paid_amount` \u00E9 0 em tudo que veio\n -- do V1, e somar os dois contaria duas vezes o dia em que o V2 come\u00E7ar a\n -- escrever no vencimento.\n coalesce(o.paid_amount, 0) + coalesce(m.pago, 0) AS paid_amount,\n o.amount - (coalesce(o.paid_amount, 0) + coalesce(m.pago, 0)) AS open_amount,\n o.status,\n o.overridden,\n o.invoice_id,\n CASE\n WHEN o.status = 'cancelled' THEN 'cancelado'\n WHEN o.amount <= coalesce(o.paid_amount, 0) + coalesce(m.pago, 0) THEN 'pago'\n WHEN coalesce(m.pago, 0) > 0 THEN 'parcial'\n WHEN o.due_date < current_date THEN 'vencido'\n ELSE 'em aberto'\n END AS situacao\nFROM public.plg_financial_recurring_bill_occurrences o\nJOIN public.plg_financial_recurring_bills b ON b.id = o.recurring_bill_id\nLEFT JOIN public.people p ON p.id = b.counterparty_person_id\nLEFT JOIN LATERAL (\n SELECT sum(mv.paid_amount) AS pago\n FROM public.plg_financial_movements mv\n WHERE mv.financial_invoice_id = o.invoice_id\n) m ON o.invoice_id IS NOT NULL;\n\nCOMMENT ON VIEW public.v_financial_payables IS\n 'Contas a pagar por vencimento, com o pago somado do movimento do t\u00EDtulo. '\n 'No V1 recurring_bill_occurrences.paid_value \u00E9 sempre 0: o pagamento mora no '\n 'movimento, e sem esta jun\u00E7\u00E3o todo vencimento consolidado parece em aberto.';\n\nGRANT SELECT ON public.v_financial_payables TO authenticated, service_role;\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_fin_recurring_bill_occurrence(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_recurring_bill_occurrence(uuid, uuid, jsonb, jsonb) TO service_role;\n";
64
+ export declare const MIGRATION_063_A_UNIDADE_TEM_CONTA_E_A_PESSOA_TEM_UMA_SO = "-- A unidade tem conta, e a pessoa tem uma s\u00F3.\n--\n-- Duas decis\u00F5es sobre o raz\u00E3o financeiro, tomadas depois de medir o que cada uma\n-- custava. As duas estavam registradas como \"adiar\" com uma estimativa de custo,\n-- e a estimativa veio do cliente errado.\n--\n-- \u2500\u2500 1. `account_type = 'unit'` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 plano de extra\u00E7\u00E3o do V1 traduz o vocabul\u00E1rio de conta do V1 para as oito\n-- esp\u00E9cies do V2, e deixava `unit` passar sem tradu\u00E7\u00E3o de prop\u00F3sito, com este\n-- coment\u00E1rio:\n--\n-- \"conta da unidade n\u00E3o \u00E9 pessoa, nem banco, nem caixa. Encaixar em 'system'\n-- seria escolher a sem\u00E2ntica no lugar de quem \u00E9 dono do contrato\n-- financeiro. Custa 6 movimentos na onda 1.\"\n--\n-- A recusa em escolher estava certa. A estimativa n\u00E3o: os 6 movimentos s\u00E3o do\n-- Espa\u00E7o Facial, uma rede de est\u00E9tica onde o dinheiro entra pela venda. Num\n-- restaurante o caixa passa pela conta da pr\u00F3pria unidade, e no Pertinho do C\u00E9u\n-- (licen\u00E7a 8) DUAS contas travam **1.359 movimentos** \u2014 30% do raz\u00E3o:\n--\n-- account_central #195 \"Pertinho do C\u00E9u\" \u2192 777 movimentos\n-- account_central #654100 \"PC Servi\u00E7os\" \u2192 582 movimentos\n--\n-- E n\u00E3o \u00E9 s\u00F3 a contagem: sem elas, `v_financial_payables` mostra 833 vencimentos\n-- em aberto num cliente que pagou R$ 311.006. O n\u00FAmero certo some junto com a\n-- conta.\n--\n-- A escolha foi **criar a esp\u00E9cie**, n\u00E3o encaixar em `system`. `system` j\u00E1 tem\n-- dono sem\u00E2ntico (`CHECK (kind <> 'system' OR code IS NOT NULL)` \u2014 conta de\n-- plano de contas, identificada por c\u00F3digo) e jogar a conta da casa l\u00E1 dentro\n-- faria os dois conceitos compartilharem um balde que nenhum relat\u00F3rio consegue\n-- separar depois.\n--\n-- \u2500\u2500 2. Uma conta por pessoa \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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_accounts_person_uq` diz que uma pessoa tem UMA conta no tenant.\n-- O V1 permite v\u00E1rias (48 casos no Pertinho do C\u00E9u), e cada colis\u00E3o derrubava a\n-- conta E todos os movimentos que a referenciavam.\n--\n-- A constraint FICA. A decis\u00E3o foi **fundir**: da segunda conta em diante, o\n-- writer devolve o id da conta que j\u00E1 existe, o ledger amarra a linha do V1 a\n-- ela, e todo movimento daquela conta passa a apontar para a conta \u00FAnica da\n-- pessoa. O saldo da pessoa fica certo; o que se perde \u00E9 a separa\u00E7\u00E3o que o V1\n-- fazia entre as contas dela, e isso fica escrito em\n-- `metadata.migration.merged_from`.\n--\n-- Fundir \u00E9 o mesmo mecanismo que `upsert_person` j\u00E1 usa para pessoa duplicada, e\n-- \u00E9 revers\u00EDvel pela leitura do ledger: `source_numeric_id` distintos apontando\n-- para o mesmo `target_id` \u00E9 a marca da fus\u00E3o.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. A esp\u00E9cie nova\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nDO $$\nBEGIN\n ALTER TABLE public.plg_financial_accounts DROP CONSTRAINT IF EXISTS plg_financial_accounts_kind_check;\n ALTER TABLE public.plg_financial_accounts ADD CONSTRAINT plg_financial_accounts_kind_check\n CHECK (kind = ANY (ARRAY['bank', 'cash_physical', 'cash_session',\n 'card_receivable_credit', 'card_receivable_debit',\n 'card_payable', 'person', 'system', 'unit']));\n\n -- Uma conta de unidade sem unidade seria a conta de ningu\u00E9m. O par esp\u00E9cie \u2194\n -- coluna obrigat\u00F3ria \u00E9 a mesma disciplina que `person`/`person_id` j\u00E1 tem.\n ALTER TABLE public.plg_financial_accounts DROP CONSTRAINT IF EXISTS plg_financial_accounts_unit_check;\n ALTER TABLE public.plg_financial_accounts ADD CONSTRAINT plg_financial_accounts_unit_check\n CHECK (kind <> 'unit' OR unit_id IS NOT NULL);\nEND $$;\n\nCOMMENT ON CONSTRAINT plg_financial_accounts_unit_check ON public.plg_financial_accounts IS\n 'A conta da pr\u00F3pria unidade (a conta da casa) nomeia a unidade a que pertence. '\n 'Sem isso ela seria indistingu\u00EDvel de uma conta de sistema sem dono.';\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O writer\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_account(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind text := coalesce(p_row ->> 'kind', 'bank');\n v_person uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_merge_into uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'this row needs an id and a name');\n END IF;\n IF v_kind NOT IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit',\n 'card_receivable_debit', 'card_payable', 'person', 'system', 'unit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown account kind %L \u2014 V1 account_type does not map onto the ledger''s nine', v_kind));\n END IF;\n\n -- A pessoa da conta polim\u00F3rfica. O ledger \u00E9 a \u00FAnica fonte que sabe o de-para,\n -- e ele guarda a tabela de origem \u2014 ent\u00E3o `clients`, `contacts` e\n -- `professionals` resolvem pelo mesmo caminho, sem o writer conhecer nenhuma.\n IF v_kind = 'person' THEN\n SELECT l.target_id INTO v_person\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = (v_meta #>> '{v1,reference_table}')\n AND l.source_numeric_id = nullif(v_meta #>> '{v1,reference_id}', '')::bigint\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n -- Recusar \u00E9 o certo, e \u00E9 tempor\u00E1rio: a pessoa entra na mesma onda, e a\n -- segunda passada oferece a conta de novo. Inventar uma pessoa aqui seria\n -- pendurar dinheiro em quem n\u00E3o existe.\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de pessoa cuja pessoa ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,reference_table}', '<sem tabela>'),\n coalesce(v_meta #>> '{v1,reference_id}', '<sem id>')));\n END IF;\n\n -- FUS\u00C3O. O V2 admite uma conta por pessoa e o V1 admite v\u00E1rias; da segunda\n -- em diante a conta do V1 passa a APONTAR para a que j\u00E1 existe, em vez de\n -- derrubar a si mesma e a todo movimento que a referencia.\n SELECT id INTO v_merge_into\n FROM public.plg_financial_accounts\n WHERE tenant_id = p_tenant AND person_id = v_person AND id <> p_id\n LIMIT 1;\n END IF;\n\n -- A unidade da conta da casa, pelo mesmo caminho da pessoa: o ledger sabe qual\n -- `app.units` a `companies` do V1 virou.\n IF v_kind = 'unit' AND v_unit IS NULL THEN\n SELECT l.target_id INTO v_unit\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = coalesce(v_meta #>> '{v1,reference_table}', 'companies')\n AND l.source_numeric_id = nullif(v_meta #>> '{v1,reference_id}', '')::bigint\n AND l.target_table = 'app.units'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de unidade cuja unidade ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,reference_table}', 'companies'),\n coalesce(v_meta #>> '{v1,reference_id}', '<sem id>')));\n END IF;\n END IF;\n\n IF v_merge_into IS NOT NULL THEN\n -- Enriquece a conta que fica e devolve O ID DELA. O motor grava esse\n -- target_id no ledger, e a partir da\u00ED todo movimento que referenciava a\n -- conta do V1 resolve para a conta \u00FAnica da pessoa no V2.\n UPDATE public.plg_financial_accounts SET\n is_active = is_active OR coalesce((p_row ->> 'is_active')::boolean, true),\n metadata = migration._merge_meta(\n metadata,\n migration._deep_set(v_meta, ARRAY['migration', 'merged_from'],\n coalesce(metadata #> '{migration,merged_from}', '[]'::jsonb)\n || jsonb_build_array(jsonb_build_object(\n 'v1_account', v_meta #> '{v1,reference_id}',\n 'name', p_row ->> 'name', 'account_id', p_id))))\n WHERE id = v_merge_into;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', v_merge_into, jsonb_build_object('reason', 'merged')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_merge_into, 'merged', true,\n 'reason', 'conta fundida na conta \u00FAnica da pessoa (o V2 admite uma por pessoa)');\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_financial_accounts WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_financial_accounts (id, tenant_id, unit_id, kind, code, name, bank_name, settlement_days, is_active, person_id, metadata)\n VALUES (p_id, p_tenant, v_unit, v_kind,\n p_row ->> 'code',\n p_row ->> 'name',\n p_row ->> 'bank_name',\n (p_row ->> 'settlement_days')::integer,\n coalesce((p_row ->> 'is_active')::boolean, true),\n v_person,\n v_meta);\n v_created := true;\n ELSE\n -- kind e person_id andam JUNTOS: a constraint amarra os dois, ent\u00E3o mudar\n -- um sem o outro derruba a linha que j\u00E1 estava l\u00E1.\n UPDATE public.plg_financial_accounts SET\n unit_id = coalesce(v_unit, unit_id),\n kind = CASE WHEN p_row ? 'kind' THEN v_kind ELSE kind END,\n person_id = CASE WHEN p_row ? 'kind' THEN v_person ELSE person_id END,\n code = CASE WHEN p_row ? 'code' THEN p_row ->> 'code' ELSE code END,\n name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,\n bank_name = CASE WHEN p_row ? 'bank_name' THEN p_row ->> 'bank_name' ELSE bank_name END,\n settlement_days = CASE WHEN p_row ? 'settlement_days' THEN (p_row ->> 'settlement_days')::integer ELSE settlement_days END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_fin_account(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_account(uuid, uuid, jsonb, jsonb) TO service_role;\n";
65
+ export declare const MIGRATION_064_A_CLASSIFICACAO_CONTABIL_CHEGA_INTEIRA = "-- A classifica\u00E7\u00E3o cont\u00E1bil chega inteira.\n--\n-- Sem esta migration n\u00E3o existe DRE no V2 \u2014 e o motivo n\u00E3o \u00E9 a tela. O DRE do V1\n-- \u00E9 montado sobre DOIS eixos, e os dois se perdiam no caminho:\n--\n-- 1. a NATUREZA da conta (receita \u00D7 despesa)\n-- 2. a CONTA CONT\u00C1BIL DO ITEM (qual linha do DRE aquele lan\u00E7amento \u00E9)\n--\n-- \u2500\u2500 Eixo 1: a natureza estava na coluna errada \u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 de-para mapeava `chart_of_accounts.account_type \u2192 node_type`. S\u00E3o coisas\n-- diferentes: no V1 `account_type` \u00E9 a NATUREZA (asset|liability|equity|revenue|\n-- expense); no V2 `node_type` \u00E9 a FORMA DO N\u00D3 (group|leaf) e `account_type` \u00E9 a\n-- natureza. O resultado, medido no cluster em 18/09/2026:\n--\n-- node_type = 'expense' \u2192 302 linhas account_type = NULL\n-- node_type = 'revenue' \u2192 38 linhas account_type = NULL\n-- node_type = 'leaf' \u2192 244 linhas account_type preenchido\n--\n-- As 244 corretas s\u00E3o as que algu\u00E9m criou pelo app; as 340 erradas s\u00E3o as que o\n-- porte escreveu. E `upsert_fin_chart_account` **nunca gravou `account_type`** \u2014\n-- a coluna n\u00E3o aparece no INSERT nem no UPDATE dele.\n--\n-- Um DRE sem natureza n\u00E3o separa receita de despesa. N\u00E3o erra por pouco: n\u00E3o\n-- monta.\n--\n-- \u2500\u2500 Eixo 2: a conta do item virava texto opaco \u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `invoice_items.account_id \u2192 jsonb:financial.v1_account_ref`. A classifica\u00E7\u00E3o\n-- cont\u00E1bil, que no V1 \u00E9 do ITEM e n\u00E3o do movimento, chegava ao V2 como o id\n-- num\u00E9rico cru do V1 dentro de `metadata` \u2014 sem FK, sem join poss\u00EDvel. E n\u00E3o\n-- havia para onde resolver: `public.items` n\u00E3o tinha coluna de conta cont\u00E1bil.\n--\n-- (`plg_financial_invoice_items.chart_account_id` existe e tem ZERO linhas: o\n-- item do V1 cai s\u00F3 em `public.items`; n\u00E3o h\u00E1 split financeiro para ele.)\n--\n-- \u2500\u2500 O terceiro eixo N\u00C3O precisou de nada \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 dire\u00E7\u00E3o (receita \u00D7 despesa do documento) parecia perdida tamb\u00E9m \u2014\n-- `invoices.type \u2192 jsonb:financial.v1_type`. N\u00E3o est\u00E1: o writer `order` devolve\n-- em `extra` o T\u00CDTULO em `plg_financial_invoices`, e l\u00E1 `direction` j\u00E1 chega\n-- certa. Medido: 8.175 `receivable` + 2.503 `payable`. O DRE l\u00EA o t\u00EDtulo, n\u00E3o o\n-- pedido, e o eixo j\u00E1 existe.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. O item ganha onde guardar a classifica\u00E7\u00E3o\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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.items\n ADD COLUMN IF NOT EXISTS chart_account_id uuid REFERENCES public.plg_financial_chart_of_accounts(id),\n ADD COLUMN IF NOT EXISTS cost_center_id uuid REFERENCES public.plg_financial_cost_centers(id);\n\nCOMMENT ON COLUMN public.items.chart_account_id IS\n 'Conta cont\u00E1bil DESTE item. A classifica\u00E7\u00E3o \u00E9 da linha, n\u00E3o do documento: no '\n 'V1 dois itens da mesma venda podem cair em ramos diferentes do DRE, e \u00E9 essa '\n 'granularidade que o relat\u00F3rio precisa.';\nCOMMENT ON COLUMN public.items.cost_center_id IS\n 'Centro de custo do item. O DRE n\u00E3o usa \u2014 \u00E9 a segunda dimens\u00E3o, para an\u00E1lise '\n 'de custo \u2014 mas perder o dado na entrada seria irrevers\u00EDvel.';\n\n-- A pergunta do DRE \u00E9 \"some os itens por conta, no per\u00EDodo\". Sem \u00EDndice ela\n-- varre `items` inteira por linha do relat\u00F3rio.\nCREATE INDEX IF NOT EXISTS items_chart_account_idx\n ON public.items (tenant_id, chart_account_id) WHERE chart_account_id IS NOT NULL;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. Conserto do dado j\u00E1 migrado\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Devolve a natureza para a coluna certa nas linhas que o porte escreveu. O\n-- teste \u00E9 o pr\u00F3prio vocabul\u00E1rio: `node_type` s\u00F3 pode ser group|leaf|account, e\n-- qualquer outra coisa ali \u00E9 natureza que foi parar no lugar errado.\nUPDATE public.plg_financial_chart_of_accounts\n SET account_type = node_type,\n node_type = 'leaf'\n WHERE node_type IN ('asset', 'liability', 'equity', 'revenue', 'expense')\n AND account_type IS NULL;\n\n-- `node_type` de verdade: \u00E9 grupo quem tem filho. O V1 n\u00E3o tem essa coluna, e\n-- deriv\u00E1-la da pr\u00F3pria \u00E1rvore \u00E9 mais honesto que herdar um default.\nUPDATE public.plg_financial_chart_of_accounts p\n SET node_type = 'group'\n WHERE node_type <> 'group'\n AND EXISTS (SELECT 1 FROM public.plg_financial_chart_of_accounts c WHERE c.parent_id = p.id);\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 3. Os writers\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_chart_account(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_type text := nullif(btrim(lower(coalesce(p_row ->> 'account_type', ''))), '');\n v_parent uuid := (p_row ->> 'parent_id')::uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'this row needs an id and a name');\n END IF;\n\n -- Sem corre\u00E7\u00E3o silenciosa: uma natureza fora do vocabul\u00E1rio do raz\u00E3o faria a\n -- conta sumir do DRE sem aparecer em lugar nenhum \u2014 nem na receita, nem na\n -- despesa, nem num aviso.\n IF v_type IS NOT NULL AND v_type NOT IN ('asset', 'liability', 'equity', 'revenue', 'expense') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('natureza de conta desconhecida: %L (o raz\u00E3o conhece asset, liability, equity, revenue e expense)', v_type));\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_financial_chart_of_accounts WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_financial_chart_of_accounts\n (id, tenant_id, code, name, node_type, account_type, parent_id, is_active)\n VALUES (p_id, p_tenant, p_row ->> 'code', p_row ->> 'name',\n -- Nasce folha; vira grupo abaixo, quando o primeiro filho chegar.\n coalesce(p_row ->> 'node_type', 'leaf'),\n v_type, v_parent,\n coalesce((p_row ->> 'is_active')::boolean, true));\n v_created := true;\n ELSE\n UPDATE public.plg_financial_chart_of_accounts SET\n code = CASE WHEN p_row ? 'code' THEN p_row ->> 'code' ELSE code END,\n name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,\n node_type = CASE WHEN p_row ? 'node_type' THEN coalesce(p_row ->> 'node_type', node_type) ELSE node_type END,\n account_type = coalesce(v_type, account_type),\n parent_id = CASE WHEN p_row ? 'parent_id' THEN v_parent ELSE parent_id END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END\n WHERE id = p_id;\n END IF;\n\n -- Quem tem filho \u00E9 grupo. Marcar aqui, e n\u00E3o num passe no fim, faz a \u00E1rvore\n -- ficar correta mesmo se a onda parar no meio.\n IF v_parent IS NOT NULL THEN\n UPDATE public.plg_financial_chart_of_accounts\n SET node_type = 'group'\n WHERE id = v_parent AND tenant_id = p_tenant AND node_type <> 'group';\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_chart_of_accounts', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- O writer do item passa a gravar as duas colunas novas. O resto do corpo \u00E9 o\n-- que j\u00E1 estava l\u00E1 \u2014 est\u00E1 reemitido inteiro porque \u00E9 assim que se troca uma\n-- fun\u00E7\u00E3o no Postgres, n\u00E3o porque mudou.\nCREATE OR REPLACE FUNCTION migration.upsert_order_item(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_order uuid := (p_row ->> 'order_id')::uuid;\n v_chart uuid := (p_row ->> 'chart_account_id')::uuid;\n v_center uuid := (p_row ->> 'cost_center_id')::uuid;\n v_order_tenant uuid;\n v_existing uuid;\n v_cancelled timestamptz;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_order IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an order line needs an id and an order');\n END IF;\n SELECT tenant_id INTO v_order_tenant FROM public.orders WHERE id = v_order;\n IF v_order_tenant IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', format('order %s is not in the destination yet', v_order));\n END IF;\n IF v_order_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order %s belongs to another tenant', v_order));\n END IF;\n\n v_cancelled := nullif(v_meta #>> '{cancellation,at}', '')::timestamptz;\n\n SELECT tenant_id INTO v_existing FROM public.items WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order line %s already exists in another tenant', p_id));\n END IF;\n IF v_existing IS NULL THEN\n -- `order_id` e S\u00D3 ele: a fatura alcan\u00E7a a linha pelo pedido (items_one_parent).\n INSERT INTO public.items (id, tenant_id, order_id, product_id, name, description,\n quantity, unit_price, discount, total, sort_order,\n cancelled_at, cancel_reason, chart_account_id, cost_center_id, metadata)\n VALUES (p_id, p_tenant, v_order, (p_row ->> 'product_id')::uuid,\n coalesce(nullif(btrim(coalesce(p_row ->> 'name', '')), ''), 'Item'),\n coalesce(p_row ->> 'description', nullif(btrim(coalesce(p_row ->> 'name', '')), '')),\n greatest(coalesce((p_row ->> 'quantity')::numeric, 1), 0.0001),\n round(coalesce((p_row ->> 'unit_price')::numeric, 0), 2),\n round(coalesce((p_row ->> 'discount')::numeric, 0), 2),\n round(coalesce((p_row ->> 'total')::numeric, 0), 2),\n coalesce((p_row ->> 'sort_order')::integer, 0),\n v_cancelled, v_meta #>> '{cancellation,reason}',\n v_chart, v_center, v_meta);\n v_created := true;\n ELSE\n UPDATE public.items SET\n order_id = v_order,\n product_id = CASE WHEN p_row ? 'product_id' THEN (p_row ->> 'product_id')::uuid ELSE product_id END,\n name = CASE WHEN p_row ? 'name' THEN coalesce(nullif(btrim(p_row ->> 'name'), ''), name) ELSE name END,\n quantity = CASE WHEN p_row ? 'quantity' THEN greatest(coalesce((p_row ->> 'quantity')::numeric, 1), 0.0001) ELSE quantity END,\n unit_price = CASE WHEN p_row ? 'unit_price' THEN round(coalesce((p_row ->> 'unit_price')::numeric, 0), 2) ELSE unit_price END,\n discount = CASE WHEN p_row ? 'discount' THEN round(coalesce((p_row ->> 'discount')::numeric, 0), 2) ELSE discount END,\n total = CASE WHEN p_row ? 'total' THEN round(coalesce((p_row ->> 'total')::numeric, 0), 2) ELSE total END,\n cancelled_at = coalesce(v_cancelled, cancelled_at),\n cancel_reason = coalesce(v_meta #>> '{cancellation,reason}', cancel_reason),\n -- `coalesce` e n\u00E3o CASE: a classifica\u00E7\u00E3o \u00E9 enriquecimento. Uma reoferta\n -- sem a coluna n\u00E3o deve apagar a conta que j\u00E1 est\u00E1 l\u00E1.\n chart_account_id = coalesce(v_chart, chart_account_id),\n cost_center_id = coalesce(v_center, cost_center_id),\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.items', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 4. O de-para\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nSET LOCAL migration.allowlist_unlock = 'on';\n\n-- `account_type` para `account_type`. O mapa anterior mandava a natureza para\n-- `node_type`, e \u00E9 a corre\u00E7\u00E3o que faz o DRE existir.\nSELECT migration._allow(\n 'chart_of_accounts', 'public.plg_financial_chart_of_accounts', 'financial',\n 'fin_chart_account', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'code', 'code',\n 'name', 'name',\n 'account_type', 'account_type',\n 'parent_id', 'parent_id@chart_of_accounts',\n 'active', 'is_active'\n ),\n 'Plano de contas. `account_type` \u00E9 a NATUREZA (o eixo do DRE); `node_type` \u00E9 '\n 'derivado da \u00E1rvore pelo writer \u2014 quem tem filho \u00E9 grupo.'\n);\n\n-- O item passa a resolver a conta cont\u00E1bil e o centro de custo em vez de\n-- carreg\u00E1-los como id cru do V1 em metadata.\nSELECT migration._allow(\n 'invoice_items', 'public.items', 'financial',\n 'order_item', '{}'::jsonb, ARRAY['unit_price', 'total_amount'],\n jsonb_build_object(\n 'uuid', 'id',\n 'invoice_id', 'order_id@invoices:public.orders',\n 'description', 'name',\n 'quantity', 'quantity',\n 'unit_price', 'unit_price',\n 'discount', 'discount',\n 'total_amount', 'total',\n 'additional', 'jsonb:financial.additional',\n 'product_id', 'product_id@products',\n 'service_id', 'product_id@services',\n 'account_id', 'chart_account_id@chart_of_accounts',\n 'cost_center_id', 'cost_center_id@cost_centers',\n 'item_type', 'jsonb:item_type',\n 'canceled_at', 'jsonb:cancellation.at',\n 'cancel_reason', 'jsonb:cancellation.reason',\n 'appointment_id', 'jsonb:agenda.v1_appointment_ref',\n 'execution_date', 'jsonb:execution.date',\n 'execution_start_time', 'jsonb:execution.start_time',\n 'execution_end_time', 'jsonb:execution.end_time',\n 'execution_photos', '-',\n 'executed_by_professional_id', 'jsonb:execution.v1_professional_ref',\n 'is_executed', 'jsonb:execution.is_executed',\n 'gift_card_id', 'jsonb:v1_gift_card_ref',\n 'service_package_id', 'jsonb:v1_package_ref'\n ),\n 'A linha da venda. `account_id` \u00E9 a classifica\u00E7\u00E3o cont\u00E1bil DO ITEM \u2014 o eixo '\n 'que o DRE agrupa; antes ela vinha como id cru do V1 em metadata.'\n);\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_fin_chart_account(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_chart_account(uuid, uuid, jsonb, jsonb) TO service_role;\nREVOKE ALL ON FUNCTION migration.upsert_order_item(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_order_item(uuid, uuid, jsonb, jsonb) TO service_role;\n";
66
+ export declare const MIGRATION_065_A_FUSAO_PARA_DE_DOBRAR_A_PROPRIA_MEMORIA = "-- A fus\u00E3o para de dobrar a pr\u00F3pria mem\u00F3ria.\n--\n-- Corre\u00E7\u00E3o de um defeito que eu introduzi na 048 e que derrubou o cluster quatro\n-- vezes em 18/09/2026.\n--\n-- \u2500\u2500 O que eu escrevi \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Na fus\u00E3o de conta de pessoa, para registrar de onde a conta veio:\n--\n-- metadata = migration._merge_meta(\n-- metadata,\n-- migration._deep_set(v_meta, ARRAY['migration','merged_from'],\n-- coalesce(metadata #> '{migration,merged_from}', '[]'::jsonb)\n-- || jsonb_build_array(...)))\n--\n-- Leio o array que j\u00E1 est\u00E1 na linha, acrescento um elemento \u2014 e entrego o\n-- resultado a `_merge_meta`, que **concatena com o array que j\u00E1 est\u00E1 na linha de\n-- novo**. Cada oferta n\u00E3o acrescenta um: ela DOBRA.\n--\n-- oferta 1 \u2192 1 elemento\n-- oferta 5 \u2192 16\n-- oferta 10 \u2192 512\n-- oferta 18 \u2192 262.143 \u2190 medido\n--\n-- \u2500\u2500 O que isso custou \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Medido antes deste conserto, no tenant Pertinho do C\u00E9u:\n--\n-- maior merged_from 262.143 elementos (2\u00B9\u2078 \u2212 1)\n-- maior metadata 1.378 kB numa \u00FAnica linha\n--\n-- Montar e mesclar um jsonb desse tamanho, uma vez por linha, dentro de um\n-- statement, num cluster com `shared_buffers` de 224 MB, mata o backend. E o\n-- sintoma n\u00E3o apontava para c\u00E1: a conex\u00E3o ca\u00EDa no meio de `account_central`, o\n-- transporte reportava \"server closed the connection unexpectedly\", e eu passei\n-- duas rodadas reduzindo o tamanho do lote \u2014 de 2.000 para 250, depois para 100.\n-- Nenhuma ajudou, porque 417 linhas nunca foram o problema.\n--\n-- \u2500\u2500 O conserto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `merged_from` sai. N\u00E3o vira \"append idempotente\", n\u00E3o vira \"array com limite\":\n-- sai inteiro, porque ele nunca deveria ter existido \u2014 **o ledger j\u00E1 registra a\n-- fus\u00E3o**, e melhor: dois `source_numeric_id` distintos apontando para o mesmo\n-- `target_id` \u00C9 a defini\u00E7\u00E3o de fus\u00E3o, \u00E9 consult\u00E1vel, e \u00E9 o lugar onde o resto\n-- deste schema j\u00E1 procura essa resposta.\n--\n-- select source_numeric_id, target_id\n-- from migration.ledger\n-- where source_table = 'account_central' and target_table = 'public.plg_financial_accounts'\n-- order by target_id;\n--\n-- Guardar a mesma informa\u00E7\u00E3o num jsonb ao lado era a segunda c\u00F3pia da regra \u2014\n-- exatamente o que o cabe\u00E7alho do `sdkmig.py` diz para n\u00E3o fazer. A segunda\n-- c\u00F3pia divergiu no primeiro dia, e de forma exponencial.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. Desarmar o que j\u00E1 explodiu\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Primeiro o dado, depois a fun\u00E7\u00E3o: enquanto as linhas gordas estiverem l\u00E1,\n-- qualquer rodada que as toque derruba o banco de novo \u2014 inclusive a que\n-- aplicaria esta migration mais adiante.\nUPDATE public.plg_financial_accounts\n SET metadata = (coalesce(metadata, '{}'::jsonb) #- '{migration,merged_from}')\n WHERE metadata #> '{migration,merged_from}' IS NOT NULL;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O writer para de acumular\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_account(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind text := coalesce(p_row ->> 'kind', 'bank');\n v_person uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_merge_into uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'this row needs an id and a name');\n END IF;\n IF v_kind NOT IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit',\n 'card_receivable_debit', 'card_payable', 'person', 'system', 'unit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown account kind %L \u2014 V1 account_type does not map onto the ledger''s nine', v_kind));\n END IF;\n\n IF v_kind = 'person' THEN\n SELECT l.target_id INTO v_person\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = (v_meta #>> '{v1,reference_table}')\n AND l.source_numeric_id = nullif(v_meta #>> '{v1,reference_id}', '')::bigint\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de pessoa cuja pessoa ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,reference_table}', '<sem tabela>'),\n coalesce(v_meta #>> '{v1,reference_id}', '<sem id>')));\n END IF;\n\n SELECT id INTO v_merge_into\n FROM public.plg_financial_accounts\n WHERE tenant_id = p_tenant AND person_id = v_person AND id <> p_id\n LIMIT 1;\n END IF;\n\n IF v_kind = 'unit' AND v_unit IS NULL THEN\n SELECT l.target_id INTO v_unit\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = coalesce(v_meta #>> '{v1,reference_table}', 'companies')\n AND l.source_numeric_id = nullif(v_meta #>> '{v1,reference_id}', '')::bigint\n AND l.target_table = 'app.units'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de unidade cuja unidade ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,reference_table}', 'companies'),\n coalesce(v_meta #>> '{v1,reference_id}', '<sem id>')));\n END IF;\n END IF;\n\n IF v_merge_into IS NOT NULL THEN\n -- A fus\u00E3o N\u00C3O escreve hist\u00F3rico aqui. Quem registra que duas contas do V1\n -- viraram uma no V2 \u00E9 o ledger: dois `source_numeric_id` apontando para o\n -- mesmo `target_id`. Acumular a mesma informa\u00E7\u00E3o num array de metadata foi o\n -- defeito que dobrava de tamanho a cada oferta e derrubou o cluster.\n UPDATE public.plg_financial_accounts\n SET is_active = is_active OR coalesce((p_row ->> 'is_active')::boolean, true)\n WHERE id = v_merge_into;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', v_merge_into, jsonb_build_object('reason', 'merged')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_merge_into, 'merged', true,\n 'reason', 'conta fundida na conta \u00FAnica da pessoa (o V2 admite uma por pessoa)');\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_financial_accounts WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_financial_accounts (id, tenant_id, unit_id, kind, code, name, bank_name, settlement_days, is_active, person_id, metadata)\n VALUES (p_id, p_tenant, v_unit, v_kind,\n p_row ->> 'code', p_row ->> 'name', p_row ->> 'bank_name',\n (p_row ->> 'settlement_days')::integer,\n coalesce((p_row ->> 'is_active')::boolean, true), v_person, v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_accounts SET\n unit_id = coalesce(v_unit, unit_id),\n kind = CASE WHEN p_row ? 'kind' THEN v_kind ELSE kind END,\n person_id = CASE WHEN p_row ? 'kind' THEN v_person ELSE person_id END,\n code = CASE WHEN p_row ? 'code' THEN p_row ->> 'code' ELSE code END,\n name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,\n bank_name = CASE WHEN p_row ? 'bank_name' THEN p_row ->> 'bank_name' ELSE bank_name END,\n settlement_days = CASE WHEN p_row ? 'settlement_days' THEN (p_row ->> 'settlement_days')::integer ELSE settlement_days END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_fin_account(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_account(uuid, uuid, jsonb, jsonb) TO service_role;\n";
67
+ export declare const MIGRATION_066_A_VENDA_DE_BALCAO_TEM_CONTA = "-- A venda de balc\u00E3o tem conta.\n--\n-- Terceira esp\u00E9cie de conta que o V1 tem e o raz\u00E3o do V2 n\u00E3o tinha \u2014 depois de\n-- `unit` (a conta da casa, migration 048) vem `unidentified`: a conta da venda\n-- para quem n\u00E3o deixou cadastro.\n--\n-- No V1 ela \u00E9 uma linha de `account_central` com `account_type = 'client'` e\n-- `reference_id = 0`. O tipo diz \"pessoa\"; a refer\u00EAncia \u00E9 o sentinela de \"n\u00E3o\n-- h\u00E1 pessoa\". O V2 n\u00E3o aceita a combina\u00E7\u00E3o, e com raz\u00E3o:\n--\n-- CHECK ((kind = 'person') = (person_id IS NOT NULL))\n--\n-- Uma conta de pessoa sem pessoa \u00E9 dinheiro pendurado em ningu\u00E9m. O CHECK est\u00E1\n-- certo; o que faltava era a palavra para o caso em que **n\u00E3o h\u00E1 contraparte e\n-- isso \u00E9 o fato**, n\u00E3o uma falha de cadastro.\n--\n-- \u2500\u2500 Por que esp\u00E9cie pr\u00F3pria, e n\u00E3o `system` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `system` j\u00E1 tem dono: \u00E9 a conta do plano de contas, identificada por c\u00F3digo\n-- (`CHECK (kind <> 'system' OR code IS NOT NULL)`). Jogar a venda de balc\u00E3o l\u00E1\n-- dentro faria dois conceitos dividirem um balde que nenhum relat\u00F3rio separa\n-- depois \u2014 e o de balc\u00E3o n\u00E3o \u00E9 detalhe: num restaurante ele \u00E9 a maior parte do\n-- movimento.\n--\n-- `unidentified` diz a verdade: existe contraparte, ela comprou, e ela n\u00E3o se\n-- identificou. \u00C9 diferente de `system` (n\u00E3o h\u00E1 contraparte) e de `person` (h\u00E1, e\n-- sabemos quem).\n--\n-- \u2500\u2500 O que estava preso \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Medido em 18/09/2026 no tenant Pertinho do C\u00E9u, antes deste arquivo:\n--\n-- account_central em quarentena por `clients #0` 5\n-- financial_movements travados atr\u00E1s delas 110\n-- valor R$ 15.102,78\n--\n-- A asser\u00E7\u00E3o de paridade (`make parity`) apontava exatamente isso: contas a\n-- pagar com \u0394 \u221210.218,30 contra a origem, e a cadeia inteira levava a uma conta.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. A esp\u00E9cie\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nDO $$\nBEGIN\n ALTER TABLE public.plg_financial_accounts DROP CONSTRAINT IF EXISTS plg_financial_accounts_kind_check;\n ALTER TABLE public.plg_financial_accounts ADD CONSTRAINT plg_financial_accounts_kind_check\n CHECK (kind = ANY (ARRAY['bank', 'cash_physical', 'cash_session',\n 'card_receivable_credit', 'card_receivable_debit',\n 'card_payable', 'person', 'system', 'unit',\n 'unidentified']));\nEND $$;\n\nCOMMENT ON COLUMN public.plg_financial_accounts.kind IS\n 'bank | cash_physical | cash_session | card_receivable_credit | '\n 'card_receivable_debit | card_payable | person | system | unit | unidentified. '\n '`unidentified` \u00E9 a contraparte que comprou e n\u00E3o se identificou (venda de '\n 'balc\u00E3o): diferente de `system`, onde n\u00E3o h\u00E1 contraparte, e de `person`, onde '\n 'h\u00E1 e sabemos quem.';\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O writer reconhece o sentinela\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 a perna que muda: quando a conta \u00E9 de pessoa e a refer\u00EAncia da origem \u00E9 o\n-- sentinela (`reference_id = 0`) ou aponta para linha que n\u00E3o existe mais, ela\n-- deixa de ser `person` e passa a ser `unidentified` \u2014 em vez de recusar a conta\n-- e, com ela, todo movimento que a cita.\n--\n-- A distin\u00E7\u00E3o importa e est\u00E1 no c\u00F3digo: refer\u00EAncia AUSENTE (0) vira\n-- `unidentified`; refer\u00EAncia PRESENTE cuja pessoa ainda n\u00E3o migrou continua\n-- caindo em quarentena de `fk`, porque a\u00ED a pessoa existe e a ordem \u00E9 que est\u00E1\n-- errada \u2014 e a segunda passada resolve.\nCREATE OR REPLACE FUNCTION migration.upsert_fin_account(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind text := coalesce(p_row ->> 'kind', 'bank');\n v_ref_id bigint := nullif(v_meta #>> '{v1,reference_id}', '')::bigint;\n v_ref_table text := v_meta #>> '{v1,reference_table}';\n v_person uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_merge_into uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'this row needs an id and a name');\n END IF;\n\n -- O sentinela ANTES da checagem de vocabul\u00E1rio: `clients#0` n\u00E3o \u00E9 uma conta de\n -- pessoa mal formada, \u00E9 uma conta sem contraparte.\n IF v_kind = 'person' AND coalesce(v_ref_id, 0) = 0 THEN\n v_kind := 'unidentified';\n END IF;\n\n IF v_kind NOT IN ('bank', 'cash_physical', 'cash_session', 'card_receivable_credit',\n 'card_receivable_debit', 'card_payable', 'person', 'system', 'unit',\n 'unidentified') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown account kind %L \u2014 V1 account_type does not map onto the ledger''s ten', v_kind));\n END IF;\n\n IF v_kind = 'person' THEN\n SELECT l.target_id INTO v_person\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = v_ref_table\n AND l.source_numeric_id = v_ref_id\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de pessoa cuja pessoa ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_ref_table, '<sem tabela>'), coalesce(v_ref_id::text, '<sem id>')));\n END IF;\n\n SELECT id INTO v_merge_into\n FROM public.plg_financial_accounts\n WHERE tenant_id = p_tenant AND person_id = v_person AND id <> p_id\n LIMIT 1;\n END IF;\n\n IF v_kind = 'unit' AND v_unit IS NULL THEN\n SELECT l.target_id INTO v_unit\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = coalesce(v_ref_table, 'companies')\n AND l.source_numeric_id = v_ref_id\n AND l.target_table = 'app.units'\n AND l.status = 'migrated'\n LIMIT 1;\n IF v_unit IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('conta de unidade cuja unidade ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_ref_table, 'companies'), coalesce(v_ref_id::text, '<sem id>')));\n END IF;\n END IF;\n\n IF v_merge_into IS NOT NULL THEN\n UPDATE public.plg_financial_accounts\n SET is_active = is_active OR coalesce((p_row ->> 'is_active')::boolean, true)\n WHERE id = v_merge_into;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', v_merge_into, jsonb_build_object('reason', 'merged')); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_merge_into, 'merged', true,\n 'reason', 'conta fundida na conta \u00FAnica da pessoa (o V2 admite uma por pessoa)');\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.plg_financial_accounts WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.plg_financial_accounts (id, tenant_id, unit_id, kind, code, name, bank_name, settlement_days, is_active, person_id, metadata)\n VALUES (p_id, p_tenant, v_unit, v_kind,\n p_row ->> 'code', p_row ->> 'name', p_row ->> 'bank_name',\n (p_row ->> 'settlement_days')::integer,\n coalesce((p_row ->> 'is_active')::boolean, true), v_person, v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_financial_accounts SET\n unit_id = coalesce(v_unit, unit_id),\n kind = CASE WHEN p_row ? 'kind' THEN v_kind ELSE kind END,\n person_id = CASE WHEN p_row ? 'kind' THEN v_person ELSE person_id END,\n code = CASE WHEN p_row ? 'code' THEN p_row ->> 'code' ELSE code END,\n name = CASE WHEN p_row ? 'name' THEN p_row ->> 'name' ELSE name END,\n bank_name = CASE WHEN p_row ? 'bank_name' THEN p_row ->> 'bank_name' ELSE bank_name END,\n settlement_days = CASE WHEN p_row ? 'settlement_days' THEN (p_row ->> 'settlement_days')::integer ELSE settlement_days END,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_accounts', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_fin_account(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_account(uuid, uuid, jsonb, jsonb) TO service_role;\n";
68
+ export declare const MIGRATION_067_O_RECIBO_DA_CONTA_ATRAVESSA = "-- O recibo da conta atravessa.\n--\n-- `bill_attachments` \u2014 o comprovante que algu\u00E9m anexou a uma conta a pagar \u2014\n-- estava em `migration.excluded_tables` com esta raz\u00E3o:\n--\n-- Not a row destination: bill attachments are FILES. 091 pointed them at\n-- `storage`, which is object storage and not something a row writer\n-- reaches. Recorded as a decision rather than left pending forever (#113).\n--\n-- A frase est\u00E1 certa sobre os BYTES e errada sobre o resto. Um anexo tem duas\n-- partes: o arquivo, que mora em object storage e nenhum writer de linha\n-- alcan\u00E7a, e o REGISTRO \u2014 nome, tamanho, tipo, quem subiu, quando, e a que\n-- conta pertence. O registro \u00E9 linha, e \u00E9 ele que faz o clipe aparecer na tela\n-- e o `make parity` conseguir contar anexo.\n--\n-- Medido em 19/09/2026, tenant Pertinho do C\u00E9u: **1.192 arquivos em 854 contas**,\n-- nenhum atravessou. E a asser\u00E7\u00E3o de paridade n\u00E3o pegou, porque a fatura que eu\n-- escolhi para testar tem zero anexos dos dois lados \u2014 passou comparando nada\n-- com nada.\n--\n-- \u2500\u2500 Onde eles moram no V2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `public.documents`, que \u00E9 o arquivo de documento do core e j\u00E1 tem 1.382\n-- linhas neste tenant. Ele foi feito exatamente para isto: `subject_type` +\n-- `subject_id` dizem a que o documento pertence, e as colunas de storage\n-- (`storage_bucket`, `storage_path`, `checksum`) esperam os bytes.\n--\n-- O `kind` \u00E9 `attachment`, que j\u00E1 est\u00E1 registrado em `document_kinds` \u2014 a FK\n-- existe e recusa esp\u00E9cie inventada, o que \u00E9 o certo: um vocabul\u00E1rio de\n-- documento que cresce sozinho vira um dep\u00F3sito.\n--\n-- \u2500\u2500 O que esta migration N\u00C3O faz \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- N\u00E3o move um byte. `storage_path` recebe o caminho DO V1 e `storage_provider`\n-- fica marcado como pendente, para que o est\u00E1gio de bytes (`make mig-assets`)\n-- saiba o que buscar e para que ningu\u00E9m confunda \"o registro chegou\" com \"o\n-- arquivo abre\". Um documento cujo link n\u00E3o abre \u00E9 pior que um documento\n-- ausente: o ausente manda procurar no V1.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. O writer\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_document(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n -- `writer_args` diz a que ESP\u00C9CIE e a que SUJEITO estas linhas pertencem, e \u00E9\n -- por isso que o mesmo writer serve anexo de conta, foto de ponto e contrato\n -- de pessoa sem um `IF` por origem aqui dentro.\n v_kind text := coalesce(p_args ->> 'kind', 'attachment');\n v_subject_type text := p_args ->> 'subject_type';\n v_subject uuid := (p_row ->> 'subject_id')::uuid;\n v_path text := nullif(btrim(coalesce(p_row ->> 'storage_path', '')), '');\n v_existing_tenant uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'um documento precisa de id');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.document_kinds k WHERE k.kind = v_kind) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('esp\u00E9cie de documento %L n\u00E3o registrada em document_kinds', v_kind));\n END IF;\n -- O anexo existe PARA estar preso a alguma coisa. Sem sujeito ele entra no\n -- arquivo e n\u00E3o aparece em tela nenhuma \u2014 o que \u00E9 a mesma coisa que n\u00E3o\n -- entrar, com a diferen\u00E7a de ningu\u00E9m ficar sabendo.\n IF v_subject_type IS NOT NULL AND v_subject IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('anexo cujo %s ainda n\u00E3o atravessou', v_subject_type));\n END IF;\n IF v_path IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'anexo sem caminho no storage \u2014 n\u00E3o h\u00E1 o que buscar depois');\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.documents WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n 'reason', format('%s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing_tenant IS NULL THEN\n INSERT INTO public.documents\n (id, tenant_id, kind, title, file_name, file_size, mime_type,\n storage_provider, storage_path, subject_type, subject_id, status, is_active, metadata)\n VALUES (p_id, p_tenant, v_kind,\n coalesce(nullif(btrim(p_row ->> 'file_name'), ''), 'Anexo'),\n p_row ->> 'file_name',\n (p_row ->> 'file_size')::integer,\n p_row ->> 'mime_type',\n -- `v1-pending`: o REGISTRO chegou, o arquivo n\u00E3o. Quem l\u00EA a coluna\n -- sabe que o link ainda n\u00E3o abre, e o est\u00E1gio de bytes sabe o que\n -- ainda tem para buscar.\n 'v1-pending',\n v_path, v_subject_type, v_subject,\n 'active', true, v_meta);\n v_created := true;\n ELSE\n UPDATE public.documents SET\n kind = v_kind,\n file_name = CASE WHEN p_row ? 'file_name' THEN p_row ->> 'file_name' ELSE file_name END,\n file_size = CASE WHEN p_row ? 'file_size' THEN (p_row ->> 'file_size')::integer ELSE file_size END,\n mime_type = CASE WHEN p_row ? 'mime_type' THEN p_row ->> 'mime_type' ELSE mime_type END,\n subject_type = coalesce(v_subject_type, subject_type),\n subject_id = coalesce(v_subject, subject_id),\n -- O caminho s\u00F3 se reescreve enquanto os bytes n\u00E3o vieram: depois de o\n -- arquivo subir, quem manda no caminho \u00E9 o storage do V2.\n storage_path = CASE WHEN storage_provider = 'v1-pending' THEN v_path ELSE storage_path END,\n metadata = migration._merge_meta(metadata, v_meta),\n updated_at = now()\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.documents', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O vocabul\u00E1rio de writers aceita o nome novo\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nDO $$\nDECLARE v_def text;\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n IF v_def IS NULL OR strpos(v_def, 'document') > 0 THEN RETURN; END IF;\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''person''::text',\n '''person''::text, ''document''::text');\nEND $$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 3. Sai da lista de exclu\u00EDdas e entra no plano\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nDELETE FROM migration.excluded_tables WHERE source_table = 'bill_attachments';\n\n-- A allowlist \u00E9 CONGELADA (`trg_allowlist_frozen`): mudan\u00E7a de core-A \u00E9\n-- arquivo de migration, n\u00E3o escrita solta \u2014 e este \u00E9 o arquivo. Sem a\n-- escotilha, a PRIMEIRA passada passa (a linha ainda n\u00E3o existia) e a\n-- SEGUNDA falha: \u00E9 assim que uma migration deixa de ser replay-safe sem\n-- ningu\u00E9m ver. `true` no terceiro argumento: vale s\u00F3 nesta transa\u00E7\u00E3o.\nSELECT set_config('migration.allowlist_unlock', 'on', true);\n\nSELECT migration._allow(\n 'bill_attachments', 'public.documents', 'financial',\n 'document',\n jsonb_build_object('kind', 'attachment', 'subject_type', 'financial_invoice'),\n ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n -- O sujeito: a conta a que o recibo pertence. `invoice_numeric_id` \u00E9 o id\n -- do V1; o ledger devolve o t\u00EDtulo do V2.\n 'invoice_numeric_id', 'subject_id@invoices:public.plg_financial_invoices',\n 'file_name', 'file_name',\n 'file_size', 'file_size',\n 'content_type', 'mime_type',\n 'file_path', 'storage_path',\n 'uploaded_at', 'jsonb:v1.uploaded_at',\n 'uploaded_by', 'jsonb:v1.uploaded_by',\n 'scope', 'jsonb:v1.scope',\n 'bill_id', 'jsonb:v1.bill_ref',\n 'invoice_id', '-'\n ),\n 'O recibo da conta. A LINHA vem por aqui; os BYTES s\u00E3o o est\u00E1gio de assets \u2014 '\n '`storage_provider = ''v1-pending''` \u00E9 o que os separa, para que ningu\u00E9m '\n 'confunda \"o registro chegou\" com \"o arquivo abre\".'\n);\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_document(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_document(uuid, uuid, jsonb, jsonb) TO service_role;\n\nSELECT set_config('migration.allowlist_unlock', 'off', true);\n";
69
+ export declare const MIGRATION_068_A_UNIDADE_ENTRA_COM_A_LINHA_NAO_DEPOIS_DELA = "-- A unidade entra COM a linha, n\u00E3o num UPDATE depois dela.\n--\n-- Todo writer deste motor termina do mesmo jeito:\n--\n-- INSERT INTO <destino> (\u2026 sem unit_id \u2026);\n-- PERFORM migration._set_unit('<destino>', p_id, <unidade>);\n--\n-- e `_set_unit` era um UPDATE incondicional. Duas coisas erradas na mesma\n-- linha: ele reescreve uma linha que acabou de ser escrita, e reescreve\n-- mesmo quando o valor n\u00E3o muda.\n--\n-- \u2500\u2500 O pre\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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Medido em 19/09/2026 no tenant Espa\u00E7o Facial, com EXPLAIN ANALYZE de um\n-- `UPDATE orders SET unit_id = unit_id` \u2014 um UPDATE que, por constru\u00E7\u00E3o, n\u00E3o\n-- muda valor nenhum:\n--\n-- Update on orders rows=0\n-- Trigger for constraint orders_kind_registered: time=39.738\n-- Trigger for constraint orders_party_id_fkey: time=398.910\n-- Trigger for constraint orders_tenant_id_fkey: time=646.595\n-- Trigger for constraint orders_unit_fk: time=275.950\n-- Execution Time: 1371.357 ms\n--\n-- **1,37 segundo para n\u00E3o mudar nada.** O trabalho n\u00E3o est\u00E1 na escrita: est\u00E1\n-- na revalida\u00E7\u00E3o das chaves estrangeiras e na reescrita dos onze \u00EDndices de\n-- `orders`, porque uma nova vers\u00E3o da linha precisa aparecer em todos eles.\n--\n-- E o custo das FKs \u00E9 de CONTEN\u00C7\u00C3O, n\u00E3o de busca: a checagem de\n-- `orders_tenant_id_fkey` tranca a MESMA linha de `public.tenants` a cada\n-- escrita do tenant. Uma sondagem de \u00EDndice que deveria custar microssegundos\n-- passa a esperar.\n--\n-- S\u00E3o 166.432 faturas no Espa\u00E7o Facial. S\u00F3 esse UPDATE s\u00E3o 63 horas.\n--\n-- \u2500\u2500 O conserto, em dois lugares \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. `_set_unit` s\u00F3 escreve quando o valor MUDA (`IS DISTINCT FROM`). Isso vale\n-- para todos os writers de uma vez, e faz da reoferta de uma linha j\u00E1\n-- migrada o que ela deveria ser: leitura.\n--\n-- 2. `upsert_order` leva `unit_id` no pr\u00F3prio INSERT. Assim a linha nova nasce\n-- completa e o `_set_unit` logo abaixo vira o no-op do item 1. A chamada\n-- FICA, de prop\u00F3sito: ela continua sendo o caminho do UPDATE (quando a\n-- unidade muda no V1) e de qualquer writer que ainda n\u00E3o carregue a coluna.\n--\n-- Nada muda no resultado: a linha termina com a mesma unidade. O que muda \u00E9 o\n-- n\u00FAmero de vers\u00F5es dela.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. O UPDATE que s\u00F3 acontece quando h\u00E1 o que mudar\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration._set_unit(p_table text, p_id uuid, p_unit uuid)\n RETURNS void\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nBEGIN\n IF p_unit IS NULL OR NOT migration._has_column(p_table, 'unit_id') THEN RETURN; END IF;\n -- `IS DISTINCT FROM` e n\u00E3o `<>`: a coluna \u00E9 anul\u00E1vel, e `NULL <> 'x'` \u00E9 NULL,\n -- que n\u00E3o \u00E9 verdadeiro \u2014 a linha sem unidade nunca receberia a dela.\n EXECUTE format('UPDATE %s SET unit_id = $1 WHERE id = $2 AND unit_id IS DISTINCT FROM $1', p_table)\n USING p_unit, p_id;\nEND $function$;\n\nCOMMENT ON FUNCTION migration._set_unit(text, uuid, uuid) IS\n 'Carimba a unidade no destino, e s\u00F3 quando ela muda. Sem o qual de guarda '\n 'este UPDATE rodava em toda linha de todo writer: 1,37 s medidos em '\n 'public.orders para n\u00E3o mudar valor nenhum, quase tudo em revalida\u00E7\u00E3o de '\n 'chave estrangeira e reescrita de \u00EDndice.';\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. A Venda nasce com a unidade\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_order(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_map jsonb := coalesce(p_args -> 'status_map', '{}'::jsonb);\n v_kind_map jsonb := coalesce(p_args -> 'kind_map', '{}'::jsonb);\n v_src_status text := p_row ->> 'status';\n v_status text;\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_src_kind text;\n v_kind text;\n v_direction text;\n v_party uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_invoice jsonb;\n v_invoice_id uuid;\n v_extra jsonb := '[]'::jsonb;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an order needs an id');\n END IF;\n\n -- A esp\u00E9cie sai do `type` da origem quando o de-para declarou o mapa; sem\n -- mapa, nada muda em rela\u00E7\u00E3o a antes.\n v_src_kind := v_meta #>> '{financial,v1_type}';\n v_kind := coalesce(v_kind_map ->> coalesce(v_src_kind, ''), p_args ->> 'kind', 'sale');\n IF NOT EXISTS (SELECT 1 FROM public.order_kinds k WHERE k.kind = v_kind) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('order kind %L is not declared in public.order_kinds', v_kind));\n END IF;\n -- Compra \u00E9 documento de despesa: o t\u00EDtulo dela \u00E9 a pagar. \u00C9 o que a pr\u00F3pria\n -- descri\u00E7\u00E3o de `order_kinds.purchase` diz.\n v_direction := CASE WHEN v_kind = 'purchase' THEN 'payable' ELSE 'receivable' END;\n\n v_status := coalesce(v_map ->> coalesce(v_src_status, ''), v_map ->> '*', v_src_status, 'completed');\n v_meta := migration._deep_set(v_meta, ARRAY['migration', 'source_status'], to_jsonb(v_src_status));\n\n -- A contraparte. `party_id` j\u00E1 vem resolvido quando \u00E9 cliente; quando \u00E9\n -- fornecedor ou profissional, quem resolve \u00E9 o ledger \u2014 as tr\u00EAs tabelas do\n -- V1 viram `public.people`, e o ledger sabe qual virou qual.\n BEGIN\n v_party := (p_row ->> 'party_id')::uuid;\n EXCEPTION WHEN OTHERS THEN v_party := NULL; END;\n IF v_party IS NULL AND (v_meta #>> '{financial,v1_ref_table}') IS NOT NULL THEN\n SELECT l.target_id INTO v_party\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = (v_meta #>> '{financial,v1_ref_table}')\n AND l.source_numeric_id = nullif(v_meta #>> '{financial,v1_ref_id}', '')::bigint\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.orders WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order %s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n -- `unit_id` entra JUNTO, e n\u00E3o num UPDATE logo depois. O UPDATE que vinha\n -- em seguida (via `_set_unit`) criava uma segunda vers\u00E3o da linha, reescrevia\n -- os onze \u00EDndices de `orders` e refazia as checagens de chave estrangeira \u2014\n -- medido em 19/09/2026: 1.371 ms para um UPDATE que n\u00E3o mudava valor nenhum,\n -- quase tudo em revalida\u00E7\u00E3o de FK (tenant 646 ms, party 398 ms, unit 275 ms).\n -- Por linha, e s\u00E3o 166.432 faturas.\n INSERT INTO public.orders (id, tenant_id, unit_id, kind, reference_number, status, party_id, subtotal, discount, tax, total, currency, completed_at, notes, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_kind, p_row ->> 'reference_number', v_status, v_party,\n (p_row ->> 'subtotal')::numeric, coalesce((p_row ->> 'discount')::numeric, 0), coalesce((p_row ->> 'tax')::numeric, 0),\n (p_row ->> 'total')::numeric, coalesce(p_row ->> 'currency', 'BRL'), (p_row ->> 'completed_at')::timestamptz,\n p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n -- financial columns are guarded upstream (upsert_row) and not touched here\n UPDATE public.orders SET\n kind = v_kind,\n reference_number = CASE WHEN p_row ? 'reference_number' THEN p_row ->> 'reference_number' ELSE reference_number END,\n status = CASE WHEN p_row ? 'status' THEN v_status ELSE status END,\n party_id = coalesce(v_party, party_id),\n completed_at = CASE WHEN p_row ? 'completed_at' THEN (p_row ->> 'completed_at')::timestamptz ELSE completed_at END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n PERFORM migration._set_unit('public.orders', p_id, (p_row ->> 'unit_id')::uuid);\n\n IF coalesce(p_args ->> 'split_invoice', 'false') = 'true' THEN\n v_invoice_id := md5('migration:invoice:' || p_id::text)::uuid;\n v_invoice := migration.upsert_invoice(p_tenant, v_invoice_id,\n jsonb_build_object('order_id', p_id, 'party_id', to_jsonb(v_party), 'unit_id', p_row -> 'unit_id', 'total', p_row -> 'total',\n 'competence_date', p_row -> 'completed_at', 'status', 'open',\n -- a fatura leva o texto da origem: sem isto a lista do\n -- financeiro mostra a coluna Descri\u00E7\u00E3o vazia em tudo\n 'description', p_row -> 'notes',\n 'metadata', v_meta),\n jsonb_build_object('direction', v_direction, 'kind', v_direction));\n v_extra := v_extra || jsonb_build_object('target_table', 'public.plg_financial_invoices', 'target_id', v_invoice_id,\n 'status', CASE WHEN v_invoice ->> 'status' = 'migrated' THEN 'migrated' ELSE 'skipped' END,\n 'reason', v_invoice ->> 'reason',\n 'financial', CASE WHEN v_invoice ->> 'status' = 'migrated' THEN jsonb_build_object('total', p_row -> 'total') ELSE NULL END);\n END IF;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.orders', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END, 'extra', v_extra);\nEND $function$\n\n;\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration._set_unit(text, uuid, uuid) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration._set_unit(text, uuid, uuid) TO service_role;\nREVOKE ALL ON FUNCTION migration.upsert_order(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_order(uuid, uuid, jsonb, jsonb) TO service_role;\n";
70
+ export declare const MIGRATION_069_O_TITULO_QUE_JA_EXISTE_ESTA_MIGRADO = "-- O t\u00EDtulo que j\u00E1 existe EST\u00C1 migrado.\n--\n-- `upsert_invoice` recusa reescrever um documento financeiro que j\u00E1 est\u00E1 no\n-- destino. A recusa est\u00E1 certa e continua. O que estava errado era o nome que\n-- ela dava ao resultado:\n--\n-- RETURN jsonb_build_object('status', 'skipped', 'target_id', p_id,\n-- 'reason', 'invoice already exists: financial documents are never rewritten')\n--\n-- `skipped` quer dizer \"esta linha ainda n\u00E3o tem destino\". Mas ela tem: est\u00E1\n-- l\u00E1, com aquele id, vinda daquela linha da origem. O estado \u00E9 `migrated`.\n--\n-- \u2500\u2500 Por que a palavra custava caro \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `migration._upsert_row_impl` reexecuta o writer quando alguma IRM\u00C3 da linha\n-- \u2014 o mesmo registro da origem gravado com OUTRO destino \u2014 est\u00E1 `pending` ou\n-- `skipped`. \u00C9 o mecanismo que faz a fatura de uma Venda nascer numa segunda\n-- passada, quando o writer dela n\u00E3o existia na primeira. Bom mecanismo.\n--\n-- S\u00F3 que uma Venda do V1 produz DUAS linhas de ledger: `public.orders` e\n-- `public.plg_financial_invoices`. Com a segunda marcada `skipped` para\n-- sempre, a condi\u00E7\u00E3o nunca deixava de ser verdadeira. Toda rodada, para cada\n-- t\u00EDtulo j\u00E1 migrado:\n--\n-- sonda a irm\u00E3 \u2192 encontra `skipped` \u2192 reexecuta upsert_order inteiro\n-- \u2192 upsert_invoice \u2192 \"j\u00E1 existe\" \u2192 `skipped` de novo\n--\n-- Medido em 20/09/2026 no tenant Espa\u00E7o Facial: **7.893 t\u00EDtulos** nesse la\u00E7o.\n-- A etapa `invoices` passava minutos sem produzir uma linha \u2014 parecia travada,\n-- e era s\u00F3 trabalho refeito. E como ela vem ANTES de `financial_movements` no\n-- plano, o que a tela de Contas a Receber precisa nunca chegava a ser lido.\n--\n-- \u2500\u2500 O conserto, e o reparo do que ficou escrito errado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- 1. O writer devolve `migrated` quando a linha j\u00E1 est\u00E1 l\u00E1, com a raz\u00E3o\n-- registrando que ela n\u00E3o foi reescrita. Nada \u00E9 atualizado \u2014 a recusa \u00E9 a\n-- mesma; muda o relato.\n--\n-- 2. As linhas de ledger que j\u00E1 carregam o relato antigo s\u00E3o corrigidas, e s\u00F3\n-- elas: o UPDATE exige que a linha de destino EXISTA de fato. Uma que n\u00E3o\n-- exista n\u00E3o \u00E9 corrigida aqui \u2014 ela \u00E9 uma linha que n\u00E3o migrou, e continuar\n-- chamando-a de `skipped` \u00E9 o correto.\n--\n-- Idempotente.\n\nCREATE OR REPLACE FUNCTION migration.upsert_invoice(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_table text := 'public.plg_financial_invoices';\n v_dir text := coalesce(p_args ->> 'direction', p_row ->> 'direction', 'receivable');\n v_total numeric := round(coalesce((p_row ->> 'total')::numeric, -1), 2);\n v_party uuid;\n v_number text;\n v_insts jsonb;\n v_sum numeric := 0;\n v_i jsonb;\n v_n integer := 0;\n v_due date;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF to_regclass(v_table) IS NULL THEN\n RETURN jsonb_build_object('status', 'skipped', 'reason', 'destination table absent: ' || v_table);\n END IF;\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n EXECUTE format('SELECT EXISTS (SELECT 1 FROM %s WHERE id = $1)', v_table) INTO v_exists USING p_id;\n IF v_exists THEN\n -- `migrated`, e nao `skipped`. A regra de nao reescrever documento\n -- financeiro continua valendo - nada e atualizado aqui. O que muda e o\n -- NOME que se da ao resultado: a linha existe no destino, com este id,\n -- vinda desta linha da origem. Isso e o estado migrado.\n --\n -- Chama-lo de `skipped` tinha uma consequencia que nao era de vocabulario.\n -- `_upsert_row_impl` reexecuta o writer quando alguma IRMA da linha (o\n -- mesmo registro da origem com outro destino) esta `pending` ou `skipped` -\n -- e assim que a fatura de uma Venda nasce numa segunda passada, quando o\n -- writer dela nao existia na primeira. Com o titulo marcado `skipped` para\n -- sempre, essa condicao nunca deixava de ser verdadeira: toda rodada\n -- reexecutava o writer inteiro para chegar de novo aqui.\n --\n -- Medido em 20/09/2026 no tenant Espaco Facial: 7.893 titulos nesse estado,\n -- refeitos a cada rodada antes de o motor sequer chegar nas linhas novas. A\n -- etapa `invoices` passava minutos sem produzir uma linha, e parecia travada.\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', 'already present; financial documents are never rewritten by the migration');\n END IF;\n\n IF v_dir NOT IN ('receivable', 'payable') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown invoice direction %L', v_dir));\n END IF;\n IF v_total < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'an invoice needs a total >= 0');\n END IF;\n\n -- the counterparty is a Person. A reference that does not resolve is a broken\n -- link, not a column to drop \u2014 which is what the old candidate-name loop did.\n BEGIN\n v_party := coalesce((p_row ->> 'counterparty_person_id')::uuid, (p_row ->> 'party_id')::uuid);\n EXCEPTION WHEN OTHERS THEN v_party := NULL; END;\n IF v_party IS NOT NULL AND NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = v_party AND p.tenant_id = p_tenant) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('counterparty %s is not a Person of this tenant', v_party));\n END IF;\n\n v_due := coalesce((p_row ->> 'due_date')::date, (p_row ->> 'competence_date')::date, current_date);\n\n -- installments: as offered, or one for the whole total. An invoice with no\n -- installment is a document nobody can pay, and the balance views derive\n -- everything from them.\n v_insts := CASE WHEN jsonb_typeof(p_row -> 'installments') = 'array' AND jsonb_array_length(p_row -> 'installments') > 0\n THEN p_row -> 'installments'\n ELSE jsonb_build_array(jsonb_build_object('number', 1, 'due_date', v_due, 'amount', v_total)) END;\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 RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('installments sum to %s but the invoice total is %s \u2014 which one is wrong is not a migration''s call', v_sum, v_total));\n END IF;\n\n -- same series as an invoice created in V2: two numbering schemes in one tenant\n -- is the kind of thing nobody notices until an auditor does\n BEGIN\n v_number := public.plg_financial_next_invoice_number(p_tenant, v_dir);\n EXCEPTION WHEN OTHERS THEN v_number := p_row ->> 'number'; END;\n\n INSERT INTO public.plg_financial_invoices\n (id, tenant_id, unit_id, direction, counterparty_person_id, order_id, number,\n competence_date, issue_date, total, description, notes, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_dir, v_party, (p_row ->> 'order_id')::uuid, v_number,\n coalesce((p_row ->> 'competence_date')::date, v_due), coalesce((p_row ->> 'issue_date')::date, v_due),\n v_total, p_row ->> 'description', p_row ->> 'notes',\n coalesce(p_row -> 'metadata', '{}'::jsonb));\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 (p_tenant, (p_row ->> 'unit_id')::uuid, p_id,\n coalesce((v_i ->> 'number')::int, v_n),\n coalesce((v_i ->> 'due_date')::date, v_due),\n round(coalesce((v_i ->> 'amount')::numeric, 0), 2),\n CASE WHEN jsonb_typeof(v_i -> 'metadata') = 'object' THEN v_i -> 'metadata' ELSE '{}'::jsonb END);\n END LOOP;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, v_table, p_id, jsonb_build_object('reason', 'created', 'installments', v_n)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created', 'installments', v_n, 'number', v_number);\nEND $function$\n\n;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- O reparo: o ledger passa a dizer o que o destino mostra\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nUPDATE migration.ledger l\n SET status = 'migrated',\n reason = 'already present; financial documents are never rewritten by the migration'\n WHERE l.target_table = 'public.plg_financial_invoices'\n AND l.status = 'skipped'\n AND l.reason = 'invoice already exists: financial documents are never rewritten by the migration'\n -- A trava: s\u00F3 corrige o relato de uma linha que REALMENTE est\u00E1 no destino.\n AND EXISTS (SELECT 1 FROM public.plg_financial_invoices t\n WHERE t.id = l.target_id AND t.tenant_id = l.tenant_id);\n";
71
+ export declare const MIGRATION_070_A_PARCELA_NASCE_ANTES_DO_MOVIMENTO = "-- A parcela nasce antes do movimento que aponta para ela.\n--\n-- No V1 a conta \u00C9 a parcela: uma linha de `financial_movements` com\n-- `movement_type = 'bill'` \u00E9, ao mesmo tempo, o lan\u00E7amento e o vencimento. O\n-- porte reproduz isso gravando as duas coisas com o MESMO id \u2014 o movimento\n-- aponta para a parcela por `financial_invoice_installment_id = p_id`.\n--\n-- E `plg_financial_movements_installment_fk` n\u00E3o \u00E9 adi\u00E1vel:\n--\n-- FOREIGN KEY (financial_invoice_installment_id)\n-- REFERENCES plg_financial_invoice_installments(id) -- NOT DEFERRABLE\n--\n-- `upsert_fin_movement` inseria o movimento primeiro e criava a parcela\n-- depois. Ou seja: apontava para uma linha que ainda n\u00E3o existia.\n--\n-- 23503: insert or update on table \"plg_financial_movements\" violates\n-- foreign key constraint \"plg_financial_movements_installment_fk\"\n--\n-- \u2500\u2500 O que isso custou \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Medido em 20/09/2026, tenant Espa\u00E7o Facial, numa \u00FAnica rodada:\n-- **14.928 contas recusadas**. E o estrago n\u00E3o para nelas: a conta que n\u00E3o\n-- entra n\u00E3o vira parcela, a baixa que quitaria essa parcela tamb\u00E9m \u00E9 recusada,\n-- e `v_financial_invoice_lines_due` \u2014 que \u00E9 o que a tela de Contas a Receber\n-- l\u00EA \u2014 n\u00E3o tem o que mostrar. A tela aparecia vazia e o motivo estava tr\u00EAs\n-- camadas abaixo.\n--\n-- N\u00E3o apareceu antes porque no Pertinho do C\u00E9u os movimentos j\u00E1 existiam\n-- quando a 042 entrou: eles pegavam o caminho do UPDATE, e o UPDATE tolera o\n-- ponteiro porque a parcela daquele porte j\u00E1 tinha sido criada. Um tenant novo,\n-- carregado do zero, encontra a ordem errada na primeira linha.\n--\n-- \u2500\u2500 O conserto: duas chamadas, e cada uma no seu lugar \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 ordem tem duas exig\u00EAncias que se contradizem se houver uma chamada s\u00F3:\n--\n-- \u00B7 a PARCELA precisa existir antes do movimento \u2014 a FK exige.\n-- \u00B7 o SINCRONIZADOR (`plg_financial_sync_mirror`) procura o movimento pela\n-- parcela \u2014 logo, precisa do movimento.\n--\n-- Ent\u00E3o a parcela \u00E9 criada antes, na forma de sete argumentos, que n\u00E3o\n-- sincroniza. Depois de o movimento existir, a chamada de oito argumentos\n-- completa o cancelamento e sincroniza o espelho. A segunda \u00E9 um\n-- `ON CONFLICT DO UPDATE` sobre a linha que a primeira criou \u2014 barata, e \u00E9 ela\n-- que fecha o ciclo.\n--\n-- Idempotente.\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_movement(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'movement_kind', ''))), '');\n v_kind text := CASE v_kind_raw\n WHEN 'bill' THEN 'bill' WHEN 'payment' THEN 'payment'\n WHEN 'transfer' THEN 'transfer' WHEN 'commission' THEN 'commission'\n WHEN 'conta' THEN 'bill' WHEN 'titulo' THEN 'bill' WHEN 't\u00EDtulo' THEN 'bill'\n WHEN 'pagamento' THEN 'payment' WHEN 'recebimento' THEN 'payment'\n WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\u00EAncia' THEN 'transfer'\n WHEN 'comissao' THEN 'commission' WHEN 'comiss\u00E3o' THEN 'commission'\n END;\n v_status_raw text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text := CASE v_status_raw\n WHEN 'canceled' THEN 'cancelled'\n WHEN 'cancelado' THEN 'cancelled'\n WHEN 'cancelada' THEN 'cancelled'\n WHEN 'pago' THEN 'paid'\n WHEN 'paga' THEN 'paid'\n WHEN 'pendente' THEN 'pending'\n WHEN 'vencido' THEN 'overdue'\n WHEN 'vencida' THEN 'overdue'\n WHEN 'atrasado' THEN 'overdue'\n WHEN 'parcial' THEN 'partial'\n ELSE coalesce(v_status_raw, 'pending')\n END;\n v_dir text := nullif(btrim(lower(coalesce(p_row ->> 'direction', ''))), '');\n v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);\n v_invoice uuid := (p_row ->> 'financial_invoice_id')::uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_due date;\n v_legs record;\n v_debit uuid := (p_row ->> 'debit_account_id')::uuid;\n v_credit uuid := (p_row ->> 'credit_account_id')::uuid;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a movement needs an id');\n END IF;\n IF v_kind IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown movement kind %L (expected bill|payment|transfer|commission, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));\n END IF;\n IF v_dir IS NOT NULL AND v_dir NOT IN ('debit', 'credit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown direction %L (expected debit|credit)', v_dir));\n END IF;\n IF v_dir IS NULL AND v_invoice IS NOT NULL THEN\n SELECT CASE t.direction WHEN 'receivable' THEN 'credit' WHEN 'payable' THEN 'debit' END INTO v_dir\n FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'the movement carries no direction and settles no invoice \u2014 which way the money went cannot be derived, and must not be defaulted');\n END IF;\n IF v_amount < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a money event cannot carry a negative amount \u2014 direction is the direction, not the sign');\n END IF;\n\n IF v_unit IS NULL AND v_invoice IS NOT NULL THEN\n SELECT t.unit_id INTO v_unit FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n\n v_due := coalesce((p_row ->> 'due_date')::date, (p_row ->> 'paid_at')::date, current_date);\n\n IF v_debit IS NULL OR v_credit IS NULL THEN\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n p_tenant, v_kind, v_dir, (p_row ->> 'bank_account_id')::uuid, (p_row ->> 'cash_session_id')::uuid,\n v_invoice, v_meta);\n IF v_legs.o_mirror THEN\n v_meta := v_meta || jsonb_build_object('legacy_mirror', true);\n ELSE\n v_debit := coalesce(v_debit, v_legs.o_debit);\n v_credit := coalesce(v_credit, v_legs.o_credit);\n END IF;\n END IF;\n\n -- \u2500\u2500 A parcela nasce ANTES do movimento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 movimento de conta aponta para a parcela dele\n -- (`financial_invoice_installment_id = p_id`, porque no V1 a conta \u00C9 a\n -- parcela), e `plg_financial_movements_installment_fk` N\u00C3O \u00E9 adi\u00E1vel. Criar a\n -- parcela depois do INSERT \u00E9 apontar para uma linha que ainda n\u00E3o existe:\n --\n -- 23503: insert or update on table \"plg_financial_movements\" violates\n -- foreign key constraint \"plg_financial_movements_installment_fk\"\n --\n -- Medido em 20/09/2026 no tenant Espa\u00E7o Facial: 14.928 contas recusadas por\n -- isso numa s\u00F3 rodada \u2014 e como a conta n\u00E3o entrava, a baixa dela tamb\u00E9m n\u00E3o,\n -- e Contas a Receber ficava vazia.\n --\n -- Aqui a parcela \u00E9 criada SEM sincronizar o espelho: o sincronizador procura\n -- o movimento pela parcela, e o movimento ainda n\u00E3o existe. A sincroniza\u00E7\u00E3o\n -- acontece na segunda chamada, no fim desta fun\u00E7\u00E3o, quando as duas linhas j\u00E1\n -- est\u00E3o no lugar. \u00C9 por isso que s\u00E3o duas chamadas e n\u00E3o uma.\n IF v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN\n PERFORM migration._fin_installment_from_bill(\n p_tenant, p_id, v_invoice, v_unit,\n (p_row ->> 'installment_number')::integer, v_due, v_amount);\n END IF;\n\n SELECT EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE id = p_id) INTO v_exists;\n IF NOT v_exists THEN\n INSERT INTO public.plg_financial_movements\n (id, tenant_id, unit_id, financial_invoice_id, financial_invoice_installment_id,\n direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, notes, metadata, debit_account_id, credit_account_id)\n VALUES (p_id, p_tenant, v_unit, v_invoice,\n CASE WHEN v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN p_id END,\n v_dir, v_kind, v_amount,\n round(coalesce((p_row ->> 'paid_amount')::numeric, 0), 2),\n v_status, v_due, (p_row ->> 'paid_at')::date,\n (p_row ->> 'installment_number')::integer, p_row ->> 'notes', v_meta, v_debit, v_credit);\n ELSE\n UPDATE public.plg_financial_movements SET\n unit_id = coalesce(unit_id, v_unit),\n debit_account_id = coalesce(debit_account_id, v_debit),\n credit_account_id = coalesce(credit_account_id, v_credit),\n financial_invoice_installment_id = coalesce(\n financial_invoice_installment_id,\n CASE WHEN v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN p_id END)\n WHERE id = p_id;\n END IF;\n\n -- DEPOIS do movimento existir: agora sim o cancelamento e a sincronia do\n -- espelho, que procura o movimento por `financial_invoice_installment_id`.\n -- A parcela em si j\u00E1 foi criada l\u00E1 em cima \u2014 esta chamada completa o que\n -- depende do movimento.\n IF v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN\n PERFORM migration._fin_installment_from_bill(\n p_tenant, p_id, v_invoice, v_unit,\n (p_row ->> 'installment_number')::integer, v_due, v_amount,\n v_status = 'cancelled');\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_movements', p_id, jsonb_build_object('reason', CASE WHEN v_exists THEN 'updated' ELSE 'created' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_exists THEN 'already in the ledger (append-only; legs, unit, installment and cancellation completed)' ELSE 'created' END);\nEND $function$\n\n;\n";
72
+ export declare const MIGRATION_071_A_PARCELA_TEM_UMA_ASSINATURA_SO = "-- A parcela tem uma assinatura s\u00F3.\n--\n-- A 055 passou a chamar `migration._fin_installment_from_bill` com SETE\n-- argumentos antes de o movimento existir (para a FK ter para onde apontar) e\n-- com OITO depois (para o cancelamento e a sincronia do espelho). A inten\u00E7\u00E3o\n-- estava certa; a resolu\u00E7\u00E3o de sobrecarga do Postgres, n\u00E3o:\n--\n-- 42725: function migration._fin_installment_from_bill(uuid, uuid, uuid,\n-- uuid, integer, date, numeric) is not unique\n--\n-- Existiam duas vers\u00F5es da fun\u00E7\u00E3o \u2014 uma de sete argumentos e uma de oito com\n-- `p_cancelled boolean DEFAULT false`. Com o DEFAULT, uma chamada de sete\n-- argumentos casa com as DUAS, e o Postgres recusa em vez de escolher. Est\u00E1\n-- certo em recusar: adivinhar qual delas o autor queria seria pior.\n--\n-- Medido em 20/09/2026, tenant Espa\u00E7o Facial: **57.220 contas** recusadas por\n-- esta ambiguidade, numa \u00FAnica passada.\n--\n-- \u2500\u2500 O conserto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Sai a vers\u00E3o de sete argumentos. Fica UMA assinatura, com `p_sync` expl\u00EDcito:\n--\n-- p_sync = false cria a parcela e N\u00C3O sincroniza \u2014 \u00E9 a chamada de antes\n-- do movimento, quando o espelho ainda n\u00E3o teria o que\n-- procurar.\n-- p_sync = true completa o cancelamento e sincroniza \u2014 \u00E9 a de depois.\n--\n-- Sem DEFAULT no par\u00E2metro novo, de prop\u00F3sito: foi um DEFAULT que criou a\n-- ambiguidade, e uma fun\u00E7\u00E3o que decide coisas diferentes conforme o n\u00FAmero de\n-- argumentos merece que cada chamada diga qual delas quer.\n--\n-- Idempotente.\n\nDROP FUNCTION IF EXISTS migration._fin_installment_from_bill(uuid, uuid, uuid, uuid, integer, date, numeric);\nDROP FUNCTION IF EXISTS migration._fin_installment_from_bill(uuid, uuid, uuid, uuid, integer, date, numeric, boolean);\n\n-- `OR REPLACE`, e n\u00E3o s\u00F3 `CREATE`: as duas assinaturas velhas caem acima, mas\n-- a nova sobrevive \u00E0 pr\u00F3pria migra\u00E7\u00E3o \u2014 e o encadeamento replica tudo uma\n-- segunda vez para provar que replicar \u00E9 seguro. Sem isso a segunda passada\n-- morre com \"function already exists with same argument types\", que \u00E9 o mesmo\n-- que dizer \"esta migra\u00E7\u00E3o n\u00E3o pode ser reaplicada\".\nCREATE OR REPLACE FUNCTION migration._fin_installment_from_bill(\n p_tenant uuid, p_id uuid, p_invoice uuid, p_unit uuid,\n p_number integer, p_due date, p_amount numeric,\n p_cancelled boolean, p_sync boolean\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_number integer := greatest(coalesce(p_number, 1), 1);\n -- `now()` seria a hora desta migration, n\u00E3o a do cancelamento. O V1 n\u00E3o\n -- guarda quando a conta foi cancelada \u2014 guarda s\u00F3 que foi \u2014, ent\u00E3o o carimbo\n -- fica na data de VENCIMENTO: \u00E9 a data que a linha j\u00E1 tem e a \u00FAnica que n\u00E3o\n -- inventa um fato novo. Quem precisar do instante exato vai ao V1.\n v_cancel timestamptz := CASE WHEN coalesce(p_cancelled, false)\n THEN coalesce(p_due, current_date)::timestamptz END;\nBEGIN\n IF p_invoice IS NULL THEN RETURN; END IF;\n\n -- A parcela gen\u00E9rica do t\u00EDtulo sai para dar lugar \u00E0 do V1 \u2014 mas s\u00F3 a que n\u00E3o\n -- veio da migra\u00E7\u00E3o E que ningu\u00E9m referencia. Uma parcela com baixa fica onde\n -- est\u00E1: apag\u00E1-la levaria a baixa junto (ON DELETE CASCADE) e o dinheiro\n -- sumiria sem ningu\u00E9m pedir.\n DELETE FROM public.plg_financial_invoice_installments i\n WHERE i.invoice_id = p_invoice\n AND i.id <> p_id\n AND NOT (i.metadata ? 'migration')\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_allocations a WHERE a.installment_id = i.id)\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_movements m\n WHERE m.financial_invoice_installment_id = i.id);\n\n BEGIN\n INSERT INTO public.plg_financial_invoice_installments\n (id, tenant_id, unit_id, invoice_id, number, due_date, amount, cancelled_at, metadata)\n VALUES (p_id, p_tenant, p_unit, p_invoice, v_number, p_due,\n greatest(coalesce(p_amount, 0), 0), v_cancel,\n jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements')))\n ON CONFLICT (id) DO UPDATE SET\n number = EXCLUDED.number,\n due_date = EXCLUDED.due_date,\n amount = EXCLUDED.amount,\n -- O cancelamento \u00E9 da ORIGEM e ela manda nos dois sentidos: uma conta que\n -- voltou a valer no V1 volta a valer aqui.\n cancelled_at = EXCLUDED.cancelled_at,\n unit_id = coalesce(public.plg_financial_invoice_installments.unit_id, EXCLUDED.unit_id),\n metadata = public.plg_financial_invoice_installments.metadata || EXCLUDED.metadata;\n EXCEPTION\n -- `UNIQUE (invoice_id, number)`: duas contas do V1 com o mesmo n\u00FAmero de\n -- parcela no mesmo t\u00EDtulo existem \u2014 a fatura #1869 tem duas com vencimento\n -- 19/08. Recusar a segunda perderia dinheiro; ela entra no pr\u00F3ximo n\u00FAmero\n -- livre, que \u00E9 o que a tela precisa para somar certo.\n WHEN unique_violation THEN\n INSERT INTO public.plg_financial_invoice_installments\n (id, tenant_id, unit_id, invoice_id, number, due_date, amount, cancelled_at, metadata)\n SELECT p_id, p_tenant, p_unit, p_invoice, coalesce(max(i.number), 0) + 1, p_due,\n greatest(coalesce(p_amount, 0), 0), v_cancel,\n jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements', 'renumbered', true))\n FROM public.plg_financial_invoice_installments i\n WHERE i.invoice_id = p_invoice\n ON CONFLICT (id) DO NOTHING;\n END;\n\n -- O espelho acompanha agora, e n\u00E3o na pr\u00F3xima baixa. Uma conta cancelada sem\n -- baixa nenhuma nunca receberia a visita do gatilho \u2014 e \u00E9 justamente ela que\n -- n\u00E3o pode ficar contando como viva.\n --\n -- Mas ele s\u00F3 pode ser chamado quando o MOVIMENTO j\u00E1 existe: \u00E9 por\n -- `financial_invoice_installment_id` que ele o encontra. Da\u00ED o par\u00E2metro.\n IF p_sync THEN\n PERFORM public.plg_financial_sync_mirror(p_id);\n END IF;\nEND $function$;\n\nCOMMENT ON FUNCTION migration._fin_installment_from_bill(uuid, uuid, uuid, uuid, integer, date, numeric, boolean, boolean) IS\n 'A conta do V1 virando parcela do V2, com o mesmo id do movimento. `p_sync` '\n 'separa as duas metades da opera\u00E7\u00E3o: a parcela precisa existir ANTES do '\n 'movimento (a FK n\u00E3o \u00E9 adi\u00E1vel) e o espelho precisa do movimento para '\n 'sincronizar. Uma assinatura s\u00F3, sem DEFAULT \u2014 foi um DEFAULT que tornou a '\n 'chamada de sete argumentos amb\u00EDgua e recusou 57.220 contas.';\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- E o writer chama a assinatura nova nas duas pontas\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_movement(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'movement_kind', ''))), '');\n v_kind text := CASE v_kind_raw\n WHEN 'bill' THEN 'bill' WHEN 'payment' THEN 'payment'\n WHEN 'transfer' THEN 'transfer' WHEN 'commission' THEN 'commission'\n WHEN 'conta' THEN 'bill' WHEN 'titulo' THEN 'bill' WHEN 't\u00EDtulo' THEN 'bill'\n WHEN 'pagamento' THEN 'payment' WHEN 'recebimento' THEN 'payment'\n WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\u00EAncia' THEN 'transfer'\n WHEN 'comissao' THEN 'commission' WHEN 'comiss\u00E3o' THEN 'commission'\n END;\n v_status_raw text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text := CASE v_status_raw\n WHEN 'canceled' THEN 'cancelled'\n WHEN 'cancelado' THEN 'cancelled'\n WHEN 'cancelada' THEN 'cancelled'\n WHEN 'pago' THEN 'paid'\n WHEN 'paga' THEN 'paid'\n WHEN 'pendente' THEN 'pending'\n WHEN 'vencido' THEN 'overdue'\n WHEN 'vencida' THEN 'overdue'\n WHEN 'atrasado' THEN 'overdue'\n WHEN 'parcial' THEN 'partial'\n ELSE coalesce(v_status_raw, 'pending')\n END;\n v_dir text := nullif(btrim(lower(coalesce(p_row ->> 'direction', ''))), '');\n v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);\n v_invoice uuid := (p_row ->> 'financial_invoice_id')::uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_due date;\n v_legs record;\n v_debit uuid := (p_row ->> 'debit_account_id')::uuid;\n v_credit uuid := (p_row ->> 'credit_account_id')::uuid;\n v_exists boolean;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a movement needs an id');\n END IF;\n IF v_kind IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('unknown movement kind %L (expected bill|payment|transfer|commission, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));\n END IF;\n IF v_dir IS NOT NULL AND v_dir NOT IN ('debit', 'credit') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown direction %L (expected debit|credit)', v_dir));\n END IF;\n IF v_dir IS NULL AND v_invoice IS NOT NULL THEN\n SELECT CASE t.direction WHEN 'receivable' THEN 'credit' WHEN 'payable' THEN 'debit' END INTO v_dir\n FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n IF v_dir IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'the movement carries no direction and settles no invoice \u2014 which way the money went cannot be derived, and must not be defaulted');\n END IF;\n IF v_amount < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a money event cannot carry a negative amount \u2014 direction is the direction, not the sign');\n END IF;\n\n IF v_unit IS NULL AND v_invoice IS NOT NULL THEN\n SELECT t.unit_id INTO v_unit FROM public.plg_financial_invoices t WHERE t.id = v_invoice;\n END IF;\n\n v_due := coalesce((p_row ->> 'due_date')::date, (p_row ->> 'paid_at')::date, current_date);\n\n IF v_debit IS NULL OR v_credit IS NULL THEN\n SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(\n p_tenant, v_kind, v_dir, (p_row ->> 'bank_account_id')::uuid, (p_row ->> 'cash_session_id')::uuid,\n v_invoice, v_meta);\n IF v_legs.o_mirror THEN\n v_meta := v_meta || jsonb_build_object('legacy_mirror', true);\n ELSE\n v_debit := coalesce(v_debit, v_legs.o_debit);\n v_credit := coalesce(v_credit, v_legs.o_credit);\n END IF;\n END IF;\n\n -- \u2500\u2500 A parcela nasce ANTES do movimento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 movimento de conta aponta para a parcela dele\n -- (`financial_invoice_installment_id = p_id`, porque no V1 a conta \u00C9 a\n -- parcela), e `plg_financial_movements_installment_fk` N\u00C3O \u00E9 adi\u00E1vel. Criar a\n -- parcela depois do INSERT \u00E9 apontar para uma linha que ainda n\u00E3o existe:\n --\n -- 23503: insert or update on table \"plg_financial_movements\" violates\n -- foreign key constraint \"plg_financial_movements_installment_fk\"\n --\n -- Medido em 20/09/2026 no tenant Espa\u00E7o Facial: 14.928 contas recusadas por\n -- isso numa s\u00F3 rodada \u2014 e como a conta n\u00E3o entrava, a baixa dela tamb\u00E9m n\u00E3o,\n -- e Contas a Receber ficava vazia.\n --\n -- Aqui a parcela \u00E9 criada SEM sincronizar o espelho: o sincronizador procura\n -- o movimento pela parcela, e o movimento ainda n\u00E3o existe. A sincroniza\u00E7\u00E3o\n -- acontece na segunda chamada, no fim desta fun\u00E7\u00E3o, quando as duas linhas j\u00E1\n -- est\u00E3o no lugar. \u00C9 por isso que s\u00E3o duas chamadas e n\u00E3o uma.\n IF v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN\n PERFORM migration._fin_installment_from_bill(\n p_tenant, p_id, v_invoice, v_unit,\n (p_row ->> 'installment_number')::integer, v_due, v_amount,\n v_status = 'cancelled', false);\n END IF;\n\n SELECT EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE id = p_id) INTO v_exists;\n IF NOT v_exists THEN\n INSERT INTO public.plg_financial_movements\n (id, tenant_id, unit_id, financial_invoice_id, financial_invoice_installment_id,\n direction, movement_kind, amount, paid_amount, status,\n due_date, payment_date, installment_number, notes, metadata, debit_account_id, credit_account_id)\n VALUES (p_id, p_tenant, v_unit, v_invoice,\n CASE WHEN v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN p_id END,\n v_dir, v_kind, v_amount,\n round(coalesce((p_row ->> 'paid_amount')::numeric, 0), 2),\n v_status, v_due, (p_row ->> 'paid_at')::date,\n (p_row ->> 'installment_number')::integer, p_row ->> 'notes', v_meta, v_debit, v_credit);\n ELSE\n UPDATE public.plg_financial_movements SET\n unit_id = coalesce(unit_id, v_unit),\n debit_account_id = coalesce(debit_account_id, v_debit),\n credit_account_id = coalesce(credit_account_id, v_credit),\n financial_invoice_installment_id = coalesce(\n financial_invoice_installment_id,\n CASE WHEN v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN p_id END)\n WHERE id = p_id;\n END IF;\n\n -- DEPOIS do movimento existir: agora sim o cancelamento e a sincronia do\n -- espelho, que procura o movimento por `financial_invoice_installment_id`.\n -- A parcela em si j\u00E1 foi criada l\u00E1 em cima \u2014 esta chamada completa o que\n -- depende do movimento.\n IF v_kind IN ('bill', 'commission') AND v_invoice IS NOT NULL THEN\n PERFORM migration._fin_installment_from_bill(\n p_tenant, p_id, v_invoice, v_unit,\n (p_row ->> 'installment_number')::integer, v_due, v_amount,\n v_status = 'cancelled', true);\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_movements', p_id, jsonb_build_object('reason', CASE WHEN v_exists THEN 'updated' ELSE 'created' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_exists THEN 'already in the ledger (append-only; legs, unit, installment and cancellation completed)' ELSE 'created' END);\nEND $function$\n\n;\n\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Auxiliar de PORTE: SECURITY DEFINER, escreve parcela de qualquer inquilino.\n-- S\u00F3 o service_role, que \u00E9 quem roda a onda. (A assinatura antiga foi\n-- derrubada acima; esta concess\u00E3o \u00E9 da nova, e \u00E9 por isso que ela vem aqui e\n-- n\u00E3o junto da cria\u00E7\u00E3o.)\nREVOKE ALL ON FUNCTION migration._fin_installment_from_bill(uuid, uuid, uuid, uuid, integer, date, numeric, boolean, boolean) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration._fin_installment_from_bill(uuid, uuid, uuid, uuid, integer, date, numeric, boolean, boolean) TO service_role;\n";
73
+ export declare const MIGRATION_072_O_CAIXA_DO_V1_GUARDA_QUEM_O_ABRIU = "-- O caixa do V1 guarda quem o abriu.\n--\n-- `cash_register_sessions` estava em `migration.excluded_tables`, e o motivo\n-- registrado era bom:\n--\n-- a V2 cash session is ATTRIBUTED \u2014 user_id is NOT NULL and only one may\n-- be open per user and unit, because the session is the accountability\n-- record for a drawer. [\u2026] every migrated session would have to be\n-- attributed to somebody who did not open it.\n--\n-- A obje\u00E7\u00E3o est\u00E1 certa e continua valendo. O que mudou \u00E9 que ela partia de uma\n-- premissa falsa: a de que `user_id` teria que apontar para um login do V2.\n--\n-- \u2500\u2500 A medi\u00E7\u00E3o que desfez a premissa \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `public.plg_financial_cash_register_sessions.user_id` \u00E9 `NOT NULL` e **n\u00E3o\n-- tem chave estrangeira**. Conferido em 20/09/2026: zero constraints de FK\n-- sobre essa coluna. Ela exige uma identidade, n\u00E3o exige que seja do V2.\n--\n-- Ent\u00E3o a sess\u00E3o migrada carrega o uuid do PR\u00D3PRIO abridor no V1\n-- (`opened_by_user_id`). Ningu\u00E9m do V2 recebe a gaveta de ningu\u00E9m \u2014 que era\n-- exatamente o que a decis\u00E3o anterior queria evitar. E a restri\u00E7\u00E3o de \"um\n-- caixa aberto por pessoa e unidade\" passa a valer sobre a pessoa certa.\n--\n-- Isso tamb\u00E9m \u00E9 o que a tela precisa: ela mostra `opened_by_name`, e o V1\n-- guarda ali o nome ou o e-mail de quem abriu \u2014 12 pessoas distintas em 4\n-- unidades, nenhuma com login no V2 (medido: 0 de 12 casam por e-mail). Sem\n-- este caminho, o hist\u00F3rico de caixa do Espa\u00E7o Facial n\u00E3o teria como existir.\n--\n-- \u2500\u2500 O recorte \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 65 sess\u00F5es, 64 fechadas e 1 aberta, de 10/07 a 19/09 de 2026. Todas com\n-- unidade e com conta. O vocabul\u00E1rio de status do V1 \u00E9 `open`/`closed` \u2014 o\n-- mesmo do CHECK do V2, sem tradu\u00E7\u00E3o.\n--\n-- A confer\u00EAncia (`review_status`, `reviewed_by_*`, `review_notes`) vai para\n-- `metadata`, e n\u00E3o para `plg_financial_cash_session_reviewers`: a tabela de\n-- mesmo nome no V1 \u00E9 outra coisa \u2014 \u00E9 a lista de QUEM PODE conferir, por\n-- unidade, sem v\u00EDnculo com sess\u00E3o nenhuma. Chamar as duas de \"reviewers\" foi o\n-- que fez a exclus\u00E3o anterior trat\u00E1-las como par.\n--\n-- Idempotente.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. O vocabul\u00E1rio fechado aprende a palavra\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Acr\u00E9scimo, e n\u00E3o lista literal. A restri\u00E7\u00E3o \u00E9 um vocabul\u00E1rio FECHADO: quem a\n-- reescreve por extenso APAGA toda palavra acrescentada por um passo posterior\n-- \u2014 e o encadeamento replica a corrente inteira uma segunda vez, quando `crm`\n-- j\u00E1 registrou `crm_deal`, `quote` e `quote_item`. Na replicagem a lista\n-- literal daqui os apagava e as linhas que j\u00E1 existiam com eles passavam a\n-- violar a restri\u00E7\u00E3o. \u00C9 a mesma corre\u00E7\u00E3o que `agenda/008` recebeu, pelo mesmo\n-- motivo, e o padr\u00E3o vem de `inventory/004`.\nDO $$\nDECLARE v_def text;\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n IF v_def IS NULL OR strpos(v_def, 'fin_cash_session') > 0 THEN RETURN; END IF;\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE replace(\n 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,\n '''person''::text',\n '''person''::text, ''fin_cash_session''::text');\nEND $$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 2. O writer\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 OR REPLACE FUNCTION migration.upsert_fin_cash_session(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_status text := coalesce(p_row ->> 'status', 'closed');\n v_opened timestamptz;\n v_user uuid;\n v_unit uuid;\n v_conta uuid;\n v_existing uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a cash session needs an id');\n END IF;\n\n v_opened := (p_row ->> 'opened_at')::timestamptz;\n IF v_opened IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a cash session needs opened_at');\n END IF;\n\n IF v_status NOT IN ('open', 'closed') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('cash session status %L is not open|closed', v_status));\n END IF;\n\n -- Quem abriu, do V1. `user_id` \u00E9 NOT NULL e n\u00E3o tem FK: ele exige uma\n -- identidade, n\u00E3o um login do V2. Sem o abridor n\u00E3o h\u00E1 registro de\n -- responsabilidade, e uma gaveta sem dono n\u00E3o \u00E9 um caixa.\n BEGIN v_user := (p_row ->> 'opened_by_user_id')::uuid; EXCEPTION WHEN OTHERS THEN v_user := NULL; END;\n IF v_user IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', 'a cash session needs the person who opened it; the V2 record is an accountability record');\n END IF;\n\n BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;\n BEGIN v_conta := (p_row ->> 'account_id')::uuid; EXCEPTION WHEN OTHERS THEN v_conta := NULL; END;\n\n SELECT tenant_id INTO v_existing FROM public.plg_financial_cash_register_sessions WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n format('cash session %s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing IS NULL THEN\n INSERT INTO public.plg_financial_cash_register_sessions\n (id, tenant_id, unit_id, account_id, user_id, status,\n opened_at, opened_by_user_id, opened_by_name, opening_balance,\n closed_at, closed_by_user_id, closed_by_name, closing_balance,\n expected_balance, difference, expected_cash, counted_cash, difference_cash,\n expected_card, counted_card, difference_card, notes, metadata, created_at, updated_at)\n VALUES\n (p_id, p_tenant, v_unit, v_conta, v_user, v_status,\n v_opened, v_user, p_row ->> 'opened_by_name',\n coalesce(nullif(p_row ->> 'opening_balance','')::numeric, 0),\n nullif(p_row ->> 'closed_at','')::timestamptz,\n nullif(p_row ->> 'closed_by_user_id','')::uuid, p_row ->> 'closed_by_name',\n nullif(p_row ->> 'closing_balance','')::numeric,\n nullif(p_row ->> 'expected_balance','')::numeric,\n nullif(p_row ->> 'difference','')::numeric,\n nullif(p_row ->> 'expected_cash','')::numeric,\n nullif(p_row ->> 'counted_cash','')::numeric,\n nullif(p_row ->> 'difference_cash','')::numeric,\n nullif(p_row ->> 'expected_card','')::numeric,\n nullif(p_row ->> 'counted_card','')::numeric,\n nullif(p_row ->> 'difference_card','')::numeric,\n p_row ->> 'notes', v_meta,\n coalesce(nullif(p_row ->> 'created_at','')::timestamptz, v_opened),\n coalesce(nullif(p_row ->> 'updated_at','')::timestamptz, now()));\n v_created := true;\n ELSE\n UPDATE public.plg_financial_cash_register_sessions SET\n unit_id = coalesce(v_unit, unit_id),\n account_id = coalesce(v_conta, account_id),\n status = v_status,\n closed_at = nullif(p_row ->> 'closed_at','')::timestamptz,\n closed_by_name = p_row ->> 'closed_by_name',\n closing_balance = nullif(p_row ->> 'closing_balance','')::numeric,\n expected_balance = nullif(p_row ->> 'expected_balance','')::numeric,\n difference = nullif(p_row ->> 'difference','')::numeric,\n counted_cash = nullif(p_row ->> 'counted_cash','')::numeric,\n counted_card = nullif(p_row ->> 'counted_card','')::numeric,\n difference_cash = nullif(p_row ->> 'difference_cash','')::numeric,\n difference_card = nullif(p_row ->> 'difference_card','')::numeric,\n notes = p_row ->> 'notes',\n metadata = migration._merge_meta(metadata, v_meta),\n updated_at = coalesce(nullif(p_row ->> 'updated_at','')::timestamptz, now())\n WHERE id = p_id;\n END IF;\n\n PERFORM migration._set_unit('public.plg_financial_cash_register_sessions', p_id, v_unit);\n\n IF v_standalone THEN\n PERFORM migration._audit(p_tenant, 'public.plg_financial_cash_register_sessions', p_id,\n jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 3. O de-para, e a readmiss\u00E3o por tenant\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nSET LOCAL migration.allowlist_unlock = 'on';\n\nSELECT migration._allow(\n 'cash_register_sessions', 'public.plg_financial_cash_register_sessions',\n 'financial', 'fin_cash_session', '{}'::jsonb, NULL::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'status', 'status',\n 'opened_at', 'opened_at',\n 'opened_by_user_id', 'opened_by_user_id',\n 'opened_by_name', 'opened_by_name',\n 'opening_balance', 'opening_balance',\n 'closed_at', 'closed_at',\n 'closed_by_user_id', 'closed_by_user_id',\n 'closed_by_name', 'closed_by_name',\n 'closing_balance', 'closing_balance',\n 'expected_balance', 'expected_balance',\n 'difference', 'difference',\n 'expected_cash', 'expected_cash',\n 'counted_cash', 'counted_cash',\n 'difference_cash', 'difference_cash',\n 'expected_card', 'expected_card',\n 'counted_card', 'counted_card',\n 'difference_card', 'difference_card',\n 'notes', 'notes',\n 'created_at', 'created_at',\n 'updated_at', 'updated_at',\n 'unit_id', 'unit_id@companies',\n 'account_central_id', 'account_id@account_central',\n 'tenant_id', '-',\n 'review_status', 'jsonb:review.status',\n 'reviewed_by_user_id', 'jsonb:review.by_user',\n 'reviewed_by_name', 'jsonb:review.by_name',\n 'reviewed_at', 'jsonb:review.at',\n 'review_notes', 'jsonb:review.notes'),\n 'O caixa do V1. `user_id` recebe o uuid de quem ABRIU no V1 \u2014 a coluna \u00E9 '\n 'NOT NULL e n\u00E3o tem FK, ent\u00E3o ela exige identidade, n\u00E3o um login do V2. '\n 'Assim ningu\u00E9m do V2 herda a gaveta de ningu\u00E9m, que era a obje\u00E7\u00E3o que '\n 'mantinha esta tabela fora. A confer\u00EAncia vai para metadata.review.'\n);\n\nRESET migration.allowlist_unlock;\n\n-- A tabela de \"reviewers\" do V1 \u00E9 a lista de QUEM PODE conferir, por unidade \u2014\n-- n\u00E3o tem session_id e n\u00E3o \u00E9 o par de `plg_financial_cash_session_reviewers`,\n-- que registra UMA confer\u00EAncia de UMA sess\u00E3o. O motivo antigo tratava as duas\n-- como par; este corrige o registro.\nUPDATE migration.excluded_tables\n SET reason = 'Hom\u00F4nima, n\u00E3o equivalente: `cash_session_reviewers` do V1 \u00E9 a '\n 'lista de quem PODE conferir caixa (unit_id + user_id + active), '\n 'sem v\u00EDnculo com sess\u00E3o. O destino de mesmo nome registra UMA '\n 'confer\u00EAncia de UMA sess\u00E3o. A confer\u00EAncia que o V1 de fato tem '\n 'mora na pr\u00F3pria sess\u00E3o e atravessa em metadata.review (057).'\n WHERE source_table = 'cash_session_reviewers';\n\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- A decis\u00E3o antiga sai quando a nova entra\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 041 registrou `cash_register_sessions` em `excluded_tables` com a raz\u00E3o da\n-- \u00E9poca: \"portar resolveria a quarentena e criaria dois problemas maiores\".\n-- Este arquivo \u00E9 a decis\u00E3o contr\u00E1ria \u2014 a sess\u00E3o de caixa passa a atravessar \u2014,\n-- e as duas n\u00E3o podem coexistir. A bancada diz isso em uma linha:\n--\n-- not ok 38 - no table is both allowlisted and excluded have: 1\n--\n-- E ela tem raz\u00E3o sobre o que \u00E9 pior: uma tabela nos dois lugares n\u00E3o \u00E9 uma\n-- contradi\u00E7\u00E3o inofensiva, \u00E9 o painel de cobertura mostrando \"decidido fora\"\n-- para algo que est\u00E1 atravessando. Some a linha antiga; a raz\u00E3o dela fica no\n-- cabe\u00E7alho da 041, que \u00E9 onde hist\u00F3ria mora.\nDELETE FROM migration.excluded_tables\n WHERE source_table = 'cash_register_sessions'\n AND tenant_id IS NULL;\n\n-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Fun\u00E7\u00E3o nova nasce execut\u00E1vel por PUBLIC, e `anon`/`authenticated` herdam\n-- dali. Estas s\u00E3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer\n-- inquilino, e uma delas alcan\u00E7\u00E1vel pela chave an\u00F4nima \u00E9 a base inteira\n-- alcan\u00E7\u00E1vel pela chave an\u00F4nima. S\u00F3 o service_role, que \u00E9 quem roda a onda.\nREVOKE ALL ON FUNCTION migration.upsert_fin_cash_session(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION migration.upsert_fin_cash_session(uuid, uuid, jsonb, jsonb) TO service_role;\n";
74
+ export declare const MIGRATION_073_O_CAIXA_PERTENCE_A_CONTA_QUE_O_V1_LHE_DEU = "-- O caixa pertence \u00E0 conta que o V1 lhe deu.\n--\n-- A 057 mapeou `account_central_id` para `account_id` e parou a\u00ED. O destino\n-- tem DUAS colunas de conta, e a segunda tamb\u00E9m \u00E9 obrigat\u00F3ria:\n--\n-- bank_account_id uuid NOT NULL -- constraint `\u2026_cofre_fk`\n-- account_id uuid -- anul\u00E1vel\n--\n-- 23502: null value in column \"bank_account_id\" \u2026 violates not-null\n--\n-- 65 de 65 sess\u00F5es recusadas por isso.\n--\n-- \u2500\u2500 Qual conta vai em qual coluna \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- No V2 as duas existem porque o caixa tem dois lados: a conta em que o\n-- dinheiro do dia \u00E9 contado (`account_id`) e o COFRE para onde ele vai\n-- (`bank_account_id`). O V1 n\u00E3o faz essa distin\u00E7\u00E3o \u2014 ele tem uma refer\u00EAncia\n-- s\u00F3, `account_central_id`.\n--\n-- Ent\u00E3o as duas recebem a mesma conta. N\u00E3o \u00E9 pregui\u00E7a: \u00E9 o que a origem tem.\n-- Inventar uma segunda conta faria o V2 afirmar um trajeto de dinheiro que\n-- ningu\u00E9m registrou, e essa \u00E9 uma mentira cara num sistema de caixa. Quando o\n-- Espa\u00E7o Facial passar a usar cofre no V2, ele separa as duas l\u00E1 \u2014 e a\u00ED a\n-- informa\u00E7\u00E3o \u00E9 dele, n\u00E3o uma inven\u00E7\u00E3o do porte.\n--\n-- A sess\u00E3o sem conta nenhuma \u00E9 recusada, e n\u00E3o completada por um palpite: um\n-- caixa que n\u00E3o sabe de que conta \u00E9 n\u00E3o \u00E9 um caixa.\n--\n-- Idempotente.\n\nCREATE OR REPLACE FUNCTION migration.upsert_fin_cash_session(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_status text := coalesce(p_row ->> 'status', 'closed');\n v_opened timestamptz;\n v_user uuid;\n v_unit uuid;\n v_conta uuid;\n v_existing uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a cash session needs an id');\n END IF;\n\n v_opened := (p_row ->> 'opened_at')::timestamptz;\n IF v_opened IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a cash session needs opened_at');\n END IF;\n\n IF v_status NOT IN ('open', 'closed') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('cash session status %L is not open|closed', v_status));\n END IF;\n\n -- Quem abriu, do V1. `user_id` \u00E9 NOT NULL e n\u00E3o tem FK: ele exige uma\n -- identidade, n\u00E3o um login do V2. Sem o abridor n\u00E3o h\u00E1 registro de\n -- responsabilidade, e uma gaveta sem dono n\u00E3o \u00E9 um caixa.\n BEGIN v_user := (p_row ->> 'opened_by_user_id')::uuid; EXCEPTION WHEN OTHERS THEN v_user := NULL; END;\n IF v_user IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', 'a cash session needs the person who opened it; the V2 record is an accountability record');\n END IF;\n\n BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;\n BEGIN v_conta := (p_row ->> 'account_id')::uuid; EXCEPTION WHEN OTHERS THEN v_conta := NULL; END;\n\n -- `bank_account_id` \u00E9 NOT NULL. O V1 tem uma refer\u00EAncia de conta s\u00F3, ent\u00E3o\n -- ela serve \u00E0s duas \u2014 ver o cabe\u00E7alho. Sem conta nenhuma, recusa.\n IF v_conta IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', 'a cash session needs the account it belongs to; the destination requires it for the drawer and for the vault');\n END IF;\n\n SELECT tenant_id INTO v_existing FROM public.plg_financial_cash_register_sessions WHERE id = p_id;\n IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',\n 'reason', format('cash session %s already exists in another tenant', p_id));\n END IF;\n\n IF v_existing IS NULL THEN\n INSERT INTO public.plg_financial_cash_register_sessions\n (id, tenant_id, unit_id, account_id, bank_account_id, user_id, status,\n opened_at, opened_by_user_id, opened_by_name, opening_balance,\n closed_at, closed_by_user_id, closed_by_name, closing_balance,\n expected_balance, difference, expected_cash, counted_cash, difference_cash,\n expected_card, counted_card, difference_card, notes, metadata, created_at, updated_at)\n VALUES\n (p_id, p_tenant, v_unit, v_conta, v_conta, v_user, v_status,\n v_opened, v_user, p_row ->> 'opened_by_name',\n coalesce(nullif(p_row ->> 'opening_balance','')::numeric, 0),\n nullif(p_row ->> 'closed_at','')::timestamptz,\n nullif(p_row ->> 'closed_by_user_id','')::uuid, p_row ->> 'closed_by_name',\n nullif(p_row ->> 'closing_balance','')::numeric,\n nullif(p_row ->> 'expected_balance','')::numeric,\n nullif(p_row ->> 'difference','')::numeric,\n nullif(p_row ->> 'expected_cash','')::numeric,\n nullif(p_row ->> 'counted_cash','')::numeric,\n nullif(p_row ->> 'difference_cash','')::numeric,\n nullif(p_row ->> 'expected_card','')::numeric,\n nullif(p_row ->> 'counted_card','')::numeric,\n nullif(p_row ->> 'difference_card','')::numeric,\n p_row ->> 'notes', v_meta,\n coalesce(nullif(p_row ->> 'created_at','')::timestamptz, v_opened),\n coalesce(nullif(p_row ->> 'updated_at','')::timestamptz, now()));\n v_created := true;\n ELSE\n UPDATE public.plg_financial_cash_register_sessions SET\n unit_id = coalesce(v_unit, unit_id),\n account_id = coalesce(v_conta, account_id),\n bank_account_id = coalesce(bank_account_id, v_conta),\n status = v_status,\n closed_at = nullif(p_row ->> 'closed_at','')::timestamptz,\n closed_by_name = p_row ->> 'closed_by_name',\n closing_balance = nullif(p_row ->> 'closing_balance','')::numeric,\n expected_balance = nullif(p_row ->> 'expected_balance','')::numeric,\n difference = nullif(p_row ->> 'difference','')::numeric,\n counted_cash = nullif(p_row ->> 'counted_cash','')::numeric,\n counted_card = nullif(p_row ->> 'counted_card','')::numeric,\n difference_cash = nullif(p_row ->> 'difference_cash','')::numeric,\n difference_card = nullif(p_row ->> 'difference_card','')::numeric,\n notes = p_row ->> 'notes',\n metadata = migration._merge_meta(metadata, v_meta),\n updated_at = coalesce(nullif(p_row ->> 'updated_at','')::timestamptz, now())\n WHERE id = p_id;\n END IF;\n\n PERFORM migration._set_unit('public.plg_financial_cash_register_sessions', p_id, v_unit);\n\n IF v_standalone THEN\n PERFORM migration._audit(p_tenant, 'public.plg_financial_cash_register_sessions', p_id,\n jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));\n END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n";
75
+ export declare const MIGRATION_074_O_MOVIMENTO_VOLTA_A_APONTAR_PARA_O_CAIXA = "-- O movimento volta a apontar para o caixa.\n--\n-- A migration 035 mandou `financial_movements.cash_session_id` para\n-- `metadata.v1_cash_session_ref` \u2014 uma refer\u00EAncia opaca. Era a decis\u00E3o certa\n-- naquele dia: o caixa n\u00E3o atravessava, e um movimento apontando para uma\n-- sess\u00E3o inexistente cairia em quarentena de `fk`. Guardar o n\u00FAmero em\n-- metadata liberou 24 movimentos que estavam presos.\n--\n-- Com a 057 o caixa atravessa. A refer\u00EAncia deixa de ser opaca e volta a ser\n-- o que sempre foi: uma liga\u00E7\u00E3o.\n--\n-- \u2500\u2500 O tamanho real disso \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Medido no V1, tenant 1, em 20/09/2026:\n--\n-- movimentos 277.567\n-- com cash_session_id 268 (49 sess\u00F5es distintas)\n--\n-- Menos de um por mil. O V1 n\u00E3o associa a gaveta ao movimento como regra \u2014 ele\n-- o faz quando o dinheiro passou pelo caixa de fato. A tela\n-- `#/financial/cash/registers` n\u00E3o depende desse v\u00EDnculo para listar as\n-- sess\u00F5es; ele \u00E9 o que permite abrir uma sess\u00E3o e ver o que passou por ela.\n--\n-- \u2500\u2500 Por que o writer n\u00E3o precisa mudar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `upsert_fin_movement` j\u00E1 escreve `cash_session_id` \u2014 sempre soube. O que\n-- faltava era o de-para entregar um uuid resolvido em vez de um n\u00FAmero cru em\n-- metadata. A mudan\u00E7a \u00E9 de uma linha do mapa, e o backfill abaixo alcan\u00E7a o\n-- que j\u00E1 entrou com a forma antiga.\n--\n-- Idempotente.\n\nSET LOCAL migration.allowlist_unlock = 'on';\n\nUPDATE migration.allowlist\n SET columns = jsonb_set(columns, '{cash_session_id}',\n '\"cash_session_id@cash_register_sessions\"'::jsonb)\n WHERE source_table = 'financial_movements';\n\nRESET migration.allowlist_unlock;\n\n-- \u2500\u2500 O que j\u00E1 entrou N\u00C3O \u00E9 corrigido, e isso \u00E9 do destino \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `trg_plg_financial_movements_append_only` recusa mudar `cash_session_id` de\n-- um evento de dinheiro que j\u00E1 existe:\n--\n-- plg_financial_movements is append-only: money event \u2026 cannot be edited\n--\n-- A regra est\u00E1 certa e n\u00E3o tem escotilha de migra\u00E7\u00E3o, de prop\u00F3sito: depois de\n-- registrado, de qual gaveta saiu um pagamento n\u00E3o se reescreve. Um backfill\n-- aqui n\u00E3o seria conserto, seria reescrita de caixa.\n--\n-- Ent\u00E3o os movimentos que j\u00E1 atravessaram com a forma antiga mant\u00EAm a gaveta\n-- s\u00F3 em `metadata.v1_cash_session_ref` \u2014 recuper\u00E1vel, e honesto sobre quando\n-- a liga\u00E7\u00E3o foi estabelecida. Os que ainda n\u00E3o entraram nascem ligados.\n--\n-- Movimento de conta (`bill`/`commission`) \u00E9 espelho de obriga\u00E7\u00E3o, n\u00E3o evento\n-- de dinheiro: o gatilho deixa suas colunas derivadas se moverem, e esses se\n-- ligam na reoferta normalmente.\n";
76
+ export declare const MIGRATION_075_A_VENDA_CANCELADA_CANCELA_O_TITULO_DELA = "-- A venda cancelada cancela o t\u00EDtulo dela.\n--\n-- Uma fatura do V1 vira DUAS linhas no V2: a Venda (`public.orders`) e o\n-- t\u00EDtulo (`public.plg_financial_invoices`). O cancelamento chegava s\u00F3 na\n-- primeira.\n--\n-- Medido em 20/09/2026, tenant Espa\u00E7o Facial:\n--\n-- public.orders com status 'cancelled' 12.678\n-- plg_financial_invoices com cancelled_at 161\n--\n-- Doze mil e quinhentas vendas canceladas cujo t\u00EDtulo nasceu vivo. E \u00E9 o\n-- t\u00EDtulo que o DRE soma \u2014 ent\u00E3o **item de venda cancelada estava contando como\n-- receita**. A asser\u00E7\u00E3o de paridade mediu o tamanho:\n--\n-- dre A (V1) 71.952.346,78 B (V2) 73.518.877,84 \u0394 +1.566.531,06\n--\n-- Do outro lado, o V1 tem 1.170 faturas `canceled` em 2026 somando\n-- R$ 1.659.523,42 em itens. Os dois n\u00FAmeros dizem a mesma coisa.\n--\n-- \u2500\u2500 Por que passou \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `upsert_order` monta a chamada do t\u00EDtulo com `'status', 'open'` fixo e mais\n-- nada sobre cancelamento. `upsert_invoice`, por sua vez, n\u00E3o continha a\n-- palavra \"cancel\" em lugar nenhum \u2014 ele nunca soube que um documento podia\n-- nascer cancelado. O dado estava ali o tempo todo, em\n-- `metadata.cancellation.at`, que o de-para grava desde sempre.\n--\n-- \u2500\u2500 A exce\u00E7\u00E3o que o destino j\u00E1 tinha escrito \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `trg_plg_financial_invoices_immutable` recusa alterar fatura emitida, com\n-- uma exce\u00E7\u00E3o declarada no pr\u00F3prio corpo:\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;\n--\n-- Ou seja: carimbar o cancelamento que faltava \u00E9 exatamente o que o destino\n-- permite. N\u00E3o \u00E9 reescrever documento financeiro \u2014 \u00E9 complet\u00E1-lo, e uma vez\n-- s\u00F3. Uma fatura j\u00E1 cancelada fica como est\u00E1.\n--\n-- \u2500\u2500 O que muda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- 1. `upsert_order` desce `cancelled_at`, `cancel_reason` e o status da origem.\n-- 2. `upsert_invoice` grava o carimbo ao criar, e PREENCHE quando a fatura j\u00E1\n-- existe e est\u00E1 sem ele.\n-- 3. O backfill alcan\u00E7a as 12.517 que j\u00E1 entraram. A verdade vem da Venda, que\n-- \u00E9 a mesma linha do V1 e j\u00E1 a carrega.\n--\n-- A data do carimbo \u00E9 a da origem; quando o V1 n\u00E3o guardou a hora, fica na\n-- COMPET\u00CANCIA \u2014 a data que a linha j\u00E1 tem. Mesma regra da 043, pelo mesmo\n-- motivo: `now()` seria a hora desta migration, n\u00E3o a do cancelamento.\n--\n-- Idempotente.\n\nCREATE OR REPLACE FUNCTION migration.upsert_invoice(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_table text := 'public.plg_financial_invoices';\n v_dir text := coalesce(p_args ->> 'direction', p_row ->> 'direction', 'receivable');\n v_total numeric := round(coalesce((p_row ->> 'total')::numeric, -1), 2);\n v_party uuid;\n v_number text;\n v_insts jsonb;\n v_sum numeric := 0;\n v_i jsonb;\n v_n integer := 0;\n v_due date;\n v_exists boolean;\n v_cancel timestamptz;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF to_regclass(v_table) IS NULL THEN\n RETURN jsonb_build_object('status', 'skipped', 'reason', 'destination table absent: ' || v_table);\n END IF;\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n EXECUTE format('SELECT EXISTS (SELECT 1 FROM %s WHERE id = $1)', v_table) INTO v_exists USING p_id;\n -- Quando a origem diz cancelada, a data vem dela; se ela n\u00E3o guardou a hora,\n -- o carimbo fica na COMPET\u00CANCIA \u2014 a data que a linha j\u00E1 tem, e a \u00FAnica que\n -- n\u00E3o inventa um fato novo. Mesma regra da 043 para a parcela.\n v_cancel := coalesce(\n nullif(p_row ->> 'cancelled_at', '')::timestamptz,\n CASE WHEN coalesce(p_row ->> 'source_status', '') = 'cancelled'\n THEN coalesce(nullif(p_row ->> 'competence_date','')::timestamptz, now()) END);\n\n IF v_exists THEN\n -- Documento financeiro n\u00E3o se reescreve \u2014 e n\u00E3o \u00E9 o que acontece aqui. O\n -- gatilho de imutabilidade do destino abre uma exce\u00E7\u00E3o NOMEADA, \"the named\n -- exception: cancelling, and the bookkeeping that goes with it\", e ela\n -- existe para isto. S\u00F3 PREENCHE: uma fatura j\u00E1 cancelada fica como est\u00E1.\n IF v_cancel IS NOT NULL THEN\n UPDATE public.plg_financial_invoices\n SET cancelled_at = v_cancel,\n cancel_reason = coalesce(cancel_reason, nullif(p_row ->> 'cancel_reason', ''))\n WHERE id = p_id AND cancelled_at IS NULL;\n END IF;\n -- `migrated`, e nao `skipped`. A regra de nao reescrever documento\n -- financeiro continua valendo - nada e atualizado aqui. O que muda e o\n -- NOME que se da ao resultado: a linha existe no destino, com este id,\n -- vinda desta linha da origem. Isso e o estado migrado.\n --\n -- Chama-lo de `skipped` tinha uma consequencia que nao era de vocabulario.\n -- `_upsert_row_impl` reexecuta o writer quando alguma IRMA da linha (o\n -- mesmo registro da origem com outro destino) esta `pending` ou `skipped` -\n -- e assim que a fatura de uma Venda nasce numa segunda passada, quando o\n -- writer dela nao existia na primeira. Com o titulo marcado `skipped` para\n -- sempre, essa condicao nunca deixava de ser verdadeira: toda rodada\n -- reexecutava o writer inteiro para chegar de novo aqui.\n --\n -- Medido em 20/09/2026 no tenant Espaco Facial: 7.893 titulos nesse estado,\n -- refeitos a cada rodada antes de o motor sequer chegar nas linhas novas. A\n -- etapa `invoices` passava minutos sem produzir uma linha, e parecia travada.\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', 'already present; financial documents are never rewritten by the migration');\n END IF;\n\n IF v_dir NOT IN ('receivable', 'payable') THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown invoice direction %L', v_dir));\n END IF;\n IF v_total < 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'an invoice needs a total >= 0');\n END IF;\n\n -- the counterparty is a Person. A reference that does not resolve is a broken\n -- link, not a column to drop \u2014 which is what the old candidate-name loop did.\n BEGIN\n v_party := coalesce((p_row ->> 'counterparty_person_id')::uuid, (p_row ->> 'party_id')::uuid);\n EXCEPTION WHEN OTHERS THEN v_party := NULL; END;\n IF v_party IS NOT NULL AND NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = v_party AND p.tenant_id = p_tenant) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('counterparty %s is not a Person of this tenant', v_party));\n END IF;\n\n v_due := coalesce((p_row ->> 'due_date')::date, (p_row ->> 'competence_date')::date, current_date);\n\n -- installments: as offered, or one for the whole total. An invoice with no\n -- installment is a document nobody can pay, and the balance views derive\n -- everything from them.\n v_insts := CASE WHEN jsonb_typeof(p_row -> 'installments') = 'array' AND jsonb_array_length(p_row -> 'installments') > 0\n THEN p_row -> 'installments'\n ELSE jsonb_build_array(jsonb_build_object('number', 1, 'due_date', v_due, 'amount', v_total)) END;\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 RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('installments sum to %s but the invoice total is %s \u2014 which one is wrong is not a migration''s call', v_sum, v_total));\n END IF;\n\n -- same series as an invoice created in V2: two numbering schemes in one tenant\n -- is the kind of thing nobody notices until an auditor does\n BEGIN\n v_number := public.plg_financial_next_invoice_number(p_tenant, v_dir);\n EXCEPTION WHEN OTHERS THEN v_number := p_row ->> 'number'; END;\n\n INSERT INTO public.plg_financial_invoices\n (id, tenant_id, unit_id, direction, counterparty_person_id, order_id, number,\n competence_date, issue_date, total, description, notes, metadata,\n cancelled_at, cancel_reason)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_dir, v_party, (p_row ->> 'order_id')::uuid, v_number,\n coalesce((p_row ->> 'competence_date')::date, v_due), coalesce((p_row ->> 'issue_date')::date, v_due),\n v_total, p_row ->> 'description', p_row ->> 'notes',\n coalesce(p_row -> 'metadata', '{}'::jsonb),\n v_cancel, nullif(p_row ->> 'cancel_reason', ''));\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 (p_tenant, (p_row ->> 'unit_id')::uuid, p_id,\n coalesce((v_i ->> 'number')::int, v_n),\n coalesce((v_i ->> 'due_date')::date, v_due),\n round(coalesce((v_i ->> 'amount')::numeric, 0), 2),\n CASE WHEN jsonb_typeof(v_i -> 'metadata') = 'object' THEN v_i -> 'metadata' ELSE '{}'::jsonb END);\n END LOOP;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, v_table, p_id, jsonb_build_object('reason', 'created', 'installments', v_n)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created', 'installments', v_n, 'number', v_number);\nEND $function$\n\n;\nCREATE OR REPLACE FUNCTION migration.upsert_order(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)\n RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $function$\nDECLARE\n v_map jsonb := coalesce(p_args -> 'status_map', '{}'::jsonb);\n v_kind_map jsonb := coalesce(p_args -> 'kind_map', '{}'::jsonb);\n v_src_status text := p_row ->> 'status';\n v_status text;\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_src_kind text;\n v_kind text;\n v_direction text;\n v_party uuid;\n v_existing_tenant uuid;\n v_created boolean := false;\n v_invoice jsonb;\n v_invoice_id uuid;\n v_extra jsonb := '[]'::jsonb;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an order needs an id');\n END IF;\n\n -- A esp\u00E9cie sai do `type` da origem quando o de-para declarou o mapa; sem\n -- mapa, nada muda em rela\u00E7\u00E3o a antes.\n v_src_kind := v_meta #>> '{financial,v1_type}';\n v_kind := coalesce(v_kind_map ->> coalesce(v_src_kind, ''), p_args ->> 'kind', 'sale');\n IF NOT EXISTS (SELECT 1 FROM public.order_kinds k WHERE k.kind = v_kind) THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('order kind %L is not declared in public.order_kinds', v_kind));\n END IF;\n -- Compra \u00E9 documento de despesa: o t\u00EDtulo dela \u00E9 a pagar. \u00C9 o que a pr\u00F3pria\n -- descri\u00E7\u00E3o de `order_kinds.purchase` diz.\n v_direction := CASE WHEN v_kind = 'purchase' THEN 'payable' ELSE 'receivable' END;\n\n v_status := coalesce(v_map ->> coalesce(v_src_status, ''), v_map ->> '*', v_src_status, 'completed');\n v_meta := migration._deep_set(v_meta, ARRAY['migration', 'source_status'], to_jsonb(v_src_status));\n\n -- A contraparte. `party_id` j\u00E1 vem resolvido quando \u00E9 cliente; quando \u00E9\n -- fornecedor ou profissional, quem resolve \u00E9 o ledger \u2014 as tr\u00EAs tabelas do\n -- V1 viram `public.people`, e o ledger sabe qual virou qual.\n BEGIN\n v_party := (p_row ->> 'party_id')::uuid;\n EXCEPTION WHEN OTHERS THEN v_party := NULL; END;\n IF v_party IS NULL AND (v_meta #>> '{financial,v1_ref_table}') IS NOT NULL THEN\n SELECT l.target_id INTO v_party\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = (v_meta #>> '{financial,v1_ref_table}')\n AND l.source_numeric_id = nullif(v_meta #>> '{financial,v1_ref_id}', '')::bigint\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n END IF;\n\n SELECT tenant_id INTO v_existing_tenant FROM public.orders WHERE id = p_id;\n IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order %s already exists in another tenant', p_id));\n END IF;\n IF v_existing_tenant IS NULL THEN\n -- `unit_id` entra JUNTO, e n\u00E3o num UPDATE logo depois. O UPDATE que vinha\n -- em seguida (via `_set_unit`) criava uma segunda vers\u00E3o da linha, reescrevia\n -- os onze \u00EDndices de `orders` e refazia as checagens de chave estrangeira \u2014\n -- medido em 19/09/2026: 1.371 ms para um UPDATE que n\u00E3o mudava valor nenhum,\n -- quase tudo em revalida\u00E7\u00E3o de FK (tenant 646 ms, party 398 ms, unit 275 ms).\n -- Por linha, e s\u00E3o 166.432 faturas.\n INSERT INTO public.orders (id, tenant_id, unit_id, kind, reference_number, status, party_id, subtotal, discount, tax, total, currency, completed_at, notes, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_kind, p_row ->> 'reference_number', v_status, v_party,\n (p_row ->> 'subtotal')::numeric, coalesce((p_row ->> 'discount')::numeric, 0), coalesce((p_row ->> 'tax')::numeric, 0),\n (p_row ->> 'total')::numeric, coalesce(p_row ->> 'currency', 'BRL'), (p_row ->> 'completed_at')::timestamptz,\n p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n -- financial columns are guarded upstream (upsert_row) and not touched here\n UPDATE public.orders SET\n kind = v_kind,\n reference_number = CASE WHEN p_row ? 'reference_number' THEN p_row ->> 'reference_number' ELSE reference_number END,\n status = CASE WHEN p_row ? 'status' THEN v_status ELSE status END,\n party_id = coalesce(v_party, party_id),\n completed_at = CASE WHEN p_row ? 'completed_at' THEN (p_row ->> 'completed_at')::timestamptz ELSE completed_at END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n PERFORM migration._set_unit('public.orders', p_id, (p_row ->> 'unit_id')::uuid);\n\n IF coalesce(p_args ->> 'split_invoice', 'false') = 'true' THEN\n v_invoice_id := md5('migration:invoice:' || p_id::text)::uuid;\n v_invoice := migration.upsert_invoice(p_tenant, v_invoice_id,\n jsonb_build_object('order_id', p_id, 'party_id', to_jsonb(v_party), 'unit_id', p_row -> 'unit_id', 'total', p_row -> 'total',\n 'competence_date', p_row -> 'completed_at', 'status', 'open',\n -- O cancelamento desce para a fatura. Sem isto a Venda\n -- nasce cancelada e o T\u00CDTULO dela nasce vivo \u2014 e \u00E9 o\n -- t\u00EDtulo que o DRE soma.\n 'cancelled_at', to_jsonb(nullif(v_meta #>> '{cancellation,at}', '')),\n 'cancel_reason', to_jsonb(nullif(v_meta #>> '{cancellation,reason}', '')),\n 'source_status', to_jsonb(v_status),\n -- a fatura leva o texto da origem: sem isto a lista do\n -- financeiro mostra a coluna Descri\u00E7\u00E3o vazia em tudo\n 'description', p_row -> 'notes',\n 'metadata', v_meta),\n jsonb_build_object('direction', v_direction, 'kind', v_direction));\n v_extra := v_extra || jsonb_build_object('target_table', 'public.plg_financial_invoices', 'target_id', v_invoice_id,\n 'status', CASE WHEN v_invoice ->> 'status' = 'migrated' THEN 'migrated' ELSE 'skipped' END,\n 'reason', v_invoice ->> 'reason',\n 'financial', CASE WHEN v_invoice ->> 'status' = 'migrated' THEN jsonb_build_object('total', p_row -> 'total') ELSE NULL END);\n END IF;\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.orders', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END, 'extra', v_extra);\nEND $function$\n\n;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- O backfill: o t\u00EDtulo herda o cancelamento da Venda de onde nasceu\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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\nUPDATE public.plg_financial_invoices fi\n SET cancelled_at = coalesce(\n nullif(o.metadata #>> '{cancellation,at}', '')::timestamptz,\n fi.competence_date::timestamptz),\n cancel_reason = coalesce(fi.cancel_reason, nullif(o.metadata #>> '{cancellation,reason}', ''))\n FROM public.orders o\n WHERE o.id = fi.order_id\n AND o.status = 'cancelled'\n AND fi.cancelled_at IS NULL;\n";
77
+ export declare const MIGRATION_076_O_PLANO_DE_CONTAS_DO_CLIENTE_VENCE_O_DO_MODELO = "-- O plano de contas do cliente vence o do modelo.\n--\n-- O tenant do Espa\u00E7o Facial nasceu no V2 com um plano de contas SEMENTE, do\n-- template da vertical. O plano real do cliente veio depois, do V1 \u2014 e quatro\n-- c\u00F3digos colidiram:\n--\n-- c\u00F3digo semente do V2 o que \u00E9 no V1\n-- 3.01 Receita de servi\u00E7os Insumos Utilizados nos Procedimentos\n-- 3.02 Receita de produtos Comiss\u00E3o dos Profissionais de Sa\u00FAde\n-- 4.01 Custo dos produtos Royalties da Franqueadora\n-- 4.02 Comiss\u00F5es Taxas de Meios de Pagamento\n--\n-- `uq_plg_financial_chart_of_accounts_code (tenant_id, code)` recusou as\n-- quatro do V1:\n--\n-- 23505: duplicate key value violates unique constraint\n--\n-- E o efeito aparecia longe dali: o item que aponta para a conta 3.01 chegava\n-- SEM classifica\u00E7\u00E3o, e o DRE mostrava o valor em \"(sem conta)\". A asser\u00E7\u00E3o de\n-- paridade mediu R$ 41.191 sob essas quatro contas, em 2026.\n--\n-- \u2500\u2500 A medi\u00E7\u00E3o que decide \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Quem tem raz\u00E3o sobre o c\u00F3digo n\u00E3o \u00E9 quem chegou primeiro: \u00E9 quem o USA.\n-- Medido em 20/09/2026, nas quatro contas semente:\n--\n-- itens classificados nelas 0\n-- movimentos nas pernas delas 0\n--\n-- Zero. S\u00E3o linhas de template que ningu\u00E9m tocou \u2014 o tenant \u00E9 novo no V2 e sua\n-- contabilidade toda vive no V1. Das 120 contas do tenant, 109 vieram do V1 e\n-- 11 s\u00E3o semente.\n--\n-- \u2500\u2500 O que se faz com a semente \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Ela n\u00E3o \u00E9 apagada. O c\u00F3digo ganha o prefixo `(modelo)` e a conta \u00E9\n-- desativada \u2014 o \u00EDndice \u00FAnico libera, o plano do cliente entra com o c\u00F3digo\n-- dele, e a linha do template continua leg\u00EDvel para quem for entender depois\n-- por que havia duas. Apagar resolveria o conflito e esconderia a hist\u00F3ria.\n--\n-- S\u00F3 toca em conta semente E sem uso: a condi\u00E7\u00E3o exige aus\u00EAncia no\n-- `migration.ledger` e zero refer\u00EAncias. Uma conta de template que algu\u00E9m j\u00E1\n-- tenha usado no V2 \u00E9 dado, n\u00E3o modelo, e fica onde est\u00E1 \u2014 nesse caso a do V1\n-- continua em quarentena, que \u00E9 a resposta certa para um conflito real.\n--\n-- Idempotente.\n\nUPDATE public.plg_financial_chart_of_accounts c\n SET code = '(modelo) ' || c.code,\n is_active = false\n WHERE c.tenant_id = 'af800ef8-853d-543f-9940-1e9a5663c061'\n AND c.code IN ('3.01', '3.02', '4.01', '4.02')\n -- semente: n\u00E3o tem linha no ledger\n AND NOT EXISTS (SELECT 1 FROM migration.ledger l\n WHERE l.target_id = c.id AND l.source_table = 'chart_of_accounts')\n -- e sem uso: nem item, nem perna de movimento\n AND NOT EXISTS (SELECT 1 FROM public.items it WHERE it.chart_account_id = c.id)\n AND NOT EXISTS (SELECT 1 FROM public.plg_financial_movements m\n WHERE m.debit_account_id = c.id OR m.credit_account_id = c.id);\n";
78
+ export declare const MIGRATION_077_O_RESUMO_FINANCEIRO_SE_SOMA_NO_BANCO = "-- O resumo financeiro se soma no banco, n\u00E3o no navegador.\n--\n-- O cart\u00E3o de resumo do painel falhava com 500 e o usu\u00E1rio via\n-- \"N\u00E3o foi poss\u00EDvel carregar o resumo financeiro\". A causa est\u00E1 em\n-- `getSummary`, e \u00E9 de forma, n\u00E3o de dado:\n--\n-- pub.from(T.movements).select('direction, movement_kind, amount,\n-- paid_amount, status, due_date, payment_date')\n--\n-- Sem filtro e sem limite. Ele baixa **todos os movimentos do tenant** e soma\n-- em JavaScript com `.filter().reduce()`. Funcionava com alguns milhares; o\n-- Espa\u00E7o Facial tem **276.360**, e o que era uma lista virou um pedido\n-- imposs\u00EDvel \u2014 tempo de consulta, tamanho de resposta e mem\u00F3ria do navegador,\n-- os tr\u00EAs ao mesmo tempo.\n--\n-- E nenhum dos nove n\u00FAmeros que a tela mostra precisa das linhas. Todos s\u00E3o\n-- agregados: saldo, a receber, a pagar, entrada e sa\u00EDda do per\u00EDodo, contagem e\n-- valor do vencido. Somar no banco devolve uma linha em vez de 276 mil.\n--\n-- \u2500\u2500 Onde a regra foi lida \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Cada express\u00E3o abaixo \u00E9 a tradu\u00E7\u00E3o literal do `.reduce()` que ela substitui,\n-- inclusive as decis\u00F5es que pareciam detalhe:\n--\n-- \u00B7 \"em aberto\" \u00E9 `status IN (pending, partial, overdue)` \u2014 n\u00E3o \u00E9 `<> paid`,\n-- porque cancelado n\u00E3o est\u00E1 em aberto, est\u00E1 morto.\n-- \u00B7 \"vencido\" \u00E9 `status = 'overdue' OR due_date < hoje` \u2014 a tela n\u00E3o confia\n-- s\u00F3 no carimbo, e faz bem: quem vence hoje \u00E0 noite ainda est\u00E1 pendente no\n-- banco.\n-- \u00B7 o saldo do per\u00EDodo conta s\u00F3 `movement_kind = 'payment'` \u2014 evento de\n-- dinheiro, nunca a obriga\u00E7\u00E3o que ele quita. Somar os dois contaria a mesma\n-- venda duas vezes.\n-- \u00B7 a janela padr\u00E3o \u00E9 M\u00D3VEL de 30 dias, e n\u00E3o o m\u00EAs corrente: no dia 2 um\n-- \"m\u00EAs at\u00E9 agora\" mostra quase zero e parece defeito.\n--\n-- `SECURITY INVOKER` de prop\u00F3sito: a fun\u00E7\u00E3o enxerga exatamente o que quem\n-- chamou enxergaria, ent\u00E3o a cerca de unidade vale aqui como vale na lista. Um\n-- resumo que somasse o que a tela n\u00E3o mostra seria pior que um resumo que falha.\n--\n-- Idempotente.\n\nCREATE OR REPLACE FUNCTION public.financial_summary(\n p_from date DEFAULT NULL,\n p_to date DEFAULT NULL\n) RETURNS TABLE (\n total_balance numeric,\n total_receivable numeric,\n total_payable numeric,\n monthly_inflow numeric,\n monthly_outflow numeric,\n overdue_receivable_count integer,\n overdue_receivable_amount numeric,\n overdue_payable_count integer,\n overdue_payable_amount numeric\n)\nLANGUAGE sql\nSTABLE\nSECURITY INVOKER\nSET search_path TO ''\nAS $function$\n WITH janela AS (\n SELECT coalesce(p_from, (current_date - 30)) AS de,\n coalesce(p_to, current_date) AS ate\n ),\n saldo AS (\n SELECT coalesce(sum(b.balance), 0) AS v\n FROM public.v_financial_account_balances b\n WHERE b.is_active\n AND b.kind IN ('bank', 'cash_physical', 'cash_session',\n 'card_receivable_credit', 'card_receivable_debit')\n ),\n aberto AS (\n SELECT\n coalesce(sum(m.amount - m.paid_amount) FILTER (WHERE m.direction = 'credit'), 0) AS receber,\n coalesce(sum(m.amount - m.paid_amount) FILTER (WHERE m.direction = 'debit'), 0) AS pagar,\n count(*) FILTER (WHERE m.direction = 'credit'\n AND (m.status = 'overdue' OR m.due_date < current_date)) AS venc_receber_n,\n coalesce(sum(m.amount - m.paid_amount) FILTER (WHERE m.direction = 'credit'\n AND (m.status = 'overdue' OR m.due_date < current_date)), 0) AS venc_receber_v,\n count(*) FILTER (WHERE m.direction = 'debit'\n AND (m.status = 'overdue' OR m.due_date < current_date)) AS venc_pagar_n,\n coalesce(sum(m.amount - m.paid_amount) FILTER (WHERE m.direction = 'debit'\n AND (m.status = 'overdue' OR m.due_date < current_date)), 0) AS venc_pagar_v\n FROM public.plg_financial_movements m\n WHERE m.status IN ('pending', 'partial', 'overdue')\n ),\n caixa AS (\n SELECT\n coalesce(sum(c.paid_amount) FILTER (WHERE c.direction = 'credit'), 0) AS entrada,\n coalesce(sum(c.paid_amount) FILTER (WHERE c.direction = 'debit'), 0) AS saida\n FROM public.v_financial_cash_movements c, janela j\n WHERE c.movement_kind = 'payment'\n AND c.payment_date >= j.de AND c.payment_date <= j.ate\n )\n SELECT saldo.v, aberto.receber, aberto.pagar, caixa.entrada, caixa.saida,\n aberto.venc_receber_n::integer, aberto.venc_receber_v,\n aberto.venc_pagar_n::integer, aberto.venc_pagar_v\n FROM saldo, aberto, caixa;\n$function$;\n\nCOMMENT ON FUNCTION public.financial_summary(date, date) IS\n 'Os nove n\u00FAmeros do cart\u00E3o de resumo, somados no banco. Substitui um '\n 'getSummary que baixava TODOS os movimentos do tenant para reduzir em '\n 'JavaScript \u2014 276.360 linhas no Espa\u00E7o Facial, e um 500 em vez do cart\u00E3o.';\n\nGRANT EXECUTE ON FUNCTION public.financial_summary(date, date) TO authenticated, service_role;\n";
79
+ export declare const MIGRATION_078_O_SALDO_DA_CONTA_SE_CALCULA_SO_NA_CONTA_PEDIDA = "-- O saldo da conta se calcula s\u00F3 na conta pedida.\n--\n-- Terceira vez que este erro aparece neste pool, e sempre com a mesma forma:\n-- uma CTE que agrega tudo antes de o filtro existir.\n--\n-- WITH debits AS (SELECT ... GROUP BY debit_account_id),\n-- credits AS (SELECT ... GROUP BY credit_account_id)\n-- SELECT ... FROM plg_financial_accounts account\n-- LEFT JOIN debits ON debits.account_id = account.id\n-- LEFT JOIN credits ON credits.account_id = account.id;\n--\n-- Uma CTE \u00E9 materializada INTEIRA antes de o `WHERE` de quem a consome\n-- aparecer. Ent\u00E3o quem pede o saldo de 156 contas paga a agrega\u00E7\u00E3o de todas.\n--\n-- \u2500\u2500 A conta, medida \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Tenant Espa\u00E7o Facial, 20/09/2026:\n--\n-- contas totais 120.516\n-- das quais kind = 'person' 120.290 (o raz\u00E3o de cliente do V1)\n-- das quais o resumo do painel usa 156 (bank, cash_*, card_*)\n--\n-- somar o saldo dessas 156 21.849 ms\n--\n-- Vinte e dois segundos para somar cento e cinquenta e seis contas. O porte do\n-- V1 trouxe uma conta de raz\u00E3o por cliente, e foi isso que exp\u00F4s a forma \u2014\n-- antes eram dezenas de contas e ningu\u00E9m sentia.\n--\n-- \u2500\u2500 O conserto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- `LEFT JOIN LATERAL` correlacionado em `account.id`. A correla\u00E7\u00E3o deixa de ser\n-- condi\u00E7\u00E3o de jun\u00E7\u00E3o e vira PAR\u00C2METRO: o planejador s\u00F3 soma os movimentos das\n-- contas que sobreviveram ao filtro, pelos \u00EDndices que j\u00E1 existem\n-- (`plg_financial_movements_debit_idx` e `_credit_idx`).\n--\n-- Nada muda no que a view devolve: as mesmas colunas, a mesma aritm\u00E9tica, a\n-- mesma regra de sinal por esp\u00E9cie de conta. Muda quantas contas ela visita.\n--\n-- Idempotente.\n\n-- `v_financial_customer_credit` pende desta view \u2014 conferido em `pg_depend`\n-- antes de derrubar, que \u00E9 a li\u00E7\u00E3o que j\u00E1 custou sete views num CASCADE. O\n-- CASCADE a levava junto e ningu\u00E9m percebia at\u00E9\n-- `financial_credit_customer()` procurar por ela:\n--\n-- ERROR: relation \"public.v_financial_customer_credit\" does not exist\n--\n-- Cai junto, de prop\u00F3sito e por escrito, e volta logo abaixo com a mesma\n-- defini\u00E7\u00E3o do baseline.\nDROP VIEW IF EXISTS public.v_financial_customer_credit;\nDROP VIEW IF EXISTS public.v_financial_account_balances CASCADE;\n\nCREATE VIEW public.v_financial_account_balances WITH (security_invoker = true) AS\nSELECT\n account.id AS account_id,\n account.tenant_id,\n account.unit_id,\n account.kind,\n account.code,\n account.name,\n account.person_id,\n account.is_active,\n account.account_type,\n account.bank_name,\n account.agency_number,\n account.account_number,\n account.metadata,\n COALESCE(d.total, 0::numeric)::numeric(14,2) AS debit_total,\n COALESCE(cr.total, 0::numeric)::numeric(14,2) AS credit_total,\n (COALESCE(cr.total, 0::numeric) - COALESCE(d.total, 0::numeric))::numeric(14,2) AS net_credit,\n -- A regra de sinal n\u00E3o muda: na conta de dinheiro o d\u00E9bito SOMA (entrou no\n -- caixa); nas outras \u00E9 o cr\u00E9dito que soma.\n CASE WHEN account.kind = ANY (ARRAY['bank','cash_physical','cash_session',\n 'card_receivable_credit','card_receivable_debit'])\n THEN (COALESCE(d.total, 0::numeric) - COALESCE(cr.total, 0::numeric))::numeric(14,2)\n ELSE (COALESCE(cr.total, 0::numeric) - COALESCE(d.total, 0::numeric))::numeric(14,2)\n END AS balance,\n (COALESCE(d.n, 0::bigint) + COALESCE(cr.n, 0::bigint))::integer AS movement_count,\n account.created_at,\n account.updated_at\n FROM public.plg_financial_accounts account\n LEFT JOIN LATERAL (\n SELECT sum(m.amount) AS total, count(*) AS n\n FROM public.v_financial_cash_movements m\n WHERE m.debit_account_id = account.id\n AND m.status <> 'cancelled'\n AND m.movement_kind = ANY (ARRAY['payment','transfer'])\n ) d ON true\n LEFT JOIN LATERAL (\n SELECT sum(m.amount) AS total, count(*) AS n\n FROM public.v_financial_cash_movements m\n WHERE m.credit_account_id = account.id\n AND m.status <> 'cancelled'\n AND m.movement_kind = ANY (ARRAY['payment','transfer'])\n ) cr ON true;\n\nCOMMENT ON VIEW public.v_financial_account_balances IS\n 'Saldo de cada conta, dos movimentos dela. LATERAL correlacionado em '\n 'account.id para que a soma aconte\u00E7a s\u00F3 nas contas que a consulta pediu \u2014 a '\n 'forma anterior agregava as 120.290 contas de raz\u00E3o de cliente para somar as '\n '156 do caixa, e o resumo do painel levava 22 s.';\n\nGRANT SELECT ON public.v_financial_account_balances TO authenticated, service_role;\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\nGRANT SELECT ON public.v_financial_customer_credit TO authenticated, service_role;\n";
80
+ export declare const MIGRATION_079_AS_PORTAS_DE_PORTE_NOVAS_NAO_NASCEM_ABERTAS = "-- As portas de porte novas n\u00E3o nascem abertas\n--\n-- As 077\u2013079 trouxeram tr\u00EAs fun\u00E7\u00F5es para o schema `migration`, e toda fun\u00E7\u00E3o\n-- no Postgres nasce com EXECUTE para PUBLIC. O contrato do ledger mede isso e\n-- n\u00E3o abre exce\u00E7\u00E3o, pelo motivo certo: as fun\u00E7\u00F5es de `migration` s\u00E3o escrita\n-- em massa no banco do cliente, por fora da cerca da tela. Uma delas cham\u00E1vel\n-- por `authenticated` \u00E9 uma porta de escrita em massa aberta a quem s\u00F3 tinha\n-- direito de ler a pr\u00F3pria conta.\n--\n-- Idempotente.\n\nREVOKE ALL ON FUNCTION migration.upsert_invoice(uuid, uuid, jsonb, jsonb)\n FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION migration.upsert_fin_movement(uuid, uuid, jsonb, jsonb)\n FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION migration._fin_installment_from_bill(uuid, uuid, uuid, uuid, integer, date, numeric, boolean, boolean)\n FROM PUBLIC, anon, authenticated;\n";
33
81
  export declare const MIGRATIONS: Array<{
34
82
  id: string;
35
83
  sql: string;