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>
440 lines
14 KiB
Python
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
|