// Despesas fixas (recorrentes) do Financeiro — migration 059. // // A despesa fixa é cadastrada uma vez; ao abrir o Financeiro, // lancarDespesasFixas() cria a transação "Pendente" de cada mês ainda não // lançado (de ultima_competencia + 1 até o mês atual). Quem paga marca // "Pago" na transação, como qualquer outra. const MAX_MESES_RETROATIVOS = 12; const fxMesISO = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; // '2026-10-15' -> '2026-10-01' const fxCompetenciaDe = (dataISO) => `${String(dataISO).slice(0, 7)}-01`; const fxMesAnterior = (hoje = new Date()) => fxMesISO(new Date(hoje.getFullYear(), hoje.getMonth() - 1, 1)); // Meses (1º dia, ISO) que faltam lançar, do mês seguinte a `ultima` até o atual. function fxCompetenciasPendentes(ultima, hoje = new Date()) { const atual = new Date(hoje.getFullYear(), hoje.getMonth(), 1); // new Date(ano, mês 1-based, 1) já é o mês seguinte a `ultima` let cur = ultima ? new Date(+ultima.slice(0, 4), +ultima.slice(5, 7), 1) : atual; const meses = []; while (cur <= atual) { meses.push(fxMesISO(cur)); cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); } return meses.slice(-MAX_MESES_RETROATIVOS); } // Dia 31 em fevereiro cai no último dia do mês. function fxVencimentoNoMes(competencia, dia) { const ano = +competencia.slice(0, 4), mes = +competencia.slice(5, 7); const ultimoDia = new Date(ano, mes, 0).getDate(); return `${competencia.slice(0, 8)}${String(Math.min(dia, ultimoDia)).padStart(2, '0')}`; } // Lança os meses pendentes das despesas fixas ativas. Devolve as transações // criadas. O unique (despesa_fixa_id, competencia) evita duplicar se duas // pessoas abrirem o Financeiro ao mesmo tempo. async function lancarDespesasFixas(fixas) { const novas = []; for (const f of (fixas || []).filter(f => f.ativo)) { const meses = fxCompetenciasPendentes(f.ultima_competencia); if (!meses.length) continue; const rows = meses.map(c => ({ type: 'despesa', description: f.description, amount: f.amount, date: fxVencimentoNoMes(c, f.dia_vencimento), category: f.category, payment_method: f.payment_method, status: 'Pendente', notes: f.notes || null, despesa_fixa_id: f.id, competencia: c, })); const { data, error } = await sb.from('transactions') .upsert(rows, { onConflict: 'despesa_fixa_id,competencia', ignoreDuplicates: true }) .select(); if (error) { console.error('[lancarDespesasFixas] insert', f.id, error); continue; } const { error: upErr } = await sb.from('despesas_fixas') .update({ ultima_competencia: meses[meses.length - 1] }).eq('id', f.id); if (upErr) console.error('[lancarDespesasFixas] update', f.id, upErr); novas.push(...(data || [])); } return novas; } const fxEmptyForm = { description: '', amount: '', dia_vencimento: '10', category: 'Outro', payment_method: 'Boleto', notes: '', lancarEsteMes: true }; const fxBRL = (v) => 'R$ ' + (parseFloat(v) || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 }); const FX_METODOS = ['PIX', 'Boleto', 'Cartão', 'Transferência', 'Dinheiro'].map(m => ({ value: m, label: m })); function DespesasFixasModal({ open, onClose, fixas, categorias, onChanged, showToast }) { const [editing, setEditing] = React.useState(null); // null = lista, 'new' ou a despesa const [form, setForm] = React.useState(fxEmptyForm); const [saving, setSaving] = React.useState(false); const [delConfirm, setDelConfirm] = React.useState(null); React.useEffect(() => { if (!open) { setEditing(null); setDelConfirm(null); } }, [open]); const openForm = (f) => { setEditing(f || 'new'); setForm(f ? { description: f.description, amount: String(f.amount), dia_vencimento: String(f.dia_vencimento), category: f.category, payment_method: f.payment_method || 'PIX', notes: f.notes || '', lancarEsteMes: false } : fxEmptyForm); }; const save = async () => { const amount = parseFloat(String(form.amount).replace(',', '.')) || 0; const dia = parseInt(form.dia_vencimento, 10); if (!form.description.trim()) { showToast('Informe a descrição.', 'danger'); return; } if (amount <= 0) { showToast('Informe um valor válido.', 'danger'); return; } if (!(dia >= 1 && dia <= 31)) { showToast('Dia de vencimento deve ser de 1 a 31.', 'danger'); return; } const payload = { description: form.description.trim(), amount, dia_vencimento: dia, category: form.category, payment_method: form.payment_method, notes: form.notes.trim() || null }; setSaving(true); const { error } = editing === 'new' ? await sb.from('despesas_fixas').insert({ ...payload, ativo: true, ultima_competencia: form.lancarEsteMes ? fxMesAnterior() : fxMesISO(new Date()) }) : await sb.from('despesas_fixas').update(payload).eq('id', editing.id); setSaving(false); if (error) { console.error('[DespesasFixas.save]', error); showToast('Erro ao salvar: ' + (error.message || ''), 'danger'); return; } showToast(editing === 'new' ? 'Despesa fixa cadastrada!' : 'Despesa fixa atualizada!'); setEditing(null); onChanged(); }; const toggleAtivo = async (f) => { // Ao reativar, recomeça do mês atual — não lança os meses em que ficou pausada. const patch = f.ativo ? { ativo: false } : { ativo: true, ultima_competencia: fxMesAnterior() }; const { error } = await sb.from('despesas_fixas').update(patch).eq('id', f.id); if (error) { showToast('Erro: ' + (error.message || ''), 'danger'); return; } showToast(f.ativo ? 'Despesa fixa pausada.' : 'Despesa fixa reativada.'); onChanged(); }; const remove = async (f) => { const { error } = await sb.from('despesas_fixas').delete().eq('id', f.id); if (error) { showToast('Erro ao excluir: ' + (error.message || ''), 'danger'); return; } setDelConfirm(null); showToast('Despesa fixa excluída. Os lançamentos já feitos continuam no Financeiro.'); onChanged(); }; const totalMensal = fixas.filter(f => f.ativo).reduce((s, f) => s + (parseFloat(f.amount) || 0), 0); const iconBtn = { border: 'none', background: 'transparent', cursor: 'pointer', padding: 4, borderRadius: 4, display: 'flex' }; return ( {editing ? (
setForm(f => ({ ...f, description: v }))} placeholder="Ex: Contador, Energia solar, iFood" />
setForm(f => ({ ...f, amount: v }))} placeholder="0,00" /> setForm(f => ({ ...f, dia_vencimento: v }))} placeholder="1 a 31" />
setForm(f => ({ ...f, payment_method: v }))} options={FX_METODOS} />
setForm(f => ({ ...f, notes: v }))} /> {editing === 'new' ? ( ) : (
A alteração vale a partir do próximo mês. O lançamento deste mês, se já existir, é editado na lista de transações.
)}
setEditing(null)}>Voltar {saving ? 'Salvando…' : 'Salvar'}
) : (

Cadastre uma vez e o sistema lança sozinho, todo mês, como Pendente no dia do vencimento. Quando pagar, é só marcar como Pago na transação.

{fixas.length === 0 ? ( ) : (
{fixas.map((f, i) => (
{f.description} {!f.ativo && Pausada}
Todo dia {f.dia_vencimento} · {f.category} · {f.payment_method}
{fxBRL(f.amount)}
{delConfirm === f.id ? (
setDelConfirm(null)}>Não remove(f)}>Excluir
) : (
)}
))}
)}
Total mensal (ativas): {fxBRL(totalMensal)} openForm(null)}>Nova despesa fixa
)}
); } Object.assign(window, { DespesasFixasModal, lancarDespesasFixas, fxCompetenciaDe, fxCompetenciasPendentes, fxVencimentoNoMes });