ViniciusKhan commited on
Commit
fa8f34a
·
1 Parent(s): 29f5134

Add libsql dependency

Browse files
Files changed (1) hide show
  1. server.py +425 -79
server.py CHANGED
@@ -4,9 +4,11 @@ Backend FinanceAI (SQLite/Turso + Groq)
4
  - Endpoints de gráficos (temporal e por categoria)
5
  - Módulo de Cartão de Crédito: cartões, compras, fatura (YYYY-MM) e pagamento
6
  - Relatórios: exportação por competência (normal + cartão)
7
- - IMPORTANTE (evitar dupla contagem):
 
 
8
  * Compras no cartão contam como DESPESA na competência da fatura (invoice_ym).
9
- * Pagamento da fatura é movimento de CAIXA (saída da conta), mas NÃO é despesa “real” (é quitação de passivo).
10
  => por padrão, os endpoints de despesas EXCLUEM a categoria de pagamento de fatura.
11
 
12
  Como rodar:
@@ -29,6 +31,7 @@ Como rodar:
29
 
30
  # CORS
31
  CORS_ORIGINS="http://127.0.0.1:5500,http://localhost:5500"
 
32
  5) uvicorn server:app --reload --port 8000
33
  """
34
 
@@ -41,8 +44,9 @@ import os
41
  import sqlite3
42
  import urllib.error
43
  import urllib.request
44
- from contextlib import contextmanager
45
  from datetime import datetime
 
46
  from typing import Any, Dict, Literal, Optional
47
 
48
  from dotenv import load_dotenv
@@ -50,7 +54,6 @@ from fastapi import FastAPI, HTTPException
50
  from fastapi.middleware.cors import CORSMiddleware
51
  from fastapi.responses import StreamingResponse
52
  from pydantic import BaseModel, Field
53
- from contextlib import asynccontextmanager
54
 
55
  # ============================================================
56
  # Env
@@ -219,10 +222,7 @@ def q_scalar(conn: Any, sql: str, params: tuple = (), default: float = 0.0) -> f
219
 
220
 
221
  def _maybe_sync(conn: Any) -> None:
222
- """
223
- Se for libSQL, tenta sincronizar sem quebrar o fluxo.
224
- Observação: em ambiente local puro (sqlite), não faz nada.
225
- """
226
  if not USE_TURSO:
227
  return
228
  try:
@@ -263,7 +263,6 @@ def get_conn():
263
  conn = sqlite3.connect(DB_PATH, check_same_thread=False)
264
  conn.row_factory = sqlite3.Row
265
 
266
- # Garantir FKs no SQLite/libSQL
267
  try:
268
  conn.execute("PRAGMA foreign_keys = ON;")
269
  except Exception:
@@ -356,6 +355,15 @@ def init_db() -> None:
356
  conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_invoice ON card_purchases(card_id, invoice_ym);")
357
  conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_date ON card_purchases(purchase_date);")
358
 
 
 
 
 
 
 
 
 
 
359
  conn.commit()
360
  _maybe_sync(conn)
361
 
@@ -371,7 +379,6 @@ def seed_defaults() -> None:
371
  ("Carteira", "Pessoal", "carteira", now_iso()),
372
  )
373
 
374
- # Categorias base (inclui bucket do cartão e categoria de pagamento)
375
  if cat_count == 0:
376
  base = [
377
  "Alimentação",
@@ -393,13 +400,12 @@ def seed_defaults() -> None:
393
 
394
 
395
  # ============================================================
396
- # Lifespan (substitui on_event: startup/shutdown)
397
  # ============================================================
398
  @asynccontextmanager
399
  async def lifespan(app: FastAPI):
400
  init_db()
401
  seed_defaults()
402
- # Sincroniza 1x na subida (se for Turso/libSQL)
403
  if USE_TURSO:
404
  try:
405
  with db() as conn:
@@ -407,10 +413,9 @@ async def lifespan(app: FastAPI):
407
  except Exception:
408
  pass
409
  yield
410
- # shutdown: nada a fazer (conexões são por-request)
411
 
412
 
413
- app = FastAPI(title="FinanceAI API", version="1.5.0", lifespan=lifespan)
414
 
415
  app.add_middleware(
416
  CORSMiddleware,
@@ -546,6 +551,28 @@ class MonthlyReportOut(BaseModel):
546
  card_purchases: list[Dict[str, Any]]
547
 
548
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  # ============================================================
550
  # Healthcheck
551
  # ============================================================
@@ -611,7 +638,6 @@ def list_categories():
611
 
612
  @app.post("/categories", response_model=CategoryOut)
613
  def create_category(payload: CategoryIn):
614
- # Protege nomes reservados (opcional; evita bagunça semântica)
615
  if payload.name.strip() == "":
616
  raise HTTPException(status_code=422, detail="Nome de categoria inválido.")
617
 
@@ -641,7 +667,6 @@ def delete_category(category_id: int):
641
  if not row:
642
  raise HTTPException(status_code=404, detail="Categoria não encontrada.")
643
 
644
- # Evita deletar a categoria “contábil” do pagamento do cartão
645
  if row["name"] == CATEGORY_CARD_PAYMENT:
646
  raise HTTPException(status_code=409, detail="Categoria reservada. Não pode ser removida.")
647
 
@@ -659,10 +684,6 @@ def delete_category(category_id: int):
659
  # ============================================================
660
  @app.get("/transactions", response_model=list[TransactionOut])
661
  def list_transactions(year: Optional[int] = None, month: Optional[int] = None, limit: int = 200):
662
- """
663
- Retorna transações (caixa).
664
- Observação: pagamentos de fatura são transações de caixa e ficam aqui.
665
- """
666
  if limit < 1 or limit > 2000:
667
  raise HTTPException(status_code=422, detail="limit deve estar entre 1 e 2000.")
668
 
@@ -682,13 +703,6 @@ def list_transactions(year: Optional[int] = None, month: Optional[int] = None, l
682
 
683
  @app.get("/transactions/combined")
684
  def list_transactions_combined(year: int, month: int, limit: int = 2000, order: Literal["asc", "desc"] = "desc"):
685
- """
686
- Extrato “combinado” (competência):
687
- - cash: transactions do mês (YYYY-MM)
688
- - card: compras cujo invoice_ym = YYYY-MM
689
-
690
- Retorno unificado com campo 'source' ('cash'|'card').
691
- """
692
  if month < 1 or month > 12:
693
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
694
  if limit < 1 or limit > 5000:
@@ -740,8 +754,6 @@ def list_transactions_combined(year: int, month: int, limit: int = 2000, order:
740
  )
741
 
742
  rows = cash + card
743
-
744
- # Ordenação previsível (front costuma querer desc)
745
  rows.sort(key=lambda r: (r.get("date") or "", int(r.get("id") or 0)), reverse=(order == "desc"))
746
  return rows[:limit]
747
 
@@ -795,11 +807,6 @@ def delete_transaction(tx_id: int):
795
  # ============================================================
796
  @app.get("/summary", response_model=SummaryOut)
797
  def get_summary(year: int, month: int, exclude_card_payments: bool = True):
798
- """
799
- Sumário de CAIXA (transactions).
800
- Por padrão, exclui pagamentos de fatura da categoria CATEGORY_CARD_PAYMENT,
801
- porque isso não é “despesa real”, é quitação de passivo.
802
- """
803
  if month < 1 or month > 12:
804
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
805
 
@@ -849,13 +856,6 @@ def get_summary(year: int, month: int, exclude_card_payments: bool = True):
849
 
850
  @app.get("/summary/combined", response_model=CombinedSummaryOut)
851
  def get_summary_combined(year: int, month: int):
852
- """
853
- Sumário por competência (cartão por invoice_ym):
854
- - income: receitas em transactions
855
- - expense_cash: despesas em transactions (EXCLUINDO pagamento de fatura)
856
- - expense_card: soma das compras em card_purchases com invoice_ym=YYYY-MM
857
- - expense_total = expense_cash + expense_card
858
- """
859
  if month < 1 or month > 12:
860
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
861
  ym = f"{year:04d}-{month:02d}"
@@ -909,10 +909,6 @@ def get_summary_combined(year: int, month: int):
909
  # ============================================================
910
  @app.get("/charts/timeseries")
911
  def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
912
- """
913
- Agregação diária do mês: income/expense por dia (transactions).
914
- Por padrão, exclui pagamentos de fatura da categoria CATEGORY_CARD_PAYMENT.
915
- """
916
  if month < 1 or month > 12:
917
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
918
 
@@ -953,10 +949,6 @@ def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
953
 
954
  @app.get("/charts/categories")
955
  def chart_categories(year: int, month: int, tx_type: TxType = "expense", exclude_card_payments: bool = True):
956
- """
957
- Total por categoria no mês (transactions).
958
- Se tx_type='expense', por padrão exclui pagamentos de fatura (CATEGORY_CARD_PAYMENT).
959
- """
960
  if month < 1 or month > 12:
961
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
962
  if tx_type not in ("income", "expense"):
@@ -1042,7 +1034,6 @@ def delete_card(card_id: int):
1042
 
1043
  @app.get("/cards/{card_id}/invoices")
1044
  def list_card_invoices(card_id: int):
1045
- """Lista competências (invoice_ym) existentes para um cartão."""
1046
  with db() as conn:
1047
  card = q_one(conn, "SELECT id FROM credit_cards WHERE id=?", (card_id,))
1048
  if not card:
@@ -1134,7 +1125,6 @@ def create_card_purchase(payload: CardPurchaseIn):
1134
 
1135
  @app.patch("/cards/purchases/{purchase_id}", response_model=dict)
1136
  def patch_card_purchase(purchase_id: int, payload: CardPurchasePatch):
1137
- """Atualiza status de uma compra (pending/paid)."""
1138
  if payload.status not in ("pending", "paid"):
1139
  raise HTTPException(status_code=422, detail="status inválido.")
1140
 
@@ -1206,12 +1196,6 @@ def invoice_summary(card_id: int, invoice_ym: str):
1206
 
1207
  @app.post("/cards/pay-invoice")
1208
  def pay_invoice(payload: PayInvoiceIn):
1209
- """
1210
- Paga uma fatura:
1211
- 1) marca compras pendentes como pagas
1212
- 2) cria transação de CAIXA para baixar o saldo da conta
1213
- - categoria: CATEGORY_CARD_PAYMENT (por padrão, NÃO entra como “despesa real” nos somatórios)
1214
- """
1215
  parse_ym(payload.invoice_ym)
1216
  parse_date_yyyy_mm_dd(payload.pay_date)
1217
 
@@ -1238,7 +1222,6 @@ def pay_invoice(payload: PayInvoiceIn):
1238
  if total <= 0:
1239
  return {"ok": True, "message": "Nada pendente para pagar nesta fatura.", "paid_total": 0.0}
1240
 
1241
- # 1) Marca compras como pagas
1242
  conn.execute(
1243
  """
1244
  UPDATE card_purchases
@@ -1248,7 +1231,6 @@ def pay_invoice(payload: PayInvoiceIn):
1248
  (now_iso(), payload.card_id, payload.invoice_ym),
1249
  )
1250
 
1251
- # 2) Transação de caixa (quitação)
1252
  desc = f"Pagamento fatura {card['name']} ({payload.invoice_ym})"
1253
  conn.execute(
1254
  """
@@ -1268,7 +1250,6 @@ def pay_invoice(payload: PayInvoiceIn):
1268
  # ============================================================
1269
  @app.get("/charts/card/categories")
1270
  def chart_card_categories(card_id: int, invoice_ym: str):
1271
- """Total por categoria na fatura (invoice_ym) de um cartão."""
1272
  parse_ym(invoice_ym)
1273
 
1274
  with db() as conn:
@@ -1291,11 +1272,6 @@ def chart_card_categories(card_id: int, invoice_ym: str):
1291
 
1292
  @app.get("/charts/combined/categories")
1293
  def chart_combined_categories(year: int, month: int):
1294
- """
1295
- Categorias de DESPESA por competência, combinando:
1296
- - transactions (expense) no mês, EXCLUINDO pagamento de fatura
1297
- - card_purchases com invoice_ym no mês
1298
- """
1299
  if month < 1 or month > 12:
1300
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
1301
  ym = f"{year:04d}-{month:02d}"
@@ -1329,11 +1305,6 @@ def chart_combined_categories(year: int, month: int):
1329
 
1330
  @app.get("/charts/combined/timeseries")
1331
  def chart_combined_timeseries(year: int, month: int):
1332
- """
1333
- Série temporal diária por competência, combinando:
1334
- - transactions (despesas) no mês, EXCLUINDO pagamento de fatura
1335
- - card_purchases (invoice_ym do mês) agrupadas por purchase_date
1336
- """
1337
  if month < 1 or month > 12:
1338
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
1339
  ym = f"{year:04d}-{month:02d}"
@@ -1415,14 +1386,6 @@ def report_monthly(year: int, month: int):
1415
 
1416
  @app.get("/reports/export")
1417
  def report_export(year: int, month: int, fmt: ExportFormat = "csv"):
1418
- """
1419
- Exporta por competência (ym):
1420
- - transactions do mês (caixa)
1421
- - card_purchases com invoice_ym do mês (cartão)
1422
-
1423
- fmt=json => retorna JSON
1424
- fmt=csv => retorna CSV (download)
1425
- """
1426
  if month < 1 or month > 12:
1427
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
1428
  ym = f"{year:04d}-{month:02d}"
@@ -1520,7 +1483,7 @@ def ask_ai(payload: AIRequest):
1520
  headers={
1521
  "Content-Type": "application/json",
1522
  "Authorization": f"Bearer {GROQ_API_KEY}",
1523
- "User-Agent": "FinanceAI/1.5.0",
1524
  },
1525
  )
1526
 
@@ -1546,3 +1509,386 @@ def ask_ai(payload: AIRequest):
1546
  answer = "Não consegui extrair a resposta do Groq. Verifique o payload retornado no servidor."
1547
 
1548
  return {"answer_md": answer}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  - Endpoints de gráficos (temporal e por categoria)
5
  - Módulo de Cartão de Crédito: cartões, compras, fatura (YYYY-MM) e pagamento
6
  - Relatórios: exportação por competência (normal + cartão)
7
+ - Módulo de Projeção (ML leve): treino e previsão por competência (cash + cartão)
8
+
9
+ IMPORTANTE (evitar dupla contagem):
10
  * Compras no cartão contam como DESPESA na competência da fatura (invoice_ym).
11
+ * Pagamento da fatura é movimento de CAIXA (saída da conta), mas NÃO é despesa “real” (quitação de passivo).
12
  => por padrão, os endpoints de despesas EXCLUEM a categoria de pagamento de fatura.
13
 
14
  Como rodar:
 
31
 
32
  # CORS
33
  CORS_ORIGINS="http://127.0.0.1:5500,http://localhost:5500"
34
+
35
  5) uvicorn server:app --reload --port 8000
36
  """
37
 
 
44
  import sqlite3
45
  import urllib.error
46
  import urllib.request
47
+ from contextlib import contextmanager, asynccontextmanager
48
  from datetime import datetime
49
+ from math import sin, cos, pi
50
  from typing import Any, Dict, Literal, Optional
51
 
52
  from dotenv import load_dotenv
 
54
  from fastapi.middleware.cors import CORSMiddleware
55
  from fastapi.responses import StreamingResponse
56
  from pydantic import BaseModel, Field
 
57
 
58
  # ============================================================
59
  # Env
 
222
 
223
 
224
  def _maybe_sync(conn: Any) -> None:
225
+ """Se for libSQL, tenta sincronizar sem quebrar o fluxo."""
 
 
 
226
  if not USE_TURSO:
227
  return
228
  try:
 
263
  conn = sqlite3.connect(DB_PATH, check_same_thread=False)
264
  conn.row_factory = sqlite3.Row
265
 
 
266
  try:
267
  conn.execute("PRAGMA foreign_keys = ON;")
268
  except Exception:
 
355
  conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_invoice ON card_purchases(card_id, invoice_ym);")
356
  conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_date ON card_purchases(purchase_date);")
357
 
358
+ # ---- ML models (forecast) ----
359
+ conn.execute("""
360
+ CREATE TABLE IF NOT EXISTS ml_models (
361
+ name TEXT PRIMARY KEY,
362
+ trained_at TEXT NOT NULL,
363
+ payload_json TEXT NOT NULL
364
+ );
365
+ """)
366
+
367
  conn.commit()
368
  _maybe_sync(conn)
369
 
 
379
  ("Carteira", "Pessoal", "carteira", now_iso()),
380
  )
381
 
 
382
  if cat_count == 0:
383
  base = [
384
  "Alimentação",
 
400
 
401
 
402
  # ============================================================
403
+ # Lifespan
404
  # ============================================================
405
  @asynccontextmanager
406
  async def lifespan(app: FastAPI):
407
  init_db()
408
  seed_defaults()
 
409
  if USE_TURSO:
410
  try:
411
  with db() as conn:
 
413
  except Exception:
414
  pass
415
  yield
 
416
 
417
 
418
+ app = FastAPI(title="FinanceAI API", version="1.6.0", lifespan=lifespan)
419
 
420
  app.add_middleware(
421
  CORSMiddleware,
 
551
  card_purchases: list[Dict[str, Any]]
552
 
553
 
554
+ # -------- Forecast (ML leve) --------
555
+ class ForecastTrainOut(BaseModel):
556
+ ok: bool
557
+ basis: str
558
+ trained_at: str
559
+ n_months: int
560
+ start_ym: str
561
+ end_ym: str
562
+ metrics: Dict[str, Any]
563
+
564
+
565
+ class ForecastOut(BaseModel):
566
+ ok: bool
567
+ basis: str
568
+ horizon: int
569
+ trained_at: Optional[str]
570
+ history: list[Dict[str, Any]]
571
+ predictions: list[Dict[str, Any]]
572
+ metrics: Dict[str, Any]
573
+ note: str
574
+
575
+
576
  # ============================================================
577
  # Healthcheck
578
  # ============================================================
 
638
 
639
  @app.post("/categories", response_model=CategoryOut)
640
  def create_category(payload: CategoryIn):
 
641
  if payload.name.strip() == "":
642
  raise HTTPException(status_code=422, detail="Nome de categoria inválido.")
643
 
 
667
  if not row:
668
  raise HTTPException(status_code=404, detail="Categoria não encontrada.")
669
 
 
670
  if row["name"] == CATEGORY_CARD_PAYMENT:
671
  raise HTTPException(status_code=409, detail="Categoria reservada. Não pode ser removida.")
672
 
 
684
  # ============================================================
685
  @app.get("/transactions", response_model=list[TransactionOut])
686
  def list_transactions(year: Optional[int] = None, month: Optional[int] = None, limit: int = 200):
 
 
 
 
687
  if limit < 1 or limit > 2000:
688
  raise HTTPException(status_code=422, detail="limit deve estar entre 1 e 2000.")
689
 
 
703
 
704
  @app.get("/transactions/combined")
705
  def list_transactions_combined(year: int, month: int, limit: int = 2000, order: Literal["asc", "desc"] = "desc"):
 
 
 
 
 
 
 
706
  if month < 1 or month > 12:
707
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
708
  if limit < 1 or limit > 5000:
 
754
  )
755
 
756
  rows = cash + card
 
 
757
  rows.sort(key=lambda r: (r.get("date") or "", int(r.get("id") or 0)), reverse=(order == "desc"))
758
  return rows[:limit]
759
 
 
807
  # ============================================================
808
  @app.get("/summary", response_model=SummaryOut)
809
  def get_summary(year: int, month: int, exclude_card_payments: bool = True):
 
 
 
 
 
810
  if month < 1 or month > 12:
811
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
812
 
 
856
 
857
  @app.get("/summary/combined", response_model=CombinedSummaryOut)
858
  def get_summary_combined(year: int, month: int):
 
 
 
 
 
 
 
859
  if month < 1 or month > 12:
860
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
861
  ym = f"{year:04d}-{month:02d}"
 
909
  # ============================================================
910
  @app.get("/charts/timeseries")
911
  def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
 
 
 
 
912
  if month < 1 or month > 12:
913
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
914
 
 
949
 
950
  @app.get("/charts/categories")
951
  def chart_categories(year: int, month: int, tx_type: TxType = "expense", exclude_card_payments: bool = True):
 
 
 
 
952
  if month < 1 or month > 12:
953
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
954
  if tx_type not in ("income", "expense"):
 
1034
 
1035
  @app.get("/cards/{card_id}/invoices")
1036
  def list_card_invoices(card_id: int):
 
1037
  with db() as conn:
1038
  card = q_one(conn, "SELECT id FROM credit_cards WHERE id=?", (card_id,))
1039
  if not card:
 
1125
 
1126
  @app.patch("/cards/purchases/{purchase_id}", response_model=dict)
1127
  def patch_card_purchase(purchase_id: int, payload: CardPurchasePatch):
 
1128
  if payload.status not in ("pending", "paid"):
1129
  raise HTTPException(status_code=422, detail="status inválido.")
1130
 
 
1196
 
1197
  @app.post("/cards/pay-invoice")
1198
  def pay_invoice(payload: PayInvoiceIn):
 
 
 
 
 
 
1199
  parse_ym(payload.invoice_ym)
1200
  parse_date_yyyy_mm_dd(payload.pay_date)
1201
 
 
1222
  if total <= 0:
1223
  return {"ok": True, "message": "Nada pendente para pagar nesta fatura.", "paid_total": 0.0}
1224
 
 
1225
  conn.execute(
1226
  """
1227
  UPDATE card_purchases
 
1231
  (now_iso(), payload.card_id, payload.invoice_ym),
1232
  )
1233
 
 
1234
  desc = f"Pagamento fatura {card['name']} ({payload.invoice_ym})"
1235
  conn.execute(
1236
  """
 
1250
  # ============================================================
1251
  @app.get("/charts/card/categories")
1252
  def chart_card_categories(card_id: int, invoice_ym: str):
 
1253
  parse_ym(invoice_ym)
1254
 
1255
  with db() as conn:
 
1272
 
1273
  @app.get("/charts/combined/categories")
1274
  def chart_combined_categories(year: int, month: int):
 
 
 
 
 
1275
  if month < 1 or month > 12:
1276
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
1277
  ym = f"{year:04d}-{month:02d}"
 
1305
 
1306
  @app.get("/charts/combined/timeseries")
1307
  def chart_combined_timeseries(year: int, month: int):
 
 
 
 
 
1308
  if month < 1 or month > 12:
1309
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
1310
  ym = f"{year:04d}-{month:02d}"
 
1386
 
1387
  @app.get("/reports/export")
1388
  def report_export(year: int, month: int, fmt: ExportFormat = "csv"):
 
 
 
 
 
 
 
 
1389
  if month < 1 or month > 12:
1390
  raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
1391
  ym = f"{year:04d}-{month:02d}"
 
1483
  headers={
1484
  "Content-Type": "application/json",
1485
  "Authorization": f"Bearer {GROQ_API_KEY}",
1486
+ "User-Agent": "FinanceAI/1.6.0",
1487
  },
1488
  )
1489
 
 
1509
  answer = "Não consegui extrair a resposta do Groq. Verifique o payload retornado no servidor."
1510
 
1511
  return {"answer_md": answer}
1512
+
1513
+
1514
+ # ============================================================
1515
+ # FORECAST (ML leve) — Ridge simples + lags (competência)
1516
+ # ============================================================
1517
+ try:
1518
+ import numpy as np
1519
+ except Exception:
1520
+ np = None
1521
+
1522
+
1523
+ def ym_from_year_month(year: int, month: int) -> str:
1524
+ return f"{year:04d}-{month:02d}"
1525
+
1526
+
1527
+ def ym_to_year_month(ym: str) -> tuple[int, int]:
1528
+ parse_ym(ym)
1529
+ y, m = ym.split("-")
1530
+ return int(y), int(m)
1531
+
1532
+
1533
+ def month_seq(start_ym: str, end_ym: str) -> list[str]:
1534
+ sy, sm = ym_to_year_month(start_ym)
1535
+ ey, em = ym_to_year_month(end_ym)
1536
+ out = []
1537
+ y, m = sy, sm
1538
+ while (y < ey) or (y == ey and m <= em):
1539
+ out.append(ym_from_year_month(y, m))
1540
+ y, m = add_months(y, m, 1)
1541
+ return out
1542
+
1543
+
1544
+ def get_min_max_ym(conn: Any) -> Optional[tuple[str, str]]:
1545
+ r1 = q_one(conn, "SELECT MIN(substr(date,1,7)) AS mn, MAX(substr(date,1,7)) AS mx FROM transactions")
1546
+ r2 = q_one(conn, "SELECT MIN(invoice_ym) AS mn, MAX(invoice_ym) AS mx FROM card_purchases")
1547
+
1548
+ mins = [x for x in [(r1 or {}).get("mn"), (r2 or {}).get("mn")] if x]
1549
+ maxs = [x for x in [(r1 or {}).get("mx"), (r2 or {}).get("mx")] if x]
1550
+ if not mins or not maxs:
1551
+ return None
1552
+
1553
+ return min(mins), max(maxs)
1554
+
1555
+
1556
+ def build_monthly_series_competencia(conn: Any) -> list[dict]:
1557
+ mm = get_min_max_ym(conn)
1558
+ if not mm:
1559
+ return []
1560
+
1561
+ start_ym, end_ym = mm
1562
+ yms = month_seq(start_ym, end_ym)
1563
+
1564
+ series = []
1565
+ for ym in yms:
1566
+ income = q_scalar(
1567
+ conn,
1568
+ """
1569
+ SELECT SUM(amount) AS s
1570
+ FROM transactions
1571
+ WHERE substr(date,1,7)=? AND type='income'
1572
+ """,
1573
+ (ym,),
1574
+ default=0.0,
1575
+ )
1576
+
1577
+ expense_cash_real = q_scalar(
1578
+ conn,
1579
+ """
1580
+ SELECT SUM(amount) AS s
1581
+ FROM transactions
1582
+ WHERE substr(date,1,7)=?
1583
+ AND type='expense'
1584
+ AND category<>?
1585
+ """,
1586
+ (ym, CATEGORY_CARD_PAYMENT),
1587
+ default=0.0,
1588
+ )
1589
+
1590
+ expense_card = q_scalar(
1591
+ conn,
1592
+ """
1593
+ SELECT SUM(amount) AS s
1594
+ FROM card_purchases
1595
+ WHERE invoice_ym=?
1596
+ """,
1597
+ (ym,),
1598
+ default=0.0,
1599
+ )
1600
+
1601
+ expense_total = float(expense_cash_real) + float(expense_card)
1602
+ balance = float(income) - expense_total
1603
+
1604
+ series.append({
1605
+ "ym": ym,
1606
+ "income": float(income),
1607
+ "expense_cash_real": float(expense_cash_real),
1608
+ "expense_card": float(expense_card),
1609
+ "expense_total": float(expense_total),
1610
+ "balance": float(balance),
1611
+ })
1612
+
1613
+ return series
1614
+
1615
+
1616
+ def ridge_fit(X: "np.ndarray", y: "np.ndarray", alpha: float = 1.0) -> tuple["np.ndarray", float]:
1617
+ x_mean = X.mean(axis=0)
1618
+ y_mean = y.mean()
1619
+
1620
+ Xc = X - x_mean
1621
+ yc = y - y_mean
1622
+
1623
+ n_features = Xc.shape[1]
1624
+ A = Xc.T @ Xc + alpha * np.eye(n_features)
1625
+ b = Xc.T @ yc
1626
+ coef = np.linalg.solve(A, b)
1627
+
1628
+ intercept = y_mean - float(x_mean @ coef)
1629
+ return coef, float(intercept)
1630
+
1631
+
1632
+ def ridge_predict(X: "np.ndarray", coef: "np.ndarray", intercept: float) -> "np.ndarray":
1633
+ return X @ coef + intercept
1634
+
1635
+
1636
+ def make_time_feats(ym: str, t_idx: int) -> list[float]:
1637
+ _, m = ym_to_year_month(ym)
1638
+ ang = 2.0 * pi * (float(m) / 12.0)
1639
+ return [float(t_idx), float(m), sin(ang), cos(ang)]
1640
+
1641
+
1642
+ def make_supervised(series_vals: list[float], yms: list[str], lags: int = 6) -> tuple[list[list[float]], list[float], list[str]]:
1643
+ X, y, out_yms = [], [], []
1644
+ for i in range(lags, len(series_vals)):
1645
+ lag_feats = [series_vals[i - k] for k in range(1, lags + 1)]
1646
+ ma3 = sum(series_vals[i-3:i]) / 3.0 if i >= 3 else series_vals[i-1]
1647
+ ma6 = sum(series_vals[i-6:i]) / 6.0 if i >= 6 else ma3
1648
+
1649
+ feats = make_time_feats(yms[i], i) + lag_feats + [ma3, ma6]
1650
+ X.append([float(v) for v in feats])
1651
+ y.append(float(series_vals[i]))
1652
+ out_yms.append(yms[i])
1653
+ return X, y, out_yms
1654
+
1655
+
1656
+ def walk_forward_metrics(y_true: list[float], y_pred: list[float]) -> dict:
1657
+ if not y_true:
1658
+ return {"mae": None, "mape": None}
1659
+ abs_err = [abs(a - b) for a, b in zip(y_true, y_pred)]
1660
+ mae = sum(abs_err) / len(abs_err)
1661
+
1662
+ mape_vals = []
1663
+ for yt, yp in zip(y_true, y_pred):
1664
+ if abs(yt) < 1e-9:
1665
+ continue
1666
+ mape_vals.append(abs(yt - yp) / abs(yt))
1667
+ mape = (sum(mape_vals) / len(mape_vals)) if mape_vals else None
1668
+ return {"mae": float(mae), "mape": (float(mape) if mape is not None else None)}
1669
+
1670
+
1671
+ def train_models_competencia(conn: Any, alpha: float = 3.0, lags: int = 6) -> dict:
1672
+ series = build_monthly_series_competencia(conn)
1673
+ if len(series) < (lags + 6):
1674
+ raise HTTPException(status_code=422, detail="Histórico insuficiente para treinar. Recomendo >= 12 meses.")
1675
+
1676
+ if np is None:
1677
+ raise HTTPException(status_code=500, detail="numpy não instalado. Adicione numpy ao requirements.txt.")
1678
+
1679
+ yms = [r["ym"] for r in series]
1680
+ income_vals = [float(r["income"]) for r in series]
1681
+ expense_vals = [float(r["expense_total"]) for r in series]
1682
+
1683
+ Xi, yi, _ = make_supervised(income_vals, yms, lags=lags)
1684
+ Xe, ye, _ = make_supervised(expense_vals, yms, lags=lags)
1685
+
1686
+ Xi_np = np.array(Xi, dtype=float)
1687
+ yi_np = np.array(yi, dtype=float)
1688
+ Xe_np = np.array(Xe, dtype=float)
1689
+ ye_np = np.array(ye, dtype=float)
1690
+
1691
+ def split_train_val(X, y, n_val=3):
1692
+ n = len(y)
1693
+ n_val = min(max(1, n_val), max(1, n - 1))
1694
+ return (X[:-n_val], y[:-n_val], X[-n_val:], y[-n_val:])
1695
+
1696
+ Xi_tr, yi_tr, Xi_va, yi_va = split_train_val(Xi_np, yi_np, n_val=3)
1697
+ Xe_tr, ye_tr, Xe_va, ye_va = split_train_val(Xe_np, ye_np, n_val=3)
1698
+
1699
+ coef_i, b_i = ridge_fit(Xi_tr, yi_tr, alpha=alpha)
1700
+ coef_e, b_e = ridge_fit(Xe_tr, ye_tr, alpha=alpha)
1701
+
1702
+ pred_i = ridge_predict(Xi_va, coef_i, b_i).tolist()
1703
+ pred_e = ridge_predict(Xe_va, coef_e, b_e).tolist()
1704
+
1705
+ m_i = walk_forward_metrics(yi_va.tolist(), pred_i)
1706
+ m_e = walk_forward_metrics(ye_va.tolist(), pred_e)
1707
+
1708
+ res_i = (ridge_predict(Xi_tr, coef_i, b_i) - yi_tr)
1709
+ res_e = (ridge_predict(Xe_tr, coef_e, b_e) - ye_tr)
1710
+ std_i = float(np.std(res_i)) if len(res_i) > 1 else 0.0
1711
+ std_e = float(np.std(res_e)) if len(res_e) > 1 else 0.0
1712
+
1713
+ payload = {
1714
+ "basis": "competencia",
1715
+ "alpha": float(alpha),
1716
+ "lags": int(lags),
1717
+ "trained_at": now_iso(),
1718
+ "start_ym": yms[0],
1719
+ "end_ym": yms[-1],
1720
+ "history": series,
1721
+ "models": {
1722
+ "income": {"coef": coef_i.tolist(), "intercept": float(b_i), "std": std_i, "metrics": m_i},
1723
+ "expense_total": {"coef": coef_e.tolist(), "intercept": float(b_e), "std": std_e, "metrics": m_e},
1724
+ },
1725
+ "feature_spec": {
1726
+ "time_feats": ["t_idx", "month_num", "sin12", "cos12"],
1727
+ "lags": [f"lag_{k}" for k in range(1, lags + 1)],
1728
+ "ma": ["ma3", "ma6"],
1729
+ }
1730
+ }
1731
+ return payload
1732
+
1733
+
1734
+ def save_model(conn: Any, name: str, payload: dict) -> None:
1735
+ conn.execute(
1736
+ """
1737
+ INSERT INTO ml_models(name, trained_at, payload_json)
1738
+ VALUES (?,?,?)
1739
+ ON CONFLICT(name) DO UPDATE SET
1740
+ trained_at=excluded.trained_at,
1741
+ payload_json=excluded.payload_json
1742
+ """,
1743
+ (name, payload.get("trained_at", now_iso()), json.dumps(payload, ensure_ascii=False)),
1744
+ )
1745
+ conn.commit()
1746
+ _maybe_sync(conn)
1747
+
1748
+
1749
+ def load_model(conn: Any, name: str) -> Optional[dict]:
1750
+ row = q_one(conn, "SELECT payload_json FROM ml_models WHERE name=?", (name,))
1751
+ if not row:
1752
+ return None
1753
+ try:
1754
+ return json.loads(row["payload_json"])
1755
+ except Exception:
1756
+ return None
1757
+
1758
+
1759
+ def forecast_next_months(payload: dict, horizon: int = 12) -> list[dict]:
1760
+ if np is None:
1761
+ raise HTTPException(status_code=500, detail="numpy não instalado. Adicione numpy ao requirements.txt.")
1762
+
1763
+ horizon = int(horizon)
1764
+ if horizon < 1 or horizon > 24:
1765
+ raise HTTPException(status_code=422, detail="horizon deve estar entre 1 e 24.")
1766
+
1767
+ hist = payload.get("history") or []
1768
+ if not hist:
1769
+ raise HTTPException(status_code=422, detail="Modelo sem histórico.")
1770
+
1771
+ lags = int(payload.get("lags", 6))
1772
+ mi = payload["models"]["income"]
1773
+ me = payload["models"]["expense_total"]
1774
+
1775
+ coef_i = np.array(mi["coef"], dtype=float)
1776
+ b_i = float(mi["intercept"])
1777
+ std_i = float(mi.get("std", 0.0))
1778
+
1779
+ coef_e = np.array(me["coef"], dtype=float)
1780
+ b_e = float(me["intercept"])
1781
+ std_e = float(me.get("std", 0.0))
1782
+
1783
+ yms = [r["ym"] for r in hist]
1784
+ inc_vals = [float(r["income"]) for r in hist]
1785
+ exp_vals = [float(r["expense_total"]) for r in hist]
1786
+
1787
+ last_ym = yms[-1]
1788
+ y, m = ym_to_year_month(last_ym)
1789
+
1790
+ preds = []
1791
+ for h in range(1, horizon + 1):
1792
+ fy, fm = add_months(y, m, h)
1793
+ fym = ym_from_year_month(fy, fm)
1794
+
1795
+ t_idx = (len(yms) - 1) + h
1796
+
1797
+ inc_lags = [inc_vals[-k] for k in range(1, lags + 1)]
1798
+ exp_lags = [exp_vals[-k] for k in range(1, lags + 1)]
1799
+
1800
+ inc_ma3 = sum(inc_vals[-3:]) / 3.0 if len(inc_vals) >= 3 else inc_vals[-1]
1801
+ inc_ma6 = sum(inc_vals[-6:]) / 6.0 if len(inc_vals) >= 6 else inc_ma3
1802
+
1803
+ exp_ma3 = sum(exp_vals[-3:]) / 3.0 if len(exp_vals) >= 3 else exp_vals[-1]
1804
+ exp_ma6 = sum(exp_vals[-6:]) / 6.0 if len(exp_vals) >= 6 else exp_ma3
1805
+
1806
+ Xi = np.array([make_time_feats(fym, t_idx) + inc_lags + [inc_ma3, inc_ma6]], dtype=float)
1807
+ Xe = np.array([make_time_feats(fym, t_idx) + exp_lags + [exp_ma3, exp_ma6]], dtype=float)
1808
+
1809
+ inc_pred = float(ridge_predict(Xi, coef_i, b_i)[0])
1810
+ exp_pred = float(ridge_predict(Xe, coef_e, b_e)[0])
1811
+
1812
+ inc_pred = max(0.0, inc_pred)
1813
+ exp_pred = max(0.0, exp_pred)
1814
+
1815
+ bal_pred = inc_pred - exp_pred
1816
+
1817
+ z = 1.28 # ~80%
1818
+ inc_low, inc_high = max(0.0, inc_pred - z * std_i), inc_pred + z * std_i
1819
+ exp_low, exp_high = max(0.0, exp_pred - z * std_e), exp_pred + z * std_e
1820
+
1821
+ preds.append({
1822
+ "ym": fym,
1823
+ "income_pred": inc_pred,
1824
+ "expense_pred": exp_pred,
1825
+ "balance_pred": bal_pred,
1826
+ "income_low": inc_low,
1827
+ "income_high": inc_high,
1828
+ "expense_low": exp_low,
1829
+ "expense_high": exp_high,
1830
+ })
1831
+
1832
+ inc_vals.append(inc_pred)
1833
+ exp_vals.append(exp_pred)
1834
+
1835
+ return preds
1836
+
1837
+
1838
+ # ============================================================
1839
+ # Forecast Endpoints
1840
+ # ============================================================
1841
+ @app.post("/forecast/train", response_model=ForecastTrainOut)
1842
+ def forecast_train(alpha: float = 3.0, lags: int = 6):
1843
+ with db() as conn:
1844
+ payload = train_models_competencia(conn, alpha=float(alpha), lags=int(lags))
1845
+ save_model(conn, "forecast_competencia_v1", payload)
1846
+
1847
+ metrics = {
1848
+ "income": payload["models"]["income"]["metrics"],
1849
+ "expense_total": payload["models"]["expense_total"]["metrics"],
1850
+ }
1851
+
1852
+ return {
1853
+ "ok": True,
1854
+ "basis": "competencia",
1855
+ "trained_at": payload["trained_at"],
1856
+ "n_months": len(payload.get("history") or []),
1857
+ "start_ym": payload["start_ym"],
1858
+ "end_ym": payload["end_ym"],
1859
+ "metrics": metrics,
1860
+ }
1861
+
1862
+
1863
+ @app.get("/forecast", response_model=ForecastOut)
1864
+ def forecast_get(horizon: int = 12):
1865
+ with db() as conn:
1866
+ payload = load_model(conn, "forecast_competencia_v1")
1867
+ if not payload:
1868
+ raise HTTPException(status_code=404, detail="Modelo não treinado. Rode POST /forecast/train.")
1869
+
1870
+ preds = forecast_next_months(payload, horizon=int(horizon))
1871
+
1872
+ hist = payload.get("history") or []
1873
+ hist_tail = hist[-24:] if len(hist) > 24 else hist
1874
+
1875
+ metrics = {
1876
+ "income": payload["models"]["income"]["metrics"],
1877
+ "expense_total": payload["models"]["expense_total"]["metrics"],
1878
+ }
1879
+
1880
+ note = (
1881
+ "Projeção por competência: despesas = (caixa sem pagamento de fatura) + (compras do cartão por invoice_ym). "
1882
+ "Pagamento de fatura afeta caixa, mas não é despesa real e não entra na projeção de despesa."
1883
+ )
1884
+
1885
+ return {
1886
+ "ok": True,
1887
+ "basis": "competencia",
1888
+ "horizon": int(horizon),
1889
+ "trained_at": payload.get("trained_at"),
1890
+ "history": hist_tail,
1891
+ "predictions": preds,
1892
+ "metrics": metrics,
1893
+ "note": note,
1894
+ }