// ============================================================ // FINAFAMILY — Importar Fatura de Cartão // Envia o PDF, revisa as transações extraídas, aplica o corte e a deduplicação // canônicos do servidor e confirma a criação das despesas. // ============================================================ function guessCategoryId(desc, categories) { const d = desc.toLowerCase(); const rules = [ [/mercado|super|alimenta|restaurante|lanch|cafe|pizza|burger|ifood/, 'Alimentação'], [/uber|99|posto|combust|estacionamento|pedagio|pedágio|onibus|ônibus|passagem/, 'Transporte'], [/farmacia|farmácia|drogaria|hospital|clinica|clínica|academia|gympass|wellhub/, 'Saúde'], [/roupa|loja|calçad|calcad|moda|vestua/, 'Vestuário'], [/netflix|spotify|prime|youtube|hbo|disney|assinatura|app store|google|microsoft|openai|anthropic|claude|chatgpt|hosting/, 'Assinaturas'], [/cinema|show|ingresso|viagem|hotel|pousada|parque/, 'Lazer'], [/aluguel|condomin|condomín|energia|luz|agua|água|internet|gás|gas/, 'Moradia'], [/escola|curso|faculdade|livro/, 'Educação'], ]; for (const [re, name] of rules) { if (re.test(d)) { const cat = categories.find(c => c.name.toLowerCase() === name.toLowerCase()); if (cat) return cat.id; } } return categories.find(c => c.name === 'Outros')?.id || categories[0]?.id || ''; } function ImportarFatura() { const { paymentMethods, categories, members, currentMember, refreshImportedInvoiceData } = useApp(); const [file, setFile] = React.useState(null); const [parsing, setParsing] = React.useState(false); const [parsed, setParsed] = React.useState(null); const [rows, setRows] = React.useState([]); const [paymentMethodId, setPaymentMethodId] = React.useState(''); const [cutoffDate, setCutoffDate] = React.useState(''); const [responsibleMemberId, setResponsibleMemberId] = React.useState(currentMember?.id || ''); const [error, setError] = React.useState(''); const [committing, setCommitting] = React.useState(false); const [result, setResult] = React.useState(null); const [needsPassword, setNeedsPassword] = React.useState(false); const [pdfPassword, setPdfPassword] = React.useState(''); const [preview, setPreview] = React.useState(null); const [previewError, setPreviewError] = React.useState(''); const [previewRetry, setPreviewRetry] = React.useState(0); const [refreshWarning, setRefreshWarning] = React.useState(''); const [refreshing, setRefreshing] = React.useState(false); const requestSequence = React.useRef(0); const activeMember = React.useRef(currentMember?.id); const mounted = React.useRef(true); React.useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); activeMember.current = currentMember?.id; const myPMs = paymentMethods.filter(pm => pm.memberId === currentMember?.id && pm.type === 'credit'); const payload = { paymentMethodId, cutoffDate, transactions: rows.map(r => ({ desc: r.desc, value: r.value, date: r.date, purchaseDate: r.purchaseDate, transactionDate: r.transactionDate, originalPurchaseDate: r.originalPurchaseDate, currentInstallment: r.currentInstallment, totalInstallments: r.totalInstallments, categoryId: r.categoryId, responsibleMemberId, include: r.include, })), }; const previewKey = parsed && !result && paymentMethodId && cutoffDate && rows.length ? JSON.stringify([currentMember?.id, payload]) : ''; const currentPreview = preview?.key === previewKey ? preview.data : null; const cardPreview = preview?.paymentMethodId === paymentMethodId && preview?.memberId === currentMember?.id ? preview.data : null; const willImportCount = currentPreview?.importableCount || 0; const processableCount = currentPreview?.processableCount ?? currentPreview?.importableCount ?? 0; const previewPending = Boolean(previewKey && !currentPreview && !previewError); const civilDate = value => value ? value.split('-').reverse().join('/') : ''; const omissionLabels = { before_cutoff: 'Anterior ao corte: não será importado.', subscription: 'Assinatura já cadastrada neste cartão, com a mesma empresa e valor.', existing_installment: 'Parcelamento já cadastrado: não será criado novamente.', duplicate: 'Lançamento já cadastrado: não será importado novamente.', not_selected: 'Não selecionado para importação.', }; function resetImport() { requestSequence.current += 1; setParsed(null); setRows([]); setFile(null); setResult(null); setPreview(null); setPreviewError(''); setError(''); setNeedsPassword(false); setPdfPassword(''); setParsing(false); setRefreshWarning(''); setRefreshing(false); setCommitting(false); } React.useEffect(() => { resetImport(); setPaymentMethodId(''); setCutoffDate(''); setResponsibleMemberId(currentMember?.id || ''); }, [currentMember?.id]); React.useEffect(() => { setPreviewError(''); if (!previewKey) return; const sequence = ++requestSequence.current; const memberId = currentMember?.id; const controller = new AbortController(); const timer = setTimeout(async () => { try { const data = await apiFetch('/invoice-import/preview', { method: 'POST', body: payload, signal: controller.signal }); if (controller.signal.aborted || sequence !== requestSequence.current || (!mounted.current || activeMember.current !== memberId)) return; setPreview({ key: previewKey, data, paymentMethodId, memberId }); // A data automática é um limite inferior; o usuário pode escolher um corte posterior. if (data.automaticCutoffDate && cutoffDate < data.automaticCutoffDate) setCutoffDate(data.automaticCutoffDate); } catch (err) { if (controller.signal.aborted || sequence !== requestSequence.current || (!mounted.current || activeMember.current !== memberId)) return; setPreview(null); setPreviewError(err?.message || 'Não foi possível verificar os lançamentos.'); } }, 250); return () => { clearTimeout(timer); controller.abort(); }; }, [previewKey, previewRetry]); function handleFileChange(f) { setFile(f); setNeedsPassword(false); setPdfPassword(''); setError(''); } async function handleParse() { if (!file) { setError('Selecione o PDF da fatura'); return; } const memberId = currentMember?.id; const sequence = ++requestSequence.current; setError(''); setParsing(true); setResult(null); try { const form = new FormData(); form.append('file', file); if (pdfPassword) form.append('password', pdfPassword); const token = localStorage.getItem('ff_token'); const res = await fetch(API_BASE + '/invoice-import/parse', { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: form, }); const data = await res.json().catch(() => ({})); if (sequence !== requestSequence.current || (!mounted.current || activeMember.current !== memberId)) return; if (!res.ok) { if (data.code === 'PDF_PASSWORD_REQUIRED') { setNeedsPassword(true); setError(data.message || 'Esse PDF está protegido por senha.'); return; } throw new Error(data.message || `HTTP ${res.status}`); } setNeedsPassword(false); setParsed(data); setPreview(null); const initialRows = data.transactions.map((t, i) => ({ ...t, _id: i, categoryId: guessCategoryId(t.desc, categories), include: true })); setRows(initialRows); const dates = initialRows.map(r => r.transactionDate || r.date || r.purchaseDate).filter(Boolean).sort(); setCutoffDate(dates[0] || todayLocalISO()); setPaymentMethodId(myPMs[0]?.id || ''); } catch (err) { if (sequence === requestSequence.current && (mounted.current && activeMember.current === memberId)) setError(err?.message || 'Não foi possível ler o PDF'); } finally { if (sequence === requestSequence.current && (mounted.current && activeMember.current === memberId)) setParsing(false); } } function updateRow(id, patch) { setPreviewError(''); setRows(prev => prev.map(r => r._id === id ? { ...r, ...patch } : r)); } async function handleRefreshImportedTotals() { const memberId = currentMember?.id; const sequence = requestSequence.current; setRefreshing(true); setRefreshWarning(''); try { await refreshImportedInvoiceData(); } catch (err) { if (mounted.current && activeMember.current === memberId && sequence === requestSequence.current) { setRefreshWarning('A importação foi concluída, mas não foi possível atualizar todos os totais. Tente atualizar os dados.'); } } finally { if (mounted.current && activeMember.current === memberId && sequence === requestSequence.current) setRefreshing(false); } } async function handleCommit() { if (!currentPreview || previewError || !processableCount || committing) return; const memberId = currentMember?.id; setError(''); setCommitting(true); setResult(null); try { // O servidor repete as verificações na transação: a prévia não autoriza duplicações. const data = await apiFetch('/invoice-import/commit', { method: 'POST', body: payload }); if ((!mounted.current || activeMember.current !== memberId)) return; setResult(data); await handleRefreshImportedTotals(); } catch (err) { if ((mounted.current && activeMember.current === memberId)) setError(err?.message || 'Não foi possível importar'); } finally { if ((mounted.current && activeMember.current === memberId)) setCommitting(false); } } return (
Envie o PDF da fatura (Santander, Porto Seguro ou Inter). Revise os novos lançamentos antes de importar.
{!parsed && (Cadastre um cartão de crédito seu para importar uma fatura.
} {needsPassword && (Selecione os lançamentos que você conferiu. Itens já cadastrados serão somente registrados no histórico desta importação, sem criar novas despesas.
Nenhum lançamento encontrado neste PDF. Escolha outra fatura.
} {error &&{result.skippedCount} lançamentos não importados, conforme as verificações de data, assinatura, parcelamento e seleção.
}