Skip to content

Instantly share code, notes, and snippets.

@renzon
Created March 2, 2026 11:44
Show Gist options
  • Select an option

  • Save renzon/f502114d4308eba40a0a8139f0807bce to your computer and use it in GitHub Desktop.

Select an option

Save renzon/f502114d4308eba40a0a8139f0807bce to your computer and use it in GitHub Desktop.
Sistema de Conciliação Bancária via Telegram - Importadores OFX (Nubank) e CSV (Banco Inter)

Sistema de Conciliação de Extratos via Telegram

Sistema automatizado para processar arquivos OFX do Nubank enviados via Telegram, categorizando transações e detectando duplicatas.

Como Usar

1. Enviar Arquivo OFX

Basta enviar o arquivo OFX do Nubank pelo Telegram. O sistema irá:

  1. Detectar automaticamente que é um arquivo OFX
  2. Processar usando o script de conciliação
  3. Mostrar preview das transações categorizadas
  4. Aguardar sua confirmação

2. Confirmar ou Cancelar

Após ver o preview:

  • Digite confirmar para adicionar as transações ao ledger
  • Digite cancelar para descartar

Funcionalidades

Categorização Automática

O sistema usa um dicionário com 434 regras para categorizar transações automaticamente:

Exemplos:

  • ApplecombillExpenses:Familia:Priscila
  • IfoodExpenses:Restaurante
  • CarrefourExpenses:Mercado
  • DrogasilExpenses:Familia:Saude
  • UberExpenses:Transporte:Uber
  • NetflixExpenses:Lazer
  • HavanExpenses:Casa
  • Desconto AntecipaçãoIncome:Business:Nubank:Desconto

Categorias principais:

  • Expenses:Mercado - Supermercados, feiras, padarias
  • Expenses:Restaurante - Restaurantes, lanchonetes, delivery
  • Expenses:Familia:Saude - Farmácias, hospitais, consultas
  • Expenses:Familia:Priscila - Gastos pessoais da Priscila
  • Expenses:Familia:Filhos - Escola, material escolar, roupas infantis
  • Expenses:Familia:Renzo - Gastos pessoais do Renzo
  • Expenses:Transporte:Carro - Gasolina, estacionamento, pedágio
  • Expenses:Transporte:Uber - Uber, táxi
  • Expenses:Lazer - Cinema, viagens, presentes
  • Expenses:Casa - Contas, manutenção, decoração
  • Expenses:Cachorro - Pet shop, veterinário
  • Income:Business:Nubank:Desconto - Descontos de antecipação

Detecção de Duplicatas

  • Verifica se a transação já existe no ledger usando nubank_id
  • Evita processamento duplicado do mesmo extrato
  • Chave de duplicata: {data}-{nubank_id}

Detecção de Tipo de Conta

Identifica automaticamente:

  • Cartão de crédito: Liabilities:Cartoes:Nubank
  • Conta corrente: Assets:Caixa:Nubank:Renzo

Uso Manual (Linha de Comando)

Preview (sem salvar)

python3 /workspace/group/scripts/reconcile_ofx.py /path/to/arquivo.ofx

Mostra:

  • Resumo com número de transações
  • Categorias e totais
  • Transações no formato Beancount
  • Não salva no ledger

Commit Direto (salvar automaticamente)

python3 /workspace/group/scripts/reconcile_ofx.py /path/to/arquivo.ofx --commit

Adiciona as transações diretamente ao ledger sem pedir confirmação.

Formato de Saída

Resumo

📊 Extrato processado: Nubank_2026-04-06.ofx

✅ 35 transações encontradas
🆕 33 transações novas
🔄 2 duplicatas removidas

Categorias:
• Mercado: 18 transações (R$ 1.234,56)
• Restaurante: 8 transações (R$ 567,89)
• Unknown: 7 transações (R$ 234,50) ⚠️

⚠️ 7 transações precisam de categorização manual

Digite 'confirmar' para adicionar ao ledger ou 'cancelar' para descartar.

Transações (formato Beancount)

2026-03-01 * "Applecombill"
  nubank_id: "69a4055d-45e6-41ae-9841-74450d811d17"
  Liabilities:Cartoes:Nubank  -5.90 BRL
  Expenses:Familia:Priscila    5.90 BRL

2026-03-01 * "Ifood"
  nubank_id: "6981deba-96a1-4d7e-adeb-d70f4bdfb621"
  Liabilities:Cartoes:Nubank  -207.46 BRL
  Expenses:Restaurante         207.46 BRL

Localização dos Arquivos

  • Script: /workspace/group/scripts/reconcile_ofx.py
  • Ledger: /workspace/extra/projects/contabilidade-pessoal/transactions.beancount
  • Uploads: /workspace/uploads/ (arquivos recebidos via Telegram)

Transações Não Categorizadas

Transações que não tem regra no dicionário vão para Expenses:Unknown.

Para adicionar nova regra, edite o dicionário _prefix_account_dct no script e adicione:

'Nome do Estabelecimento': 'Expenses:Categoria',

Importante: O match é feito por prefixo (começa com), então:

  • 'Ifood' vai pegar 'Ifood *Restaurante XYZ'
  • Seja específico quando necessário
  • Coloque regras mais específicas antes das genéricas

Troubleshooting

"Arquivo não reconhecido"

Certifique-se que o arquivo:

  • Termina com .ofx ou .OFX
  • É um arquivo OFX válido do Nubank

"Todas duplicatas"

Você já processou este extrato anteriormente. As transações já estão no ledger.

Categorização incorreta

Edite o dicionário _prefix_account_dct no script para ajustar as regras.

Erro ao carregar ledger

Verifique se /workspace/extra/projects/contabilidade-pessoal/transactions.beancount existe e está acessível.

Limitações Atuais

  1. Suporta apenas OFX do Nubank (cartão e conta)
  2. Não suporta CSV do Banco Inter ainda
  3. Categorização manual necessária para estabelecimentos novos
  4. Sistema de confirmação via Telegram ainda não implementado (usar --commit como alternativa)

Próximos Passos

  • Integração com bot Telegram para confirmação interativa
  • Arquivamento automático de OFX processados
  • Suporte a CSV do Banco Inter
  • Interface web para revisar/editar categorias antes de salvar
  • Relatórios de gastos por categoria
#!/usr/bin/env python3
"""
Script de conciliação de extratos CSV do Banco Inter
Processa arquivos CSV exportados do app Inter
"""
import sys
import csv
from pathlib import Path
from typing import List, Set, Tuple, Optional
from datetime import datetime
from decimal import Decimal
from beancount.loader import load_file
from beancount.parser import printer
from beancount.core import data, amount
# Dicionário de categorização de transações (mesmo do Nubank)
_prefix_account_dct = {
'Oba Hortifruti': 'Expenses:Mercado',
'Oba': 'Expenses:Mercado',
'Big Jump': 'Expenses:Familia:Filhos',
'Assai': 'Expenses:Mercado',
'Carrefour': 'Expenses:Mercado',
'Pao de Acucar': 'Expenses:Mercado',
'Lojas Americanas': 'Expenses:Mercado',
'Ifood': 'Expenses:Restaurante',
'Uber': 'Expenses:Transporte:Uber',
'Netflix': 'Expenses:Lazer',
'Drogasil': 'Expenses:Familia:Saude',
'Raia': 'Expenses:Familia:Saude',
'Posto': 'Expenses:Transporte:Carro',
'Shell': 'Expenses:Transporte:Carro',
'Ipiranga': 'Expenses:Transporte:Carro',
}
def extract_existing_keys(entries: List) -> Set[str]:
"""
Extrai chaves únicas de transações existentes para detecção de duplicatas.
Chave para Inter CSV: "{data.isoformat()}-{memo}-{valor}"
"""
keys = set()
for entry in entries:
if isinstance(entry, data.Transaction):
if hasattr(entry, 'postings') and len(entry.postings) > 0:
# Usar data + narration + valor como chave
valor = abs(float(entry.postings[0].units.number))
key = f"{entry.date.isoformat()}-{entry.narration}-{valor:.2f}"
keys.add(key)
return keys
def categorize_transaction(memo: str, prefix_dict: dict) -> str:
"""
Categoriza transação baseado no memo usando dicionário de prefixos
"""
memo_upper = memo.upper()
for prefix, account in prefix_dict.items():
if prefix.upper() in memo_upper:
return account
return 'Expenses:Unknown'
def categorize_entries(entries: List) -> dict:
"""
Agrupa transações por categoria para resumo
"""
categories = {}
for entry in entries:
if isinstance(entry, data.Transaction):
for posting in entry.postings:
account = posting.account
if account.startswith('Expenses:') or account.startswith('Income:'):
if account not in categories:
categories[account] = {'count': 0, 'total': 0}
categories[account]['count'] += 1
if posting.units:
categories[account]['total'] += float(posting.units.number)
return categories
def parse_inter_csv(csv_path: str, account_owner: str = 'Priscila') -> List:
"""
Parse arquivo CSV do Inter e retorna lista de entries beancount
Args:
csv_path: Caminho para o arquivo CSV
account_owner: Dono da conta (Renzo, Priscila, Amanda)
Returns:
Lista de entries beancount
"""
entries = []
# Determinar conta base
if account_owner == 'Priscila':
base_account = 'Liabilities:Cartoes:Inter:Priscila'
elif account_owner == 'Renzo':
base_account = 'Liabilities:Cartoes:Inter:Renzo'
elif account_owner == 'Amanda':
base_account = 'Liabilities:Cartoes:Inter:Amanda'
else:
base_account = f'Liabilities:Cartoes:Inter:{account_owner}'
with open(csv_path, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for idx, row in enumerate(reader):
# Parsear data (formato DD/MM/YYYY)
date_str = row['Data']
date = datetime.strptime(date_str, '%d/%m/%Y').date()
# Parsear valor (formato "R$ 123,45" ou "R$ 1.234,56")
valor_str = row['Valor'].replace('R$', '').strip()
# Remover pontos de milhar e trocar vírgula por ponto
valor_str = valor_str.replace('.', '').replace(',', '.')
valor = Decimal(valor_str).copy_abs() # Usar valor absoluto como Decimal
# Lançamento é o memo/descrição
memo = row['Lançamento']
# Categorizar
expense_account = categorize_transaction(memo, _prefix_account_dct)
# Criar metadata
meta = data.new_metadata(csv_path, idx)
# Criar transação
# Para cartão de crédito, despesas são negativas no cartão
txn = data.Transaction(
meta,
date,
'*',
None,
memo,
data.EMPTY_SET,
data.EMPTY_SET,
[
data.Posting(
base_account,
amount.Amount(-valor, 'BRL'),
None, None, None, None
),
data.Posting(
expense_account,
amount.Amount(valor, 'BRL'),
None, None, None, None
),
]
)
entries.append(txn)
return entries
def reconcile_inter_csv(csv_path: str, ledger_path: str, account_owner: str = 'Priscila', auto_commit: bool = False) -> Tuple[str, Optional[List]]:
"""
Processa arquivo CSV do Inter e retorna transações novas.
Args:
csv_path: Caminho para o arquivo CSV
ledger_path: Caminho para transactions.beancount
account_owner: Dono da conta (Renzo, Priscila, Amanda)
auto_commit: Se True, adiciona automaticamente ao ledger
Returns:
(mensagem, entries): Mensagem de resultado e lista de entries (se houver)
"""
try:
# Verificar se arquivo existe
if not Path(csv_path).exists():
return f"❌ Arquivo não encontrado: {csv_path}", None
# Verificar formato do arquivo
file_name = Path(csv_path).name
if not (file_name.endswith('.csv') or file_name.endswith('.CSV')):
return "❌ Arquivo deve ser .csv", None
# 1. Carregar ledger existente
entries, errors, options = load_file(ledger_path)
# 2. Extrair chaves existentes para deduplicação
existing_keys = extract_existing_keys(entries)
# 3. Parsear arquivo CSV
try:
new_entries = parse_inter_csv(csv_path, account_owner)
except Exception as e:
import traceback
return f"❌ Erro ao processar CSV:\n{str(e)}\n\n{traceback.format_exc()}", None
if not new_entries:
return "ℹ️ Nenhuma transação encontrada no arquivo.", None
total_entries = len(new_entries)
# 4. Filtrar duplicatas
filtered_entries = []
duplicates_count = 0
for entry in new_entries:
if isinstance(entry, data.Transaction):
# Criar chave para comparação
valor = abs(float(entry.postings[0].units.number))
key = f"{entry.date.isoformat()}-{entry.narration}-{valor:.2f}"
if key in existing_keys:
duplicates_count += 1
continue
filtered_entries.append(entry)
new_transactions_count = len(filtered_entries)
if new_transactions_count == 0:
return f"✅ Todas as {total_entries} transações deste extrato já estão no ledger (duplicatas).", None
# 5. Categorizar para resumo
categories = categorize_entries(filtered_entries)
unknown_count = categories.get('Expenses:Unknown', {}).get('count', 0)
# 6. Formatar saída
output_lines = []
for entry in filtered_entries:
output_lines.append(printer.format_entry(entry))
output = '\n'.join(output_lines)
# 7. Criar mensagem de resumo
summary = f"📊 *Extrato Inter CSV processado:* {file_name}\n"
summary += f"👤 Conta: {account_owner}\n\n"
summary += f"✅ {total_entries} transações encontradas\n"
summary += f"🆕 {new_transactions_count} transações novas\n"
if duplicates_count > 0:
summary += f"🔄 {duplicates_count} duplicatas removidas\n"
summary += f"\n*Categorias:*\n"
for account, stats in sorted(categories.items(), key=lambda x: -x[1]['count'])[:5]:
account_name = account.split(':')[-1]
summary += f"• {account_name}: {stats['count']} transações (R$ {abs(stats['total']):.2f})\n"
if unknown_count > 0:
summary += f"\n⚠️ {unknown_count} transações precisam de categorização manual\n"
# 8. Auto-commit se solicitado
if auto_commit:
with open(ledger_path, 'a', encoding='utf-8') as f:
f.write('\n' + output + '\n')
summary += f"\n✅ Transações adicionadas ao ledger!"
return summary, filtered_entries
summary += f"\n_Digite 'confirmar' para adicionar ao ledger ou 'cancelar' para descartar._"
return summary, filtered_entries
except Exception as e:
import traceback
return f"❌ Erro inesperado:\n{str(e)}\n\n{traceback.format_exc()}", None
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Uso: python reconcile_inter_csv.py <arquivo.csv> [--owner Priscila] [--commit]")
sys.exit(1)
csv_path = sys.argv[1]
# Parse argumentos
account_owner = 'Priscila'
auto_commit = False
for i, arg in enumerate(sys.argv[2:]):
if arg == '--owner' and i + 3 < len(sys.argv):
account_owner = sys.argv[i + 3]
elif arg == '--commit':
auto_commit = True
ledger_path = '/workspace/extra/projects/contabilidade-pessoal/transactions.beancount'
result, entries = reconcile_inter_csv(csv_path, ledger_path, account_owner, auto_commit)
print(result)
if entries and not auto_commit:
print("\n" + "="*60)
print("TRANSAÇÕES:")
print("="*60)
for entry in entries:
if isinstance(entry, data.Transaction):
print(printer.format_entry(entry))
#!/usr/bin/env python3
"""
Script de conciliação de extratos OFX via Telegram
Processa arquivos OFX diretamente do Nubank
"""
import sys
import re
from pathlib import Path
from typing import List, Set, Tuple, Optional
from beancount.loader import load_file
from beancount.parser import printer
from beancount.core import data, amount
from ofxparse import OfxParser
# Dicionário de categorização de transações
# Copiado de contabilidade-pessoal/importers/nubank/nubank_ofx_importer.py
_prefix_account_dct = {
'Desconto Antecipação': 'Income:Business:Nubank:Desconto',
'Desconto Antecipação': 'Income:Business:Nubank:Desconto', # Encoding issue
'Ederson':'Expenses:Mercado',
'Muniz':'Expenses:Mercado',
'Jim.Com':'Expenses:Mercado',
'Ricardo':'Expenses:Mercado',
'Alvino':'Expenses:Mercado',
'Banca do Silvio':'Expenses:Mercado',
'Bdm Mercado':'Expenses:Mercado',
'Jessianetobias':'Expenses:Mercado',
'Armazemda':'Expenses:Mercado',
'Karina Aparecida':'Expenses:Mercado',
'Juliocesarde':'Expenses:Mercado',
'Diego':'Expenses:Mercado',
'Mp *Thais':'Expenses:Mercado',
'Mp *Aguadecocosjc':'Expenses:Mercado',
'Lilianepaivasilva':'Expenses:Mercado',
'Teresinha':'Expenses:Mercado',
'Gabriel Figueiredo':'Expenses:Mercado',
'Cafe do Joao':'Expenses:Mercado',
'Vandersondinisda':'Expenses:Mercado',
'Jnt Legumes e Verduras':'Expenses:Mercado',
'31.529.460 Leandro':'Expenses:Mercado',
'Almir Faria':'Expenses:Mercado',
'Assai':'Expenses:Mercado',
'46.398.651 Francesca':'Expenses:Mercado',
'Paygo*Joao':'Expenses:Mercado',
'Gigiomaster':'Expenses:Mercado',
'Orgair Joaquim ':'Expenses:Mercado',
'Rigonan Paes':'Expenses:Mercado',
'Miyaguis':'Expenses:Mercado',
'Nagumo':'Expenses:Mercado',
'Andrezapaulo':'Expenses:Mercado',
'Ademirlemospontes':'Expenses:Mercado',
'Familho':'Expenses:Mercado',
'Casa de Carnes Majesta':'Expenses:Mercado',
'Diegohenrique':'Expenses:Mercado',
'P. M Furuiama Sandova':'Expenses:Mercado',
'Janeaparecida':'Expenses:Mercado',
'Deboraalexandre':'Expenses:Mercado',
'Leandroaparecido':'Expenses:Mercado',
'Paygo*Joao Aparecido':'Expenses:Mercado',
'Mp *Almir':'Expenses:Mercado',
'N S A Comercio de Alim':'Expenses:Mercado',
'Leonardomartinsde':'Expenses:Mercado',
'Rubensrosella':'Expenses:Mercado',
'Marcia da Rocha Onari':'Expenses:Mercado',
'Amandamartins':'Expenses:Mercado',
'Lojas Americanas': 'Expenses:Mercado',
'Minuto Pa': 'Expenses:Mercado',
'Pao de Acucar': 'Expenses:Mercado',
'N S A Foods': 'Expenses:Mercado',
'Supermercado Maximo': 'Expenses:Mercado',
'Emporio de Paes Integr': 'Expenses:Mercado',
'Oba Hortifruti': 'Expenses:Mercado',
'Carrefour': 'Expenses:Mercado',
'Urbanova Carnes': 'Expenses:Mercado',
'Alvino\'S Confeitaria': 'Expenses:Mercado',
'Almirfaria': 'Expenses:Mercado',
'Devid': 'Expenses:Mercado',
'Fruit City': 'Expenses:Mercado',
'Padaria e Confeitaria': 'Expenses:Mercado',
'Cleyton G. Montuan': 'Expenses:Mercado',
'la Sardina': 'Expenses:Mercado',
'Sams': 'Expenses:Mercado',
'Villa Hortifruti': 'Expenses:Mercado',
'Sebastiaocarlosde': 'Expenses:Mercado',
'Jonathanmarcelo': 'Expenses:Mercado',
'Rogeralvesdemelo': 'Expenses:Mercado',
'Marlyarnautpena': 'Expenses:Mercado',
'Fsf Comercio de Alime': 'Expenses:Mercado',
'Tentacao': 'Expenses:Mercado',
'Granovie Mercado Nat': 'Expenses:Mercado',
'Tercioluizcaldas': 'Expenses:Mercado',
'Ricardopescados': 'Expenses:Mercado',
'Villarreal': 'Expenses:Mercado',
'Alexcasaltavieira': 'Expenses:Mercado',
'Mp *Fabiotourrj': 'Expenses:Mercado',
'Tauste': 'Expenses:Mercado',
'Emporio Santa Gula': 'Expenses:Mercado',
'Guilherme da S Leal': 'Expenses:Mercado',
'Padaria': 'Expenses:Mercado',
'Aparecidadefatima': 'Expenses:Mercado',
'Carol Alimentos Aparec': 'Expenses:Mercado',
'Zelainepescados': 'Expenses:Mercado',
'David': 'Expenses:Mercado',
'Pg *Mada Comercio de R': 'Expenses:Mercado',
'Ldonizettidefaria': 'Expenses:Mercado',
'Sitio Verde': 'Expenses:Mercado',
'Josericardopralon': 'Expenses:Mercado',
'Mercadinho Saturno': 'Expenses:Mercado',
'39452045tania': 'Expenses:Mercado',
'Marcelodefaria':'Expenses:Mercado',
'Supermercado Jk':'Expenses:Mercado',
'Marcelavitoriano':'Expenses:Mercado',
'Leandro':'Expenses:Mercado',
'Diallalopesdias':'Expenses:Restaurante',
'Golden':'Expenses:Restaurante',
'Estacao Coronel':'Expenses:Restaurante',
'Fbr':'Expenses:Restaurante',
'Segretini':'Expenses:Restaurante',
'Fairy Cake':'Expenses:Restaurante',
'Empanada':'Expenses:Restaurante',
'Kfc':'Expenses:Restaurante',
'Sapore':'Expenses:Restaurante',
'Gopag S*Ponto da Parri':'Expenses:Restaurante',
'Ifd':'Expenses:Restaurante',
'Grand Parrilla':'Expenses:Restaurante',
'Bigjohnburguer':'Expenses:Restaurante',
'Sjkbarerestaurant':'Expenses:Restaurante',
'Divino Fogao':'Expenses:Restaurante',
'Elianeferreirade':'Expenses:Restaurante',
'Loja Nova - Bullguer':'Expenses:Restaurante',
'Frangoefilegrill':'Expenses:Restaurante',
'Pointdoacai':'Expenses:Restaurante',
'Nsa Comercio Alimentos':'Expenses:Restaurante',
'Churraskilo':'Expenses:Restaurante',
'Cristiane Tieko':'Expenses:Restaurante',
'Leandro Aparecido ':'Expenses:Restaurante',
'Aguadecocos':'Expenses:Restaurante',
'Fazenda da Comadre':'Expenses:Restaurante',
'Rrpgelateria':'Expenses:Restaurante',
'Chiquinho':'Expenses:Restaurante',
'Xun Barbaresco':'Expenses:Restaurante',
'Mp *Helena':'Expenses:Restaurante',
'Mara Cakes':'Expenses:Restaurante',
'Hokkaido':'Expenses:Restaurante',
'Pointdoacai"':'Expenses:Restaurante',
'Cacau Show':'Expenses:Restaurante',
'Subway':'Expenses:Restaurante',
'Toquedenut':'Expenses:Restaurante',
'Zp*Barbaresco Express':'Expenses:Restaurante',
'Pastel da Neni':'Expenses:Restaurante',
'Hoken Sushi':'Expenses:Restaurante',
'Gelateria Borelli':'Expenses:Restaurante',
'Cafe Donuts':'Expenses:Restaurante',
'Reaiche':'Expenses:Restaurante',
'Melt':'Expenses:Restaurante',
'Restaurante':'Expenses:Restaurante',
'Xttbarbaresco':'Expenses:Restaurante',
'Ternopil':'Expenses:Restaurante',
'Rei do Mate Vale Sul':'Expenses:Restaurante',
'Troina':'Expenses:Restaurante',
'Lanchonete':'Expenses:Restaurante',
'Container Burger':'Expenses:Restaurante',
'Maicon Douglas de Oliv':'Expenses:Restaurante',
'Bom Pastel':'Expenses:Restaurante',
'Lcs Vale Restaurante':'Expenses:Restaurante',
'Orient Express':'Expenses:Restaurante',
'Boali - Colinas Shop':'Expenses:Restaurante',
'Fwl Alimentos':'Expenses:Restaurante',
'Bacio Di Latte': 'Expenses:Restaurante',
'Oakberry Acai': 'Expenses:Restaurante',
'Espresso Latte': 'Expenses:Restaurante',
'Mc Donalds': 'Expenses:Restaurante',
'Ifood': 'Expenses:Restaurante',
'Zig*Fazenda da Vovo': 'Expenses:Restaurante',
'Demoiselle Restaurante': 'Expenses:Restaurante',
'Sheriff Beer Bbq': 'Expenses:Restaurante',
'Kiosque Kalango': 'Expenses:Restaurante',
'Mais1 Cafe Sp': 'Expenses:Restaurante',
'Pizza': 'Expenses:Restaurante',
'Outback': 'Expenses:Restaurante',
'Ifd*': 'Expenses:Restaurante',
'Apa Vale': 'Expenses:Restaurante',
'Chale da Pamonha': 'Expenses:Restaurante',
'Spetinho': 'Expenses:Restaurante',
'Boteco': 'Expenses:Restaurante',
'Asami Sushi': 'Expenses:Restaurante',
'Patia': 'Expenses:Restaurante',
'Barbaresco': 'Expenses:Restaurante',
'Al Badah': 'Expenses:Restaurante',
'Coco Bambu': 'Expenses:Restaurante',
'Jardim do Cafe': 'Expenses:Restaurante',
'Fuxiqueira Santos': 'Expenses:Restaurante',
'Sandra Uema Sao Vicent': 'Expenses:Restaurante',
'Capodarte Oscar': 'Expenses:Restaurante',
'Acqio*Up Urbapizza': 'Expenses:Restaurante',
'Posto 012 e Esporte': 'Expenses:Restaurante',
'Cap Quiero': 'Expenses:Restaurante',
'Toca do Mineiro':'Expenses:Restaurante',
'Lig Lig':'Expenses:Restaurante',
'Hospital Policlin':'Expenses:Familia:Saude',
'Drogasil':'Expenses:Familia:Saude',
'Byoformula Sjc':'Expenses:Familia:Saude',
'Mercadopago *Growthsu':'Expenses:Familia:Saude',
'Pg *Pp Reggia':'Expenses:Familia:Saude',
'Hosp Vivalle':'Expenses:Familia:Saude',
'Pg *Pp Terapeutica':'Expenses:Familia:Saude',
'Farma':'Expenses:Familia:Saude',
'Cipax':'Expenses:Familia:Saude',
'Original Nutri':'Expenses:Familia:Saude',
'Hakim Farma':'Expenses:Familia:Saude',
'Raia': 'Expenses:Familia:Saude',
'Elisvacinas': 'Expenses:Familia:Saude',
'Drogaria': 'Expenses:Familia:Saude',
'Pg *Medmanipulado': 'Expenses:Familia:Saude',
'Mp *Growthsupplements': 'Expenses:Familia:Saude',
'Terapeutica Farmacia': 'Expenses:Familia:Saude',
'Bp Crs':'Expenses:Transporte:Carro',
'Urentcar':'Expenses:Transporte:Carro',
'Gp Negocios Lava Rapid':'Expenses:Transporte:Carro',
'Maxx Jato':'Expenses:Transporte:Carro',
'Postosete':'Expenses:Transporte:Carro',
'Allpark':'Expenses:Transporte:Carro',
'Auto Posto':'Expenses:Transporte:Carro',
'Vallparking':'Expenses:Transporte:Carro',
'Mss Park Estacionament':'Expenses:Transporte:Carro',
'Mobiciti':'Expenses:Transporte:Carro',
'Cpark':'Expenses:Transporte:Carro',
'Ayuni Centro Automot':'Expenses:Transporte:Carro',
'NuTag':'Expenses:Transporte:Carro',
'Posto-2504-Colinas':'Expenses:Transporte:Carro',
'Mhh':'Expenses:Transporte:Carro',
'MOBILICIDADE':'Expenses:Transporte:Carro',
'Zae':'Expenses:Transporte:Carro',
'Estacionamento': 'Expenses:Transporte:Carro',
'Autoposto': 'Expenses:Transporte:Carro',
'Ancar Parking': 'Expenses:Transporte:Carro',
'Transação de NuTag': 'Expenses:Transporte:Carro',
'Executive Parking': 'Expenses:Transporte:Carro',
'Colinas Pay': 'Expenses:Transporte:Carro',
'Yelum': 'Expenses:Transporte:Carro',
'Liberty Seguros': 'Expenses:Transporte:Carro',
'Buser': 'Expenses:Transporte:Uber',
'Uber': 'Expenses:Transporte:Uber',
'Movida': 'Expenses:Transporte:Uber',
'Vale Eventos':'Expenses:Lazer',
'Mp *Camilashesebr':'Expenses:Lazer',
'Senhor Smart':'Expenses:Lazer',
'Pg *Pedido Pago Intern':'Expenses:Lazer',
'Swsjcroupase':'Expenses:Lazer',
'Nagata Shoes':'Expenses:Lazer',
'Eccere Centro':'Expenses:Lazer',
'On Mart ':'Expenses:Lazer',
'Julliekelly':'Expenses:Lazer',
'Asa*Gabriel':'Expenses:Lazer',
'Stb*Buscacenter':'Expenses:Lazer',
'Angel Presentes':'Expenses:Lazer',
'Trailler':'Expenses:Lazer',
'Du Chapeu':'Expenses:Lazer',
'Aqualanches':'Expenses:Lazer',
'Wdn':'Expenses:Lazer',
'Bazar Baby Kids':'Expenses:Lazer',
'Surbanova':'Expenses:Lazer',
'00355 Sh Colinas':'Expenses:Lazer',
'00030 Sh Centervale':'Expenses:Lazer',
'Empreendimentos Turist':'Expenses:Lazer',
'Lxj Presentes':'Expenses:Lazer',
'Reve Perfumes':'Expenses:Lazer',
'Animalia':'Expenses:Lazer',
'Glow Park':'Expenses:Lazer',
'Casa de Bolos Urbanova':'Expenses:Lazer',
'Pesqueiro':'Expenses:Lazer',
'Mio':'Expenses:Lazer',
'Hillarius':'Expenses:Lazer',
'Cinemark':'Expenses:Lazer',
'Fontanezi Franquias':'Expenses:Lazer',
'Tcketnamao':'Expenses:Lazer',
'Stafeba*Lanchdinossa':'Expenses:Lazer',
'Georgesseven':'Expenses:Lazer',
'Idealizzare':'Expenses:Lazer',
'Cervejaria':'Expenses:Lazer',
'Domis Milho':'Expenses:Lazer',
'Sorveteira':'Expenses:Lazer',
'Zig*Sea Lounge':'Expenses:Lazer',
'Dibran Turismo':'Expenses:Lazer',
'Zp *Elo7 Elo7':'Expenses:Lazer',
'Shein':'Expenses:Lazer',
'Vivara':'Expenses:Lazer',
'Shx':'Expenses:Lazer',
'Saborqueromais':'Expenses:Lazer',
'Paroquia':'Expenses:Lazer',
'Tecplant':'Expenses:Lazer',
'Jian Presentes':'Expenses:Lazer',
'King Bike':'Expenses:Lazer',
'Sp Sao Jose Campos Val':'Expenses:Lazer',
'Vmt Telecomunicacoes':'Expenses:Lazer',
'8gs Diversoes':'Expenses:Lazer',
'Oitopeia':'Expenses:Lazer',
'Pousada':'Expenses:Lazer',
'Redecine-Valesul Cine':'Expenses:Lazer',
'Sorveteria':'Expenses:Lazer',
'Mambuca Village':'Expenses:Lazer',
'Acqualanches':'Expenses:Lazer',
'Zp *Thermas':'Expenses:Lazer',
'Redecine-Valesul ':'Expenses:Lazer',
'Kopenhagen':'Expenses:Lazer',
'Lvmlojade':'Expenses:Lazer',
'Shopping Certer Valle':'Expenses:Lazer',
'Daiso':'Expenses:Lazer',
'Easyjet':'Expenses:Lazer',
'7071 Sao Jose dos Ca':'Expenses:Lazer',
'Quintal Bar Food Skate':'Expenses:Lazer',
'Lojas Renascer':'Expenses:Lazer',
'Sympla':'Expenses:Lazer',
'Toykidzmachine': 'Expenses:Lazer',
'Festou': 'Expenses:Lazer',
'Netflix': 'Expenses:Lazer',
'S J': 'Expenses:Lazer',
'Pg *Ton Maria Eduard': 'Expenses:Lazer',
'Jetshr': 'Expenses:Lazer',
'Sonia Batistela':'Expenses:Lazer',
'Pe de Moleque da Barra':'Expenses:Lazer',
'Botiques':'Expenses:Familia:Priscila',
'Mundo do Cabeleireiro':'Expenses:Familia:Priscila',
'Otica Urbanova':'Expenses:Familia:Priscila',
'Fcs Diagnostico Odont':'Expenses:Familia:Priscila',
'Julio Cesar':'Expenses:Familia:Priscila',
'Polyotica':'Expenses:Familia:Priscila',
'Mp *Dceatariesmal':'Expenses:Familia:Priscila',
'Fashion Biju':'Expenses:Familia:Priscila',
'We Pink':'Expenses:Familia:Priscila',
'Masterformula':'Expenses:Familia:Priscila',
'Toy Life':'Expenses:Familia:Priscila',
'Studio Velocity':'Expenses:Familia:Priscila',
'Jefferson Fernando':'Expenses:Familia:Priscila',
'Trhs Amis Boutique':'Expenses:Familia:Priscila',
'Polyotica Colinas':'Expenses:Familia:Priscila',
'Studio Francesca Bian':'Expenses:Familia:Priscila',
'D. Cestari Esmalteria':'Expenses:Familia:Priscila',
'Matsunos':'Expenses:Familia:Priscila',
'Urba Cell':'Expenses:Familia:Priscila',
'Loungerie':'Expenses:Familia:Priscila',
'Lojas Renner':'Expenses:Familia:Priscila',
'Youcom':'Expenses:Familia:Priscila',
'Accv Calcados':'Expenses:Familia:Priscila',
'Apple.Com': 'Expenses:Familia:Priscila',
'Lemoa Cosmeticos': 'Expenses:Familia:Priscila',
'Uni Esmalteria': 'Expenses:Familia:Priscila',
'Applecombill': 'Expenses:Familia:Priscila', # Apple subscription
'Manavet Servicos Vete':'Expenses:Cachorro',
'Popular Pet': 'Expenses:Cachorro',
'Gift Mix Papelaria':'Expenses:Familia:Filhos',
'Stb*Fofo Papelaria':'Expenses:Familia:Filhos',
'Big Jump Park Rio':'Expenses:Familia:Filhos',
'Gigio':'Expenses:Familia:Filhos',
'Livestorebrasi':'Expenses:Familia:Filhos',
'Gigafesta':'Expenses:Familia:Filhos',
'Franco Bolfarini':'Expenses:Familia:Filhos',
'Leitura Colinas':'Expenses:Familia:Filhos',
'Nutrebem':'Expenses:Familia:Filhos',
'Facilivro':'Expenses:Familia:Filhos',
'D.D.K.S Confeccoes':'Expenses:Familia:Filhos',
'Mercadolivre*Trwindus':'Expenses:Familia:Filhos',
'Kalunga':'Expenses:Familia:Filhos',
'Inovathi':'Expenses:Familia:Filhos',
'Cea Vsu 203':'Expenses:Familia:Filhos',
'Decathlon':'Expenses:Familia:Filhos',
'Fr4 Fitness': 'Expenses:Familia:Filhos',
'Rm Confeccoes': 'Expenses:Familia:Filhos',
'Gracie Barra': 'Expenses:Familia:Filhos',
'Papelaria': 'Expenses:Familia:Filhos',
'Arena': 'Expenses:Familia:Filhos',
'Tribo do Papel': 'Expenses:Familia:Filhos',
'Puket': 'Expenses:Familia:Filhos',
'Don Guillermo': 'Expenses:Familia:Filhos',
'Pbkids Brinquedos':'Expenses:Familia:Filhos',
'Baby Hair':'Expenses:Familia:Filhos',
'Ri Happy Brinquedos':'Expenses:Familia:Filhos',
'Granovie':'Expenses:Familia:Renzo',
'Prainha':'Expenses:Familia:Renzo',
'Greenn**Educacao Real':'Expenses:Familia:Renzo',
'Asaas *Tudo':'Expenses:Familia:Renzo',
'Fisia Nfs2050':'Expenses:Familia:Renzo',
'Farma Reggia Farm':'Expenses:Familia:Renzo',
'Xtrack Sjc':'Expenses:Familia:Renzo',
'Centauro':'Expenses:Familia:Renzo',
'Hering':'Expenses:Familia:Renzo',
'Fisia Nike Ecommer':'Expenses:Familia:Renzo',
'Ppro *Microsoft': 'Expenses:Familia:Renzo',
'Penaareiasports': 'Expenses:Familia:Renzo',
'Abraao Barbearia': 'Expenses:Familia:Renzo',
'Casa das Cuecas': 'Expenses:Familia:Renzo',
'Democrata': 'Expenses:Familia:Renzo',
'Centauro Ce85': 'Expenses:Familia:Renzo',
'Insider Come': 'Expenses:Familia:Renzo',
'Cyclex': 'Expenses:Familia:Renzo',
'S Stein Joalheiros': 'Expenses:Familia:Renzo',
'Quiosquepenaareia':'Expenses:Familia:Renzo',
'Pgz*Oreidamot':'Assets:Bens:Moto',
'Pagamento recebido': 'Assets:Caixa:Nubank:Renzo',
'Prudent*Apol': 'Expenses:SeguroDeVida',
'Microsoft': 'Equity:PythonPro:Estorno',
'Arranjos': 'Expenses:Casa',
'Mp *4produtos': 'Expenses:Casa',
'Emporio Verde Garden': 'Expenses:Casa',
'Hvn': 'Expenses:Casa',
'Rw Negocios Comerciai': 'Expenses:Casa',
'Condominio': 'Expenses:Casa',
'Sodimac': 'Expenses:Casa',
'Belcenter': 'Expenses:Casa',
'Havan': 'Expenses:Casa',
'Conta Vivo': 'Expenses:Casa',
'Americanas *Electrolu': 'Expenses:Casa',
'Leroy Merlin': 'Expenses:Casa',
'Construdecor S A': 'Expenses:Casa',
'Bradesco Auto Re': 'Expenses:Casa',
'Lavtop': 'Expenses:Casa',
'Varejao': 'Expenses:Casa',
'Ajuste a crédito': 'Expenses:Casa', # Crédito de ajuste vai para Casa
}
def extract_existing_keys(entries: List) -> Set[str]:
"""
Extrai chaves únicas de transações existentes para detecção de duplicatas.
Chave: "{data.isoformat()}-{nubank_id}"
"""
keys = set()
for entry in entries:
if isinstance(entry, data.Transaction):
if hasattr(entry, 'meta') and 'nubank_id' in entry.meta:
key = f"{entry.date.isoformat()}-{entry.meta['nubank_id']}"
keys.add(key)
return keys
def categorize_transaction(memo: str, prefix_dict: dict) -> str:
"""
Categoriza transação baseado no memo usando dicionário de prefixos
"""
for prefix, account in prefix_dict.items():
if memo.startswith(prefix):
return account
return 'Expenses:Unknown'
def categorize_entries(entries: List) -> dict:
"""
Agrupa transações por categoria para resumo
"""
categories = {}
for entry in entries:
if isinstance(entry, data.Transaction):
for posting in entry.postings:
account = posting.account
if account.startswith('Expenses:') or account.startswith('Income:'):
if account not in categories:
categories[account] = {'count': 0, 'total': 0}
categories[account]['count'] += 1
if posting.units:
categories[account]['total'] += float(posting.units.number)
return categories
def parse_nubank_ofx(ofx_path: str, existing_entries: List = None) -> Tuple[List, str]:
"""
Parse arquivo OFX do Nubank e retorna lista de entries beancount
Args:
ofx_path: Caminho para o arquivo OFX
existing_entries: Entries existentes do ledger (para calcular balance correto)
Returns:
(entries, account_type): Lista de entries e tipo de conta ('credit' ou 'account')
"""
entries = []
with open(ofx_path, 'rb') as f:
ofx = OfxParser.parse(f)
# Determinar tipo de conta baseado na estrutura OFX
if hasattr(ofx, 'account'):
statement = ofx.account.statement
# Detectar tipo: creditcard tem routing_number None, bank account tem routing_number
if hasattr(ofx.account, 'routing_number') and ofx.account.routing_number:
account_type = 'account'
base_account = 'Assets:Caixa:Nubank:Renzo'
else:
# Cartão de crédito (ou checar se tem account_type == 'CREDITLINE')
account_type = 'credit'
base_account = 'Liabilities:Cartoes:Nubank'
else:
raise ValueError("Formato OFX não reconhecido")
# Processar transações
for idx, transaction in enumerate(statement.transactions):
meta = data.new_metadata(ofx_path, idx)
meta['nubank_id'] = transaction.id
# Categorizar transação
if account_type == 'credit':
expense_account = categorize_transaction(transaction.memo, _prefix_account_dct)
# Criar transação de cartão de crédito
txn = data.Transaction(
meta,
transaction.date.date(),
'*',
None,
transaction.memo,
data.EMPTY_SET,
data.EMPTY_SET,
[
data.Posting(
'Liabilities:Cartoes:Nubank',
amount.Amount(transaction.amount, 'BRL'),
None, None, None, None
),
data.Posting(
expense_account,
amount.Amount(-transaction.amount, 'BRL'),
None, None, None, None
),
]
)
else:
# Conta corrente - categorizar baseado em patterns
if any(pattern.search(transaction.memo) for pattern in _renzo_memo_patterns):
contra_account = 'Expenses:Unknown'
else:
contra_account = 'Expenses:Unknown'
txn = data.Transaction(
meta,
transaction.date.date(),
'*',
None,
transaction.memo,
data.EMPTY_SET,
data.EMPTY_SET,
[
data.Posting(
base_account,
amount.Amount(transaction.amount, 'BRL'),
None, None, None, None
),
data.Posting(
contra_account,
amount.Amount(-transaction.amount, 'BRL'),
None, None, None, None
),
]
)
entries.append(txn)
# NÃO adicionar balance entry automaticamente
# O saldo do OFX é apenas do mês, não inclui saldo anterior
# Para cartões de crédito, o saldo do OFX não considera faturas passadas
return entries, account_type
def reconcile_ofx(ofx_path: str, ledger_path: str, auto_commit: bool = False) -> Tuple[str, Optional[List]]:
"""
Processa arquivo OFX e retorna transações novas.
Args:
ofx_path: Caminho para o arquivo OFX
ledger_path: Caminho para transactions.beancount
auto_commit: Se True, adiciona automaticamente ao ledger
Returns:
(mensagem, entries): Mensagem de resultado e lista de entries (se houver)
"""
try:
# Verificar se arquivo existe
if not Path(ofx_path).exists():
return f"❌ Arquivo não encontrado: {ofx_path}", None
# Verificar formato do arquivo
file_name = Path(ofx_path).name
if not (file_name.endswith('.ofx') or file_name.endswith('.OFX')):
return "❌ Arquivo deve ser .ofx", None
# 1. Carregar ledger existente
entries, errors, options = load_file(ledger_path)
# Ignorar erros de validação do ledger existente - não afetam a conciliação
# if errors:
# error_msgs = [str(e) for e in errors[:3]]
# return f"⚠️ Avisos ao carregar ledger:\n" + "\n".join(error_msgs), None
# 2. Extrair chaves existentes para deduplicação
existing_keys = extract_existing_keys(entries)
# 3. Parsear arquivo OFX
try:
new_entries, account_type = parse_nubank_ofx(ofx_path, entries)
except Exception as e:
return f"❌ Erro ao processar OFX:\n{str(e)}", None
if not new_entries:
return "ℹ️ Nenhuma transação encontrada no arquivo.", None
total_entries = len([e for e in new_entries if isinstance(e, data.Transaction)])
# 4. Filtrar duplicatas
filtered_entries = []
duplicates_count = 0
for entry in new_entries:
if isinstance(entry, data.Transaction):
if hasattr(entry, 'meta') and 'nubank_id' in entry.meta:
key = f"{entry.date.isoformat()}-{entry.meta['nubank_id']}"
if key in existing_keys:
duplicates_count += 1
continue
filtered_entries.append(entry)
else:
# Balance entries sempre incluir
filtered_entries.append(entry)
new_transactions_count = len([e for e in filtered_entries if isinstance(e, data.Transaction)])
if new_transactions_count == 0:
return f"✅ Todas as {total_entries} transações deste extrato já estão no ledger (duplicatas).", None
# 5. Categorizar para resumo
categories = categorize_entries(filtered_entries)
unknown_count = categories.get('Expenses:Unknown', {}).get('count', 0)
# 6. Formatar saída
output_lines = []
for entry in filtered_entries:
output_lines.append(printer.format_entry(entry))
output = '\n'.join(output_lines)
# 7. Criar mensagem de resumo
summary = f"📊 *Extrato processado:* {file_name}\n\n"
summary += f"✅ {total_entries} transações encontradas\n"
summary += f"🆕 {new_transactions_count} transações novas\n"
if duplicates_count > 0:
summary += f"🔄 {duplicates_count} duplicatas removidas\n"
summary += f"\n*Categorias:*\n"
for account, stats in sorted(categories.items(), key=lambda x: -x[1]['count'])[:5]:
account_name = account.split(':')[-1]
summary += f"• {account_name}: {stats['count']} transações (R$ {abs(stats['total']):.2f})\n"
if unknown_count > 0:
summary += f"\n⚠️ {unknown_count} transações precisam de categorização manual\n"
# 8. Auto-commit se solicitado
if auto_commit:
with open(ledger_path, 'a', encoding='utf-8') as f:
f.write('\n' + output + '\n')
summary += f"\n✅ Transações adicionadas ao ledger!"
return summary, filtered_entries
summary += f"\n_Digite 'confirmar' para adicionar ao ledger ou 'cancelar' para descartar._"
return summary, filtered_entries
except Exception as e:
import traceback
return f"❌ Erro inesperado:\n{str(e)}\n\n{traceback.format_exc()}", None
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Uso: python reconcile_ofx.py <arquivo.ofx> [--commit]")
sys.exit(1)
ofx_path = sys.argv[1]
auto_commit = '--commit' in sys.argv
ledger_path = '/workspace/extra/projects/contabilidade-pessoal/transactions.beancount'
result, entries = reconcile_ofx(ofx_path, ledger_path, auto_commit)
print(result)
if entries and not auto_commit:
print("\n" + "="*60)
print("TRANSAÇÕES:")
print("="*60)
for entry in entries:
if isinstance(entry, data.Transaction):
print(printer.format_entry(entry))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment