ViniciusKhan commited on
Commit
aedf9ff
·
1 Parent(s): 7f57374

Add libsql dependency

Browse files
Files changed (1) hide show
  1. server.py +301 -9
server.py CHANGED
@@ -31,6 +31,7 @@ from ml_forecast import (
31
  # ============================================================
32
  load_dotenv()
33
 
 
34
  def _env_str(name: str, default: str = "") -> str:
35
  v = os.getenv(name)
36
  if v is None:
@@ -38,6 +39,7 @@ def _env_str(name: str, default: str = "") -> str:
38
  v = v.strip()
39
  return v if v != "" else default
40
 
 
41
  def _env_int(name: str, default: int) -> int:
42
  raw = os.getenv(name)
43
  if raw is None:
@@ -50,6 +52,7 @@ def _env_int(name: str, default: int) -> int:
50
  except Exception:
51
  return int(default)
52
 
 
53
  DB_PATH = _env_str("FINANCE_DB", "./data/finance.db")
54
 
55
  GROQ_API_KEY = _env_str("GROQ_API_KEY", "")
@@ -86,12 +89,15 @@ try:
86
  except Exception:
87
  libsql = None
88
 
 
89
  def now_iso() -> str:
90
  return datetime.utcnow().isoformat(timespec="seconds") + "Z"
91
 
 
92
  def _ensure_db_dir(db_path: str) -> None:
93
  os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
94
 
 
95
  def _maybe_sync(conn: Any) -> None:
96
  if not USE_TURSO:
97
  return
@@ -102,6 +108,7 @@ def _maybe_sync(conn: Any) -> None:
102
  except Exception:
103
  pass
104
 
 
105
  def get_conn():
106
  _ensure_db_dir(DB_PATH)
107
 
@@ -134,6 +141,7 @@ def get_conn():
134
 
135
  return conn
136
 
 
137
  @contextmanager
138
  def db():
139
  conn = get_conn()
@@ -145,6 +153,7 @@ def db():
145
  except Exception:
146
  pass
147
 
 
148
  def _rows_to_dicts(cur: Any, rows: list[Any]) -> list[dict]:
149
  if not rows:
150
  return []
@@ -153,6 +162,7 @@ def _rows_to_dicts(cur: Any, rows: list[Any]) -> list[dict]:
153
  cols = [d[0] for d in (cur.description or [])]
154
  return [dict(zip(cols, r)) for r in rows]
155
 
 
156
  def _row_to_dict(cur: Any, row: Any) -> Optional[dict]:
157
  if row is None:
158
  return None
@@ -161,22 +171,26 @@ def _row_to_dict(cur: Any, row: Any) -> Optional[dict]:
161
  cols = [d[0] for d in (cur.description or [])]
162
  return dict(zip(cols, row))
163
 
 
164
  def q_all(conn: Any, sql: str, params: tuple = ()) -> list[dict]:
165
  cur = conn.execute(sql, params)
166
  rows = cur.fetchall()
167
  return _rows_to_dicts(cur, rows)
168
 
 
169
  def q_one(conn: Any, sql: str, params: tuple = ()) -> Optional[dict]:
170
  cur = conn.execute(sql, params)
171
  row = cur.fetchone()
172
  return _row_to_dict(cur, row)
173
 
 
174
  def q_scalar(conn: Any, sql: str, params: tuple = (), default: float = 0.0) -> float:
175
  r = q_one(conn, sql, params)
176
  if not r:
177
  return float(default)
178
  return float(next(iter(r.values())) or default)
179
 
 
180
  def _lastrowid(cur: Any, conn: Any) -> int:
181
  lid = getattr(cur, "lastrowid", None)
182
  if lid is not None:
@@ -192,6 +206,7 @@ def _lastrowid(cur: Any, conn: Any) -> int:
192
  except Exception:
193
  return 0
194
 
 
195
  def _rowcount(cur: Any) -> int:
196
  rc = getattr(cur, "rowcount", None)
197
  if rc is not None:
@@ -207,6 +222,7 @@ def _rowcount(cur: Any) -> int:
207
  return 0
208
  return 0
209
 
 
210
  # ============================================================
211
  # Init DB
212
  # ============================================================
@@ -280,7 +296,7 @@ def init_db() -> None:
280
  conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_date ON card_purchases(purchase_date);")
281
  conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_cat ON card_purchases(category);")
282
 
283
- # modelos ML (serializados em JSON, com base64 de pickle quando necessário)
284
  conn.execute("""
285
  CREATE TABLE IF NOT EXISTS ml_models (
286
  name TEXT PRIMARY KEY,
@@ -292,6 +308,7 @@ def init_db() -> None:
292
  conn.commit()
293
  _maybe_sync(conn)
294
 
 
295
  def seed_defaults() -> None:
296
  with db() as conn:
297
  acc_count = int(q_scalar(conn, "SELECT COUNT(*) AS c FROM accounts", default=0))
@@ -322,6 +339,7 @@ def seed_defaults() -> None:
322
  conn.commit()
323
  _maybe_sync(conn)
324
 
 
325
  # ============================================================
326
  # Lifespan
327
  # ============================================================
@@ -331,7 +349,8 @@ async def lifespan(app: FastAPI):
331
  seed_defaults()
332
  yield
333
 
334
- app = FastAPI(title="FinanceAI API", version="1.7.0", lifespan=lifespan)
 
335
 
336
  app.add_middleware(
337
  CORSMiddleware,
@@ -349,17 +368,21 @@ class AccountIn(BaseModel):
349
  bank: str = Field(min_length=1)
350
  type: str = Field(min_length=1)
351
 
 
352
  class AccountOut(AccountIn):
353
  id: int
354
  created_at: str
355
 
 
356
  class CategoryIn(BaseModel):
357
  name: str = Field(min_length=1)
358
 
 
359
  class CategoryOut(CategoryIn):
360
  id: int
361
  created_at: str
362
 
 
363
  class TransactionIn(BaseModel):
364
  type: TxType
365
  amount: float = Field(ge=0)
@@ -368,10 +391,12 @@ class TransactionIn(BaseModel):
368
  account_id: int
369
  category: str = Field(min_length=1)
370
 
 
371
  class TransactionOut(TransactionIn):
372
  id: int
373
  created_at: str
374
 
 
375
  class SummaryOut(BaseModel):
376
  year: int
377
  month: int
@@ -380,6 +405,7 @@ class SummaryOut(BaseModel):
380
  balance: float
381
  count: int
382
 
 
383
  class CombinedSummaryOut(BaseModel):
384
  year: int
385
  month: int
@@ -391,13 +417,16 @@ class CombinedSummaryOut(BaseModel):
391
  count_cash: int
392
  count_card: int
393
 
 
394
  class AIRequest(BaseModel):
395
  question: str = Field(min_length=1)
396
  context: Dict[str, Any] = Field(default_factory=dict)
397
 
 
398
  class AIResponse(BaseModel):
399
  answer_md: str
400
 
 
401
  # Cartões
402
  class CreditCardIn(BaseModel):
403
  name: str = Field(min_length=1)
@@ -406,10 +435,12 @@ class CreditCardIn(BaseModel):
406
  due_day: int = Field(ge=1, le=31)
407
  credit_limit: float = Field(default=0, ge=0)
408
 
 
409
  class CreditCardOut(CreditCardIn):
410
  id: int
411
  created_at: str
412
 
 
413
  class CardPurchaseIn(BaseModel):
414
  card_id: int
415
  amount: float = Field(ge=0)
@@ -417,6 +448,7 @@ class CardPurchaseIn(BaseModel):
417
  category: str = Field(min_length=1)
418
  purchase_date: str = Field(min_length=10, max_length=10) # YYYY-MM-DD
419
 
 
420
  class CardPurchaseOut(CardPurchaseIn):
421
  id: int
422
  invoice_ym: str
@@ -424,9 +456,11 @@ class CardPurchaseOut(CardPurchaseIn):
424
  paid_at: Optional[str] = None
425
  created_at: str
426
 
 
427
  class CardPurchasePatch(BaseModel):
428
  status: PurchaseStatus
429
 
 
430
  class InvoiceSummaryOut(BaseModel):
431
  card_id: int
432
  invoice_ym: str
@@ -435,12 +469,14 @@ class InvoiceSummaryOut(BaseModel):
435
  paid_total: float
436
  count: int
437
 
 
438
  class PayInvoiceIn(BaseModel):
439
  card_id: int
440
  invoice_ym: str = Field(min_length=7, max_length=7)
441
  pay_date: str = Field(min_length=10, max_length=10)
442
  account_id: int
443
 
 
444
  # Relatórios
445
  class MonthlyReportOut(BaseModel):
446
  year: int
@@ -449,6 +485,7 @@ class MonthlyReportOut(BaseModel):
449
  transactions: list[Dict[str, Any]]
450
  card_purchases: list[Dict[str, Any]]
451
 
 
452
  # Forecast outputs
453
  class ForecastDailyOut(BaseModel):
454
  ok: bool
@@ -461,6 +498,7 @@ class ForecastDailyOut(BaseModel):
461
  metrics: Dict[str, Any]
462
  note: str
463
 
 
464
  class ForecastTrainOut(BaseModel):
465
  ok: bool
466
  basis: str
@@ -470,6 +508,7 @@ class ForecastTrainOut(BaseModel):
470
  end_ym: str
471
  metrics: Dict[str, Any]
472
 
 
473
  class ForecastOut(BaseModel):
474
  ok: bool
475
  basis: str
@@ -490,18 +529,21 @@ def parse_date_yyyy_mm_dd(date_str: str) -> None:
490
  except ValueError:
491
  raise HTTPException(status_code=422, detail="date inválida. Use YYYY-MM-DD.")
492
 
 
493
  def parse_ym(ym: str) -> None:
494
  try:
495
  datetime.strptime(ym, "%Y-%m")
496
  except ValueError:
497
  raise HTTPException(status_code=422, detail="valor inválido. Use YYYY-MM.")
498
 
 
499
  def add_months(year: int, month: int, delta: int) -> tuple[int, int]:
500
  m = month - 1 + delta
501
  y = year + (m // 12)
502
  m = (m % 12) + 1
503
  return y, m
504
 
 
505
  def compute_invoice_ym(purchase_date: str, closing_day: int) -> str:
506
  dt = datetime.strptime(purchase_date, "%Y-%m-%d")
507
  y, m = dt.year, dt.month
@@ -527,6 +569,7 @@ def save_model(conn: Any, name: str, payload: dict) -> None:
527
  conn.commit()
528
  _maybe_sync(conn)
529
 
 
530
  def load_model(conn: Any, name: str) -> Optional[dict]:
531
  row = q_one(conn, "SELECT payload_json FROM ml_models WHERE name=?", (name,))
532
  if not row:
@@ -542,7 +585,8 @@ def load_model(conn: Any, name: str) -> Optional[dict]:
542
  # ============================================================
543
  @app.get("/")
544
  def root():
545
- return {"name": "FinanceAI API", "version": "1.7.0", "ok": True}
 
546
 
547
  @app.get("/health")
548
  def health():
@@ -567,6 +611,7 @@ def list_accounts():
567
  with db() as conn:
568
  return q_all(conn, "SELECT * FROM accounts ORDER BY id DESC")
569
 
 
570
  @app.post("/accounts", response_model=AccountOut)
571
  def create_account(payload: AccountIn):
572
  with db() as conn:
@@ -580,6 +625,7 @@ def create_account(payload: AccountIn):
580
  row = q_one(conn, "SELECT * FROM accounts WHERE id = ?", (new_id,))
581
  return row # type: ignore[return-value]
582
 
 
583
  @app.delete("/accounts/{account_id}")
584
  def delete_account(account_id: int):
585
  with db() as conn:
@@ -599,6 +645,7 @@ def list_categories():
599
  with db() as conn:
600
  return q_all(conn, "SELECT * FROM categories ORDER BY name ASC")
601
 
 
602
  @app.post("/categories", response_model=CategoryOut)
603
  def create_category(payload: CategoryIn):
604
  if payload.name.strip() == "":
@@ -620,6 +667,7 @@ def create_category(payload: CategoryIn):
620
  row = q_one(conn, "SELECT * FROM categories WHERE id = ?", (new_id,))
621
  return row # type: ignore[return-value]
622
 
 
623
  @app.delete("/categories/{category_id}")
624
  def delete_category(category_id: int):
625
  with db() as conn:
@@ -655,6 +703,7 @@ def list_transactions(year: Optional[int] = None, month: Optional[int] = None, l
655
  )
656
  return q_all(conn, "SELECT * FROM transactions ORDER BY date DESC, id DESC LIMIT ?", (limit,))
657
 
 
658
  @app.post("/transactions", response_model=TransactionOut)
659
  def create_transaction(payload: TransactionIn):
660
  parse_date_yyyy_mm_dd(payload.date)
@@ -683,6 +732,7 @@ def create_transaction(payload: TransactionIn):
683
  row = q_one(conn, "SELECT * FROM transactions WHERE id = ?", (new_id,))
684
  return row # type: ignore[return-value]
685
 
 
686
  @app.delete("/transactions/{tx_id}")
687
  def delete_transaction(tx_id: int):
688
  with db() as conn:
@@ -694,6 +744,108 @@ def delete_transaction(tx_id: int):
694
  return {"ok": True}
695
 
696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  # ============================================================
698
  # Summary (caixa) + combinado (caixa + cartão)
699
  # ============================================================
@@ -734,7 +886,15 @@ def get_summary(year: int, month: int, exclude_card_payments: bool = True):
734
  income = float(row.get("income") or 0.0)
735
  expense = float(row.get("expense") or 0.0)
736
 
737
- return {"year": year, "month": month, "income": income, "expense": expense, "balance": income - expense, "count": int(row.get("cnt") or 0)}
 
 
 
 
 
 
 
 
738
 
739
  @app.get("/summary/combined", response_model=CombinedSummaryOut)
740
  def get_summary_combined(year: int, month: int):
@@ -786,7 +946,7 @@ def get_summary_combined(year: int, month: int):
786
 
787
 
788
  # ============================================================
789
- # Charts (mantidos)
790
  # ============================================================
791
  @app.get("/charts/timeseries")
792
  def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
@@ -824,6 +984,7 @@ def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
824
  (ym,),
825
  )
826
 
 
827
  @app.get("/charts/categories")
828
  def chart_categories(year: int, month: int, tx_type: TxType = "expense", exclude_card_payments: bool = True):
829
  if month < 1 or month > 12:
@@ -860,6 +1021,123 @@ def chart_categories(year: int, month: int, tx_type: TxType = "expense", exclude
860
  )
861
 
862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
863
  # ============================================================
864
  # Cartões
865
  # ============================================================
@@ -868,6 +1146,7 @@ def list_cards():
868
  with db() as conn:
869
  return q_all(conn, "SELECT * FROM credit_cards ORDER BY id DESC")
870
 
 
871
  @app.post("/cards", response_model=CreditCardOut)
872
  def create_card(payload: CreditCardIn):
873
  with db() as conn:
@@ -884,6 +1163,7 @@ def create_card(payload: CreditCardIn):
884
  row = q_one(conn, "SELECT * FROM credit_cards WHERE id=?", (new_id,))
885
  return row # type: ignore[return-value]
886
 
 
887
  @app.delete("/cards/{card_id}")
888
  def delete_card(card_id: int):
889
  with db() as conn:
@@ -894,6 +1174,7 @@ def delete_card(card_id: int):
894
  raise HTTPException(status_code=404, detail="Cartão não encontrado.")
895
  return {"ok": True}
896
 
 
897
  @app.get("/cards/{card_id}/invoices")
898
  def list_card_invoices(card_id: int):
899
  with db() as conn:
@@ -913,6 +1194,7 @@ def list_card_invoices(card_id: int):
913
  (card_id,),
914
  )
915
 
 
916
  @app.get("/cards/{card_id}/purchases", response_model=list[CardPurchaseOut])
917
  def list_card_purchases(card_id: int, invoice_ym: Optional[str] = None, limit: int = 500):
918
  if limit < 1 or limit > 5000:
@@ -945,6 +1227,7 @@ def list_card_purchases(card_id: int, invoice_ym: Optional[str] = None, limit: i
945
  (card_id, limit),
946
  )
947
 
 
948
  @app.post("/cards/purchases", response_model=CardPurchaseOut)
949
  def create_card_purchase(payload: CardPurchaseIn):
950
  parse_date_yyyy_mm_dd(payload.purchase_date)
@@ -966,6 +1249,7 @@ def create_card_purchase(payload: CardPurchaseIn):
966
  row = q_one(conn, "SELECT * FROM card_purchases WHERE id=?", (new_id,))
967
  return row # type: ignore[return-value]
968
 
 
969
  @app.patch("/cards/purchases/{purchase_id}", response_model=dict)
970
  def patch_card_purchase(purchase_id: int, payload: CardPurchasePatch):
971
  if payload.status not in ("pending", "paid"):
@@ -982,6 +1266,7 @@ def patch_card_purchase(purchase_id: int, payload: CardPurchasePatch):
982
  _maybe_sync(conn)
983
  return {"ok": True}
984
 
 
985
  @app.delete("/cards/purchases/{purchase_id}")
986
  def delete_card_purchase(purchase_id: int):
987
  with db() as conn:
@@ -992,6 +1277,7 @@ def delete_card_purchase(purchase_id: int):
992
  raise HTTPException(status_code=404, detail="Compra não encontrada.")
993
  return {"ok": True}
994
 
 
995
  @app.get("/cards/{card_id}/invoice-summary", response_model=InvoiceSummaryOut)
996
  def invoice_summary(card_id: int, invoice_ym: str):
997
  parse_ym(invoice_ym)
@@ -1022,6 +1308,7 @@ def invoice_summary(card_id: int, invoice_ym: str):
1022
  "count": int(row.get("cnt") or 0),
1023
  }
1024
 
 
1025
  @app.post("/cards/pay-invoice")
1026
  def pay_invoice(payload: PayInvoiceIn):
1027
  parse_ym(payload.invoice_ym)
@@ -1095,6 +1382,7 @@ def _fetch_monthly_report(conn: Any, ym: str) -> tuple[list[dict], list[dict]]:
1095
  )
1096
  return tx, cp
1097
 
 
1098
  @app.get("/reports/monthly", response_model=MonthlyReportOut)
1099
  def report_monthly(year: int, month: int):
1100
  if month < 1 or month > 12:
@@ -1104,6 +1392,7 @@ def report_monthly(year: int, month: int):
1104
  tx, cp = _fetch_monthly_report(conn, ym)
1105
  return {"year": year, "month": month, "ym": ym, "transactions": tx, "card_purchases": cp}
1106
 
 
1107
  @app.get("/reports/export")
1108
  def report_export(year: int, month: int, fmt: ExportFormat = "csv"):
1109
  if month < 1 or month > 12:
@@ -1148,7 +1437,7 @@ def ask_ai(payload: AIRequest):
1148
  url=f"{GROQ_BASE}/chat/completions",
1149
  method="POST",
1150
  data=json.dumps(body).encode("utf-8"),
1151
- headers={"Content-Type": "application/json", "Authorization": f"Bearer {GROQ_API_KEY}", "User-Agent": "FinanceAI/1.7.0"},
1152
  )
1153
  try:
1154
  with urllib.request.urlopen(req, timeout=60) as resp:
@@ -1199,6 +1488,7 @@ def _fetch_daily_income_expense(conn: Any, account_id: Optional[int]) -> list[di
1199
  (account_id,),
1200
  )
1201
 
 
1202
  @app.post("/forecast/daily/train")
1203
  def forecast_daily_train(account_id: Optional[int] = None, lags: int = 14):
1204
  with db() as conn:
@@ -1214,6 +1504,7 @@ def forecast_daily_train(account_id: Optional[int] = None, lags: int = 14):
1214
  save_model(conn, name, payload)
1215
  return {"ok": True, "trained_at": payload["trained_at"], "basis": payload["basis"], "lags": payload["lags"], "note": payload.get("note")}
1216
 
 
1217
  @app.get("/forecast/daily", response_model=ForecastDailyOut)
1218
  def forecast_daily(days: int = 7, auto_train: bool = True, lags: int = 14, account_id: Optional[int] = None):
1219
  with db() as conn:
@@ -1257,10 +1548,9 @@ def forecast_daily(days: int = 7, auto_train: bool = True, lags: int = 14, accou
1257
 
1258
 
1259
  # ============================================================
1260
- # Forecast — competência (despesa real = caixa sem pagamento + cartão por invoice_ym)
1261
  # ============================================================
1262
  def _fetch_monthly_competencia(conn: Any, account_id: Optional[int], include_card: bool) -> list[dict]:
1263
- # Descobre min/max de meses considerando caixa e (opcional) cartão
1264
  if account_id is None:
1265
  r1 = q_one(conn, "SELECT MIN(substr(date,1,7)) AS mn, MAX(substr(date,1,7)) AS mx FROM transactions")
1266
  else:
@@ -1277,7 +1567,6 @@ def _fetch_monthly_competencia(conn: Any, account_id: Optional[int], include_car
1277
 
1278
  start_ym, end_ym = min(mins), max(maxs)
1279
 
1280
- # sequência mensal
1281
  def month_seq(start_ym: str, end_ym: str):
1282
  sy, sm = start_ym.split("-")
1283
  ey, em = end_ym.split("-")
@@ -1344,6 +1633,7 @@ def _fetch_monthly_competencia(conn: Any, account_id: Optional[int], include_car
1344
 
1345
  return series
1346
 
 
1347
  @app.post("/forecast/train", response_model=ForecastTrainOut)
1348
  def forecast_train(lags: int = 6, account_id: Optional[int] = None, include_card: bool = True):
1349
  with db() as conn:
@@ -1374,6 +1664,7 @@ def forecast_train(lags: int = 6, account_id: Optional[int] = None, include_card
1374
  "metrics": metrics,
1375
  }
1376
 
 
1377
  @app.get("/forecast/status")
1378
  def forecast_status(account_id: Optional[int] = None, include_card: bool = True, min_months: int = 12, lags: int = 6):
1379
  with db() as conn:
@@ -1394,6 +1685,7 @@ def forecast_status(account_id: Optional[int] = None, include_card: bool = True,
1394
  "note": "O treino mensal exige histórico suficiente (>= lags + 6). Recomenda-se >= 12 meses.",
1395
  }
1396
 
 
1397
  @app.get("/forecast", response_model=ForecastOut)
1398
  def forecast_get(horizon: int = 12, auto_train: bool = True, lags: int = 6, account_id: Optional[int] = None, include_card: bool = True):
1399
  with db() as conn:
 
31
  # ============================================================
32
  load_dotenv()
33
 
34
+
35
  def _env_str(name: str, default: str = "") -> str:
36
  v = os.getenv(name)
37
  if v is None:
 
39
  v = v.strip()
40
  return v if v != "" else default
41
 
42
+
43
  def _env_int(name: str, default: int) -> int:
44
  raw = os.getenv(name)
45
  if raw is None:
 
52
  except Exception:
53
  return int(default)
54
 
55
+
56
  DB_PATH = _env_str("FINANCE_DB", "./data/finance.db")
57
 
58
  GROQ_API_KEY = _env_str("GROQ_API_KEY", "")
 
89
  except Exception:
90
  libsql = None
91
 
92
+
93
  def now_iso() -> str:
94
  return datetime.utcnow().isoformat(timespec="seconds") + "Z"
95
 
96
+
97
  def _ensure_db_dir(db_path: str) -> None:
98
  os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
99
 
100
+
101
  def _maybe_sync(conn: Any) -> None:
102
  if not USE_TURSO:
103
  return
 
108
  except Exception:
109
  pass
110
 
111
+
112
  def get_conn():
113
  _ensure_db_dir(DB_PATH)
114
 
 
141
 
142
  return conn
143
 
144
+
145
  @contextmanager
146
  def db():
147
  conn = get_conn()
 
153
  except Exception:
154
  pass
155
 
156
+
157
  def _rows_to_dicts(cur: Any, rows: list[Any]) -> list[dict]:
158
  if not rows:
159
  return []
 
162
  cols = [d[0] for d in (cur.description or [])]
163
  return [dict(zip(cols, r)) for r in rows]
164
 
165
+
166
  def _row_to_dict(cur: Any, row: Any) -> Optional[dict]:
167
  if row is None:
168
  return None
 
171
  cols = [d[0] for d in (cur.description or [])]
172
  return dict(zip(cols, row))
173
 
174
+
175
  def q_all(conn: Any, sql: str, params: tuple = ()) -> list[dict]:
176
  cur = conn.execute(sql, params)
177
  rows = cur.fetchall()
178
  return _rows_to_dicts(cur, rows)
179
 
180
+
181
  def q_one(conn: Any, sql: str, params: tuple = ()) -> Optional[dict]:
182
  cur = conn.execute(sql, params)
183
  row = cur.fetchone()
184
  return _row_to_dict(cur, row)
185
 
186
+
187
  def q_scalar(conn: Any, sql: str, params: tuple = (), default: float = 0.0) -> float:
188
  r = q_one(conn, sql, params)
189
  if not r:
190
  return float(default)
191
  return float(next(iter(r.values())) or default)
192
 
193
+
194
  def _lastrowid(cur: Any, conn: Any) -> int:
195
  lid = getattr(cur, "lastrowid", None)
196
  if lid is not None:
 
206
  except Exception:
207
  return 0
208
 
209
+
210
  def _rowcount(cur: Any) -> int:
211
  rc = getattr(cur, "rowcount", None)
212
  if rc is not None:
 
222
  return 0
223
  return 0
224
 
225
+
226
  # ============================================================
227
  # Init DB
228
  # ============================================================
 
296
  conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_date ON card_purchases(purchase_date);")
297
  conn.execute("CREATE INDEX IF NOT EXISTS idx_card_purchases_cat ON card_purchases(category);")
298
 
299
+ # modelos ML (serializados em JSON)
300
  conn.execute("""
301
  CREATE TABLE IF NOT EXISTS ml_models (
302
  name TEXT PRIMARY KEY,
 
308
  conn.commit()
309
  _maybe_sync(conn)
310
 
311
+
312
  def seed_defaults() -> None:
313
  with db() as conn:
314
  acc_count = int(q_scalar(conn, "SELECT COUNT(*) AS c FROM accounts", default=0))
 
339
  conn.commit()
340
  _maybe_sync(conn)
341
 
342
+
343
  # ============================================================
344
  # Lifespan
345
  # ============================================================
 
349
  seed_defaults()
350
  yield
351
 
352
+
353
+ app = FastAPI(title="FinanceAI API", version="1.7.1", lifespan=lifespan)
354
 
355
  app.add_middleware(
356
  CORSMiddleware,
 
368
  bank: str = Field(min_length=1)
369
  type: str = Field(min_length=1)
370
 
371
+
372
  class AccountOut(AccountIn):
373
  id: int
374
  created_at: str
375
 
376
+
377
  class CategoryIn(BaseModel):
378
  name: str = Field(min_length=1)
379
 
380
+
381
  class CategoryOut(CategoryIn):
382
  id: int
383
  created_at: str
384
 
385
+
386
  class TransactionIn(BaseModel):
387
  type: TxType
388
  amount: float = Field(ge=0)
 
391
  account_id: int
392
  category: str = Field(min_length=1)
393
 
394
+
395
  class TransactionOut(TransactionIn):
396
  id: int
397
  created_at: str
398
 
399
+
400
  class SummaryOut(BaseModel):
401
  year: int
402
  month: int
 
405
  balance: float
406
  count: int
407
 
408
+
409
  class CombinedSummaryOut(BaseModel):
410
  year: int
411
  month: int
 
417
  count_cash: int
418
  count_card: int
419
 
420
+
421
  class AIRequest(BaseModel):
422
  question: str = Field(min_length=1)
423
  context: Dict[str, Any] = Field(default_factory=dict)
424
 
425
+
426
  class AIResponse(BaseModel):
427
  answer_md: str
428
 
429
+
430
  # Cartões
431
  class CreditCardIn(BaseModel):
432
  name: str = Field(min_length=1)
 
435
  due_day: int = Field(ge=1, le=31)
436
  credit_limit: float = Field(default=0, ge=0)
437
 
438
+
439
  class CreditCardOut(CreditCardIn):
440
  id: int
441
  created_at: str
442
 
443
+
444
  class CardPurchaseIn(BaseModel):
445
  card_id: int
446
  amount: float = Field(ge=0)
 
448
  category: str = Field(min_length=1)
449
  purchase_date: str = Field(min_length=10, max_length=10) # YYYY-MM-DD
450
 
451
+
452
  class CardPurchaseOut(CardPurchaseIn):
453
  id: int
454
  invoice_ym: str
 
456
  paid_at: Optional[str] = None
457
  created_at: str
458
 
459
+
460
  class CardPurchasePatch(BaseModel):
461
  status: PurchaseStatus
462
 
463
+
464
  class InvoiceSummaryOut(BaseModel):
465
  card_id: int
466
  invoice_ym: str
 
469
  paid_total: float
470
  count: int
471
 
472
+
473
  class PayInvoiceIn(BaseModel):
474
  card_id: int
475
  invoice_ym: str = Field(min_length=7, max_length=7)
476
  pay_date: str = Field(min_length=10, max_length=10)
477
  account_id: int
478
 
479
+
480
  # Relatórios
481
  class MonthlyReportOut(BaseModel):
482
  year: int
 
485
  transactions: list[Dict[str, Any]]
486
  card_purchases: list[Dict[str, Any]]
487
 
488
+
489
  # Forecast outputs
490
  class ForecastDailyOut(BaseModel):
491
  ok: bool
 
498
  metrics: Dict[str, Any]
499
  note: str
500
 
501
+
502
  class ForecastTrainOut(BaseModel):
503
  ok: bool
504
  basis: str
 
508
  end_ym: str
509
  metrics: Dict[str, Any]
510
 
511
+
512
  class ForecastOut(BaseModel):
513
  ok: bool
514
  basis: str
 
529
  except ValueError:
530
  raise HTTPException(status_code=422, detail="date inválida. Use YYYY-MM-DD.")
531
 
532
+
533
  def parse_ym(ym: str) -> None:
534
  try:
535
  datetime.strptime(ym, "%Y-%m")
536
  except ValueError:
537
  raise HTTPException(status_code=422, detail="valor inválido. Use YYYY-MM.")
538
 
539
+
540
  def add_months(year: int, month: int, delta: int) -> tuple[int, int]:
541
  m = month - 1 + delta
542
  y = year + (m // 12)
543
  m = (m % 12) + 1
544
  return y, m
545
 
546
+
547
  def compute_invoice_ym(purchase_date: str, closing_day: int) -> str:
548
  dt = datetime.strptime(purchase_date, "%Y-%m-%d")
549
  y, m = dt.year, dt.month
 
569
  conn.commit()
570
  _maybe_sync(conn)
571
 
572
+
573
  def load_model(conn: Any, name: str) -> Optional[dict]:
574
  row = q_one(conn, "SELECT payload_json FROM ml_models WHERE name=?", (name,))
575
  if not row:
 
585
  # ============================================================
586
  @app.get("/")
587
  def root():
588
+ return {"name": "FinanceAI API", "version": "1.7.1", "ok": True}
589
+
590
 
591
  @app.get("/health")
592
  def health():
 
611
  with db() as conn:
612
  return q_all(conn, "SELECT * FROM accounts ORDER BY id DESC")
613
 
614
+
615
  @app.post("/accounts", response_model=AccountOut)
616
  def create_account(payload: AccountIn):
617
  with db() as conn:
 
625
  row = q_one(conn, "SELECT * FROM accounts WHERE id = ?", (new_id,))
626
  return row # type: ignore[return-value]
627
 
628
+
629
  @app.delete("/accounts/{account_id}")
630
  def delete_account(account_id: int):
631
  with db() as conn:
 
645
  with db() as conn:
646
  return q_all(conn, "SELECT * FROM categories ORDER BY name ASC")
647
 
648
+
649
  @app.post("/categories", response_model=CategoryOut)
650
  def create_category(payload: CategoryIn):
651
  if payload.name.strip() == "":
 
667
  row = q_one(conn, "SELECT * FROM categories WHERE id = ?", (new_id,))
668
  return row # type: ignore[return-value]
669
 
670
+
671
  @app.delete("/categories/{category_id}")
672
  def delete_category(category_id: int):
673
  with db() as conn:
 
703
  )
704
  return q_all(conn, "SELECT * FROM transactions ORDER BY date DESC, id DESC LIMIT ?", (limit,))
705
 
706
+
707
  @app.post("/transactions", response_model=TransactionOut)
708
  def create_transaction(payload: TransactionIn):
709
  parse_date_yyyy_mm_dd(payload.date)
 
732
  row = q_one(conn, "SELECT * FROM transactions WHERE id = ?", (new_id,))
733
  return row # type: ignore[return-value]
734
 
735
+
736
  @app.delete("/transactions/{tx_id}")
737
  def delete_transaction(tx_id: int):
738
  with db() as conn:
 
744
  return {"ok": True}
745
 
746
 
747
+ # ============================================================
748
+ # Transactions (Combined) — usado pelo frontend
749
+ # - Competência: caixa (ym) + compras cartão (invoice_ym=ym)
750
+ # - Por padrão NÃO inclui pagamento de fatura (evita dupla contagem)
751
+ # ============================================================
752
+ @app.get("/transactions/combined")
753
+ def list_transactions_combined(
754
+ year: int,
755
+ month: int,
756
+ limit: int = 5000,
757
+ include_card_payments: bool = False,
758
+ ):
759
+ if month < 1 or month > 12:
760
+ raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
761
+ if limit < 1 or limit > 20000:
762
+ raise HTTPException(status_code=422, detail="limit deve estar entre 1 e 20000.")
763
+
764
+ ym = f"{year:04d}-{month:02d}"
765
+
766
+ with db() as conn:
767
+ # Caixa
768
+ if include_card_payments:
769
+ tx = q_all(
770
+ conn,
771
+ """
772
+ SELECT id, type, amount, description, date, account_id, category, created_at
773
+ FROM transactions
774
+ WHERE substr(date,1,7)=?
775
+ ORDER BY date DESC, id DESC
776
+ LIMIT ?
777
+ """,
778
+ (ym, limit),
779
+ )
780
+ else:
781
+ tx = q_all(
782
+ conn,
783
+ """
784
+ SELECT id, type, amount, description, date, account_id, category, created_at
785
+ FROM transactions
786
+ WHERE substr(date,1,7)=?
787
+ AND NOT (type='expense' AND category=?)
788
+ ORDER BY date DESC, id DESC
789
+ LIMIT ?
790
+ """,
791
+ (ym, CATEGORY_CARD_PAYMENT, limit),
792
+ )
793
+
794
+ # Cartão (por competência)
795
+ cp = q_all(
796
+ conn,
797
+ """
798
+ SELECT id, card_id, amount, description, category, purchase_date, invoice_ym, status, paid_at, created_at
799
+ FROM card_purchases
800
+ WHERE invoice_ym=?
801
+ ORDER BY purchase_date DESC, id DESC
802
+ LIMIT ?
803
+ """,
804
+ (ym, limit),
805
+ )
806
+
807
+ combined: list[dict] = []
808
+
809
+ for t in tx:
810
+ combined.append(
811
+ {
812
+ "source": "cash",
813
+ "id": t.get("id"),
814
+ "type": t.get("type"),
815
+ "amount": float(t.get("amount") or 0.0),
816
+ "description": t.get("description") or "",
817
+ "date": t.get("date"),
818
+ "category": t.get("category") or "",
819
+ "account_id": t.get("account_id"),
820
+ "created_at": t.get("created_at"),
821
+ }
822
+ )
823
+
824
+ for p in cp:
825
+ combined.append(
826
+ {
827
+ "source": "card",
828
+ "id": p.get("id"),
829
+ "type": "expense",
830
+ "amount": float(p.get("amount") or 0.0),
831
+ "description": p.get("description") or "",
832
+ # no front ele usa t.date; aqui a data é a purchase_date
833
+ "date": p.get("purchase_date"),
834
+ "category": p.get("category") or "",
835
+ "account_id": None,
836
+ "card_id": p.get("card_id"),
837
+ "invoice_ym": p.get("invoice_ym"),
838
+ "status": p.get("status"),
839
+ "paid_at": p.get("paid_at"),
840
+ "created_at": p.get("created_at"),
841
+ }
842
+ )
843
+
844
+ # Ordenação final (igual ao front)
845
+ combined.sort(key=lambda r: (str(r.get("date") or ""), int(r.get("id") or 0)), reverse=True)
846
+ return combined[:limit]
847
+
848
+
849
  # ============================================================
850
  # Summary (caixa) + combinado (caixa + cartão)
851
  # ============================================================
 
886
  income = float(row.get("income") or 0.0)
887
  expense = float(row.get("expense") or 0.0)
888
 
889
+ return {
890
+ "year": year,
891
+ "month": month,
892
+ "income": income,
893
+ "expense": expense,
894
+ "balance": income - expense,
895
+ "count": int(row.get("cnt") or 0),
896
+ }
897
+
898
 
899
  @app.get("/summary/combined", response_model=CombinedSummaryOut)
900
  def get_summary_combined(year: int, month: int):
 
946
 
947
 
948
  # ============================================================
949
+ # Charts (caixa)
950
  # ============================================================
951
  @app.get("/charts/timeseries")
952
  def chart_timeseries(year: int, month: int, exclude_card_payments: bool = True):
 
984
  (ym,),
985
  )
986
 
987
+
988
  @app.get("/charts/categories")
989
  def chart_categories(year: int, month: int, tx_type: TxType = "expense", exclude_card_payments: bool = True):
990
  if month < 1 or month > 12:
 
1021
  )
1022
 
1023
 
1024
+ # ============================================================
1025
+ # Charts (combined) — usado pelo frontend
1026
+ # - timeseries: soma despesa real de caixa (sem pagamento) + cartão (invoice_ym), por data
1027
+ # - categories: soma por categoria (caixa real + cartão)
1028
+ # ============================================================
1029
+ @app.get("/charts/combined/timeseries")
1030
+ def chart_combined_timeseries(year: int, month: int):
1031
+ if month < 1 or month > 12:
1032
+ raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
1033
+ ym = f"{year:04d}-{month:02d}"
1034
+
1035
+ with db() as conn:
1036
+ # Caixa: receitas por dia (somente YM)
1037
+ cash_inc = q_all(
1038
+ conn,
1039
+ """
1040
+ SELECT date, SUM(amount) AS income
1041
+ FROM transactions
1042
+ WHERE substr(date,1,7)=? AND type='income'
1043
+ GROUP BY date
1044
+ """,
1045
+ (ym,),
1046
+ )
1047
+ cash_exp = q_all(
1048
+ conn,
1049
+ """
1050
+ SELECT date, SUM(amount) AS expense_cash
1051
+ FROM transactions
1052
+ WHERE substr(date,1,7)=?
1053
+ AND type='expense'
1054
+ AND category<>?
1055
+ GROUP BY date
1056
+ """,
1057
+ (ym, CATEGORY_CARD_PAYMENT),
1058
+ )
1059
+
1060
+ # Cartão: despesas por purchase_date, filtrando pela competência invoice_ym
1061
+ card_exp = q_all(
1062
+ conn,
1063
+ """
1064
+ SELECT purchase_date AS date, SUM(amount) AS expense_card
1065
+ FROM card_purchases
1066
+ WHERE invoice_ym=?
1067
+ GROUP BY purchase_date
1068
+ """,
1069
+ (ym,),
1070
+ )
1071
+
1072
+ # Merge em mapa
1073
+ m: dict[str, dict[str, float]] = {}
1074
+
1075
+ for r in cash_inc:
1076
+ d = str(r.get("date"))
1077
+ m.setdefault(d, {"income": 0.0, "expense_total": 0.0})
1078
+ m[d]["income"] += float(r.get("income") or 0.0)
1079
+
1080
+ for r in cash_exp:
1081
+ d = str(r.get("date"))
1082
+ m.setdefault(d, {"income": 0.0, "expense_total": 0.0})
1083
+ m[d]["expense_total"] += float(r.get("expense_cash") or 0.0)
1084
+
1085
+ for r in card_exp:
1086
+ d = str(r.get("date"))
1087
+ m.setdefault(d, {"income": 0.0, "expense_total": 0.0})
1088
+ m[d]["expense_total"] += float(r.get("expense_card") or 0.0)
1089
+
1090
+ out = [{"date": k, "income": v["income"], "expense_total": v["expense_total"]} for k, v in m.items()]
1091
+ out.sort(key=lambda x: str(x.get("date") or ""))
1092
+ return out
1093
+
1094
+
1095
+ @app.get("/charts/combined/categories")
1096
+ def chart_combined_categories(year: int, month: int):
1097
+ if month < 1 or month > 12:
1098
+ raise HTTPException(status_code=422, detail="month deve estar entre 1 e 12.")
1099
+ ym = f"{year:04d}-{month:02d}"
1100
+
1101
+ with db() as conn:
1102
+ cash = q_all(
1103
+ conn,
1104
+ """
1105
+ SELECT category, SUM(amount) AS total
1106
+ FROM transactions
1107
+ WHERE substr(date,1,7)=?
1108
+ AND type='expense'
1109
+ AND category<>?
1110
+ GROUP BY category
1111
+ """,
1112
+ (ym, CATEGORY_CARD_PAYMENT),
1113
+ )
1114
+
1115
+ card = q_all(
1116
+ conn,
1117
+ """
1118
+ SELECT category, SUM(amount) AS total
1119
+ FROM card_purchases
1120
+ WHERE invoice_ym=?
1121
+ GROUP BY category
1122
+ """,
1123
+ (ym,),
1124
+ )
1125
+
1126
+ agg: dict[str, float] = {}
1127
+
1128
+ for r in cash:
1129
+ c = str(r.get("category") or "Geral")
1130
+ agg[c] = agg.get(c, 0.0) + float(r.get("total") or 0.0)
1131
+
1132
+ for r in card:
1133
+ c = str(r.get("category") or "Geral")
1134
+ agg[c] = agg.get(c, 0.0) + float(r.get("total") or 0.0)
1135
+
1136
+ out = [{"category": k, "total": v} for k, v in agg.items()]
1137
+ out.sort(key=lambda x: float(x.get("total") or 0.0), reverse=True)
1138
+ return out
1139
+
1140
+
1141
  # ============================================================
1142
  # Cartões
1143
  # ============================================================
 
1146
  with db() as conn:
1147
  return q_all(conn, "SELECT * FROM credit_cards ORDER BY id DESC")
1148
 
1149
+
1150
  @app.post("/cards", response_model=CreditCardOut)
1151
  def create_card(payload: CreditCardIn):
1152
  with db() as conn:
 
1163
  row = q_one(conn, "SELECT * FROM credit_cards WHERE id=?", (new_id,))
1164
  return row # type: ignore[return-value]
1165
 
1166
+
1167
  @app.delete("/cards/{card_id}")
1168
  def delete_card(card_id: int):
1169
  with db() as conn:
 
1174
  raise HTTPException(status_code=404, detail="Cartão não encontrado.")
1175
  return {"ok": True}
1176
 
1177
+
1178
  @app.get("/cards/{card_id}/invoices")
1179
  def list_card_invoices(card_id: int):
1180
  with db() as conn:
 
1194
  (card_id,),
1195
  )
1196
 
1197
+
1198
  @app.get("/cards/{card_id}/purchases", response_model=list[CardPurchaseOut])
1199
  def list_card_purchases(card_id: int, invoice_ym: Optional[str] = None, limit: int = 500):
1200
  if limit < 1 or limit > 5000:
 
1227
  (card_id, limit),
1228
  )
1229
 
1230
+
1231
  @app.post("/cards/purchases", response_model=CardPurchaseOut)
1232
  def create_card_purchase(payload: CardPurchaseIn):
1233
  parse_date_yyyy_mm_dd(payload.purchase_date)
 
1249
  row = q_one(conn, "SELECT * FROM card_purchases WHERE id=?", (new_id,))
1250
  return row # type: ignore[return-value]
1251
 
1252
+
1253
  @app.patch("/cards/purchases/{purchase_id}", response_model=dict)
1254
  def patch_card_purchase(purchase_id: int, payload: CardPurchasePatch):
1255
  if payload.status not in ("pending", "paid"):
 
1266
  _maybe_sync(conn)
1267
  return {"ok": True}
1268
 
1269
+
1270
  @app.delete("/cards/purchases/{purchase_id}")
1271
  def delete_card_purchase(purchase_id: int):
1272
  with db() as conn:
 
1277
  raise HTTPException(status_code=404, detail="Compra não encontrada.")
1278
  return {"ok": True}
1279
 
1280
+
1281
  @app.get("/cards/{card_id}/invoice-summary", response_model=InvoiceSummaryOut)
1282
  def invoice_summary(card_id: int, invoice_ym: str):
1283
  parse_ym(invoice_ym)
 
1308
  "count": int(row.get("cnt") or 0),
1309
  }
1310
 
1311
+
1312
  @app.post("/cards/pay-invoice")
1313
  def pay_invoice(payload: PayInvoiceIn):
1314
  parse_ym(payload.invoice_ym)
 
1382
  )
1383
  return tx, cp
1384
 
1385
+
1386
  @app.get("/reports/monthly", response_model=MonthlyReportOut)
1387
  def report_monthly(year: int, month: int):
1388
  if month < 1 or month > 12:
 
1392
  tx, cp = _fetch_monthly_report(conn, ym)
1393
  return {"year": year, "month": month, "ym": ym, "transactions": tx, "card_purchases": cp}
1394
 
1395
+
1396
  @app.get("/reports/export")
1397
  def report_export(year: int, month: int, fmt: ExportFormat = "csv"):
1398
  if month < 1 or month > 12:
 
1437
  url=f"{GROQ_BASE}/chat/completions",
1438
  method="POST",
1439
  data=json.dumps(body).encode("utf-8"),
1440
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {GROQ_API_KEY}", "User-Agent": "FinanceAI/1.7.1"},
1441
  )
1442
  try:
1443
  with urllib.request.urlopen(req, timeout=60) as resp:
 
1488
  (account_id,),
1489
  )
1490
 
1491
+
1492
  @app.post("/forecast/daily/train")
1493
  def forecast_daily_train(account_id: Optional[int] = None, lags: int = 14):
1494
  with db() as conn:
 
1504
  save_model(conn, name, payload)
1505
  return {"ok": True, "trained_at": payload["trained_at"], "basis": payload["basis"], "lags": payload["lags"], "note": payload.get("note")}
1506
 
1507
+
1508
  @app.get("/forecast/daily", response_model=ForecastDailyOut)
1509
  def forecast_daily(days: int = 7, auto_train: bool = True, lags: int = 14, account_id: Optional[int] = None):
1510
  with db() as conn:
 
1548
 
1549
 
1550
  # ============================================================
1551
+ # Forecast — competência mensal (despesa real = caixa sem pagamento + cartão por invoice_ym)
1552
  # ============================================================
1553
  def _fetch_monthly_competencia(conn: Any, account_id: Optional[int], include_card: bool) -> list[dict]:
 
1554
  if account_id is None:
1555
  r1 = q_one(conn, "SELECT MIN(substr(date,1,7)) AS mn, MAX(substr(date,1,7)) AS mx FROM transactions")
1556
  else:
 
1567
 
1568
  start_ym, end_ym = min(mins), max(maxs)
1569
 
 
1570
  def month_seq(start_ym: str, end_ym: str):
1571
  sy, sm = start_ym.split("-")
1572
  ey, em = end_ym.split("-")
 
1633
 
1634
  return series
1635
 
1636
+
1637
  @app.post("/forecast/train", response_model=ForecastTrainOut)
1638
  def forecast_train(lags: int = 6, account_id: Optional[int] = None, include_card: bool = True):
1639
  with db() as conn:
 
1664
  "metrics": metrics,
1665
  }
1666
 
1667
+
1668
  @app.get("/forecast/status")
1669
  def forecast_status(account_id: Optional[int] = None, include_card: bool = True, min_months: int = 12, lags: int = 6):
1670
  with db() as conn:
 
1685
  "note": "O treino mensal exige histórico suficiente (>= lags + 6). Recomenda-se >= 12 meses.",
1686
  }
1687
 
1688
+
1689
  @app.get("/forecast", response_model=ForecastOut)
1690
  def forecast_get(horizon: int = 12, auto_train: bool = True, lags: int = 6, account_id: Optional[int] = None, include_card: bool = True):
1691
  with db() as conn: