"""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