Files
lifetrack/apps/api/app/modules/finance/presets.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

440 lines
14 KiB
Python

"""Built-in seed data: source profiles (§3.4) and the default category tree (§2.2).
Both are lazy-seeded on first access to the module (see `service.ensure_seed`)
because v1 has no Alembic data migration (DESIGN §9).
"""
from copy import deepcopy
from typing import Any
from app.modules.finance.enums import CategoryKind, SourceKind
# Chart palette (ux-pages.md §7.5), fixed slot order, colour-blind validated.
PALETTE = (
"#3987E5",
"#D95926",
"#199E70",
"#C98500",
"#D55181",
"#008300",
"#9085E9",
"#E66767",
)
MUTED_COLOR = "#898781"
UNCATEGORIZED_COLOR = "#9ca3af"
UNCATEGORIZED_LABEL = "Non catégorisé"
TRANSFER_CATEGORY_NAME = "Virements internes"
SAVINGS_NODE_NAME = "Épargne du mois"
DEFICIT_NODE_NAME = "Découvert / réserves"
INCOME_NODE_NAME = "Revenus"
# --------------------------------------------------------------------------
# Source profiles — presets shipped with the app (datamodel §3.4 + research §2)
# --------------------------------------------------------------------------
# Generic CSV defaults (§3.1). Every preset below is this dict updated.
GENERIC_CSV_CONFIG: dict[str, Any] = {
"encoding": "auto",
"delimiter": "auto",
"quote_char": '"',
"decimal_separator": ",",
"thousands_separator": " ",
"date_format": None,
"date_formats": ["%d/%m/%Y", "%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%d %H:%M:%S"],
"has_header": True,
"skip_rows_top": 0,
"skip_rows_bottom": 0,
"skip_until_header_startswith": None,
"stop_on_non_date_row": False,
"columns": {
"booked_date": [
"dateOp",
"Date",
"Date de comptabilisation",
"Date opération",
"Date de l'opération",
"date_comptabilisation",
"Booking Date",
"Completed Date",
],
"value_date": ["dateVal", "Date valeur", "Date de valeur", "Value Date"],
"label": [
"label",
"Libellé",
"Libelle operation",
"libellé_complet_operation",
"Détail de l'écriture",
"Description",
"Partner Name",
],
"amount": [
"amount",
"Montant",
"Montant(EUROS)",
"Montant de l'opération",
"montant_operation",
"Amount",
"Amount (EUR)",
],
"debit": ["Débit", "Débit euros", "Debit", "Débit Euros"],
"credit": ["Crédit", "Crédit euros", "Credit", "Crédit Euros"],
"currency": ["Devise", "devise", "Currency"],
"external_id": None,
"counterparty": None,
"fee": None,
},
"amount_mode": "auto",
"invert_sign": False,
"label_join": [],
"skip_row_if": [],
}
def _csv_config(**overrides: Any) -> dict[str, Any]:
config = deepcopy(GENERIC_CSV_CONFIG)
columns = config["columns"]
columns.update(overrides.pop("columns", {}))
config.update(overrides)
config["columns"] = columns
return config
BUILTIN_PROFILES: tuple[dict[str, Any], ...] = (
{
"slug": "generic",
"name": "CSV générique",
"kind": SourceKind.CSV,
"config": _csv_config(),
},
{
"slug": "boursobank",
"name": "BoursoBank / Boursorama (CSV)",
"kind": SourceKind.CSV,
"config": _csv_config(
encoding="utf-8-sig",
delimiter=";",
date_format="%Y-%m-%d",
amount_mode="signed",
label_join=["supplierFound"],
columns={
"booked_date": "dateOp",
"value_date": "dateVal",
"label": "label",
"amount": "amount",
"debit": None,
"credit": None,
},
),
},
{
"slug": "credit-agricole",
"name": "Crédit Agricole (CSV)",
"kind": SourceKind.CSV,
# Variable-length preamble: locate the header line instead of skipping
# a fixed number of rows; a footer may follow the data (research §2.2).
"config": _csv_config(
encoding="cp1252",
delimiter=";",
date_format="%d/%m/%Y",
amount_mode="split",
skip_until_header_startswith="Date;",
stop_on_non_date_row=True,
columns={
"booked_date": "Date",
"value_date": "Date valeur",
"label": "Libellé",
"amount": None,
"debit": ["Débit euros", "Débit Euros", "Debit euros"],
"credit": ["Crédit euros", "Crédit Euros", "Credit euros"],
},
),
},
{
"slug": "bnp-paribas",
"name": "BNP Paribas (CSV)",
"kind": SourceKind.CSV,
# First line = balance metadata, no column header: positional mapping.
"config": _csv_config(
encoding="cp1252",
delimiter=";",
date_format="%d/%m/%Y",
has_header=False,
skip_rows_top=1,
amount_mode="signed",
columns={
"booked_date": 0,
"value_date": None,
"label": 3,
"amount": 4,
"debit": None,
"credit": None,
"currency": None,
},
),
},
{
"slug": "societe-generale",
"name": "Société Générale (CSV)",
"kind": SourceKind.CSV,
"config": _csv_config(
encoding="cp1252",
delimiter=";",
date_format="%d/%m/%Y",
skip_rows_top=1,
amount_mode="signed",
columns={
"booked_date": ["date_comptabilisation", "Date de l'opération", "Date"],
"value_date": None,
"label": [
"libellé_complet_operation",
"Libellé",
"Détail de l'écriture",
],
"amount": [
"montant_operation",
"Montant de l'opération",
"Montant",
],
"debit": None,
"credit": None,
"currency": ["devise", "Devise"],
},
),
},
{
"slug": "banque-postale",
"name": "La Banque Postale (CSV)",
"kind": SourceKind.CSV,
"config": _csv_config(
encoding="cp1252",
delimiter=";",
date_format="%d/%m/%Y",
skip_until_header_startswith="Date;",
amount_mode="signed",
columns={
"booked_date": "Date",
"value_date": None,
"label": "Libellé",
"amount": ["Montant(EUROS)", "Montant (EUROS)", "Montant"],
"debit": None,
"credit": None,
},
),
},
{
"slug": "caisse-epargne",
"name": "Caisse d'Épargne (CSV)",
"kind": SourceKind.CSV,
# 2024+ layout: debit already negative, credit prefixed with '+'.
"config": _csv_config(
encoding="cp1252",
delimiter=";",
date_format="%d/%m/%Y",
amount_mode="split",
columns={
"booked_date": ["Date de comptabilisation", "Date operation"],
"value_date": "Date de valeur",
"label": ["Libelle operation", "Libellé opération"],
"amount": None,
"debit": "Debit",
"credit": "Credit",
"counterparty": ["Libelle simplifie", "Libellé simplifié"],
},
),
},
{
"slug": "fortuneo",
"name": "Fortuneo (CSV)",
"kind": SourceKind.CSV,
"config": _csv_config(
encoding="auto",
delimiter=";",
date_format="%d/%m/%Y",
amount_mode="split",
columns={
"booked_date": ["Date opération", "Date operation"],
"value_date": "Date valeur",
"label": ["libellé", "Libellé"],
"amount": None,
"debit": "Débit",
"credit": "Crédit",
},
),
},
{
"slug": "revolut",
"name": "Revolut (CSV)",
"kind": SourceKind.CSV,
# Amount is gross: Fee is subtracted to get the real balance impact.
"config": _csv_config(
encoding="utf-8",
delimiter=",",
decimal_separator=".",
thousands_separator="",
date_format="%Y-%m-%d %H:%M:%S",
amount_mode="signed",
skip_row_if=[{"column": "State", "not_equals": "COMPLETED"}],
columns={
"booked_date": ["Completed Date", "Started Date"],
"value_date": None,
"label": "Description",
"amount": "Amount",
"debit": None,
"credit": None,
"currency": "Currency",
"fee": "Fee",
},
),
},
{
"slug": "n26",
"name": "N26 (CSV)",
"kind": SourceKind.CSV,
"config": _csv_config(
encoding="utf-8",
delimiter=",",
decimal_separator=".",
thousands_separator="",
date_format="%Y-%m-%d",
amount_mode="signed",
# Column names are resolved case/accent-insensitively.
label_join=["Payment Reference"],
columns={
"booked_date": ["Booking Date", "Date"],
"value_date": "Value Date",
"label": ["Partner Name", "Payee"],
"amount": ["Amount (EUR)", "Amount(EUR)"],
"debit": None,
"credit": None,
"counterparty": ["Partner Name", "Payee"],
},
),
},
{
"slug": "ofx",
"name": "OFX (toutes banques)",
"kind": SourceKind.OFX,
"config": {"fallback_encoding": "cp1252", "account_match": None},
},
{
"slug": "paypal",
"name": "PayPal — rapport d'activité (CSV)",
"kind": SourceKind.PAYPAL_CSV,
"config": {
"encoding": "utf-8-sig",
"delimiter": ",",
"decimal_separator": "auto",
"date_format": "%d/%m/%Y",
"date_formats": ["%d/%m/%Y", "%m/%d/%Y", "%Y-%m-%d"],
"use_net_amount": True,
"skip_types": [
"Autorisation",
"Authorization",
"Commande",
"Order",
"Annulation d'autorisation",
"Void of Authorization",
"Retenue pour vérification par PayPal",
"Payment Review Hold",
"Annulation de la retenue",
"Payment Review Release",
],
"skip_status_not_completed": True,
"conversion_as_skip": True,
},
},
)
# --------------------------------------------------------------------------
# Default category tree (§2.2). (name, icon, children)
# --------------------------------------------------------------------------
EXPENSE_TREE: tuple[tuple[str, str, tuple[str, ...]], ...] = (
("Alimentation", "shopping-cart", ("Courses", "Restaurants & bars", "Livraison")),
(
"Logement",
"home",
(
"Loyer / Crédit",
"Énergie",
"Eau",
"Internet & mobile",
"Assurance habitation",
"Entretien",
),
),
(
"Transports",
"car",
(
"Carburant",
"Péages & parking",
"Transports en commun",
"Entretien véhicule",
"Assurance auto",
),
),
("Santé", "heart-pulse", ("Pharmacie", "Médecin", "Mutuelle")),
(
"Loisirs",
"gamepad-2",
("Abonnements & streaming", "Jeux vidéo", "Sorties", "Sport", "Vacances"),
),
("Shopping", "shirt", ("Vêtements", "High-tech", "Maison")),
("Vape & tabac", "cigarette", ()),
("Banque & frais", "landmark", ("Frais bancaires", "Intérêts")),
("Impôts & taxes", "receipt", ()),
("Enfants & famille", "baby", ()),
("Animaux", "paw-print", ()),
("Dons & cadeaux", "gift", ()),
("Autres dépenses", "circle-ellipsis", ()),
)
INCOME_TREE: tuple[tuple[str, str, tuple[str, ...]], ...] = (
("Salaire", "wallet", ()),
("Aides & prestations", "hand-coins", ()),
("Remboursements", "undo-2", ("Santé", "Autres")),
("Ventes", "tag", ()),
("Intérêts & placements", "trending-up", ()),
("Autres revenus", "circle-plus", ()),
)
TRANSFER_TREE: tuple[tuple[str, str, tuple[str, ...]], ...] = (
(TRANSFER_CATEGORY_NAME, "arrow-left-right", ()),
)
def default_category_tree() -> list[dict[str, Any]]:
"""Flat description of the seed tree, roots first, in display order."""
out: list[dict[str, Any]] = []
order = 0
palette_index = 0
for kind, tree in (
(CategoryKind.INCOME, INCOME_TREE),
(CategoryKind.EXPENSE, EXPENSE_TREE),
(CategoryKind.TRANSFER, TRANSFER_TREE),
):
for name, icon, children in tree:
color = (
MUTED_COLOR
if kind is CategoryKind.TRANSFER
else PALETTE[palette_index % len(PALETTE)]
)
palette_index += 1
out.append(
{
"name": name,
"icon": icon,
"color": color,
"kind": kind,
"sort_order": order,
"is_system": kind is CategoryKind.TRANSFER,
"children": [
{"name": child, "sort_order": i}
for i, child in enumerate(children)
],
}
)
order += 1
return out