import argparse import csv import json from datetime import date, datetime, timedelta, timezone from decimal import Decimal from pathlib import Path from typing import Any import ynab from dateutil.relativedelta import relativedelta from category_suggester import CategoryHistory from wf_credit_extractor import WellsFargoPayloadExtractor as CreditExtractor from wf_debit_extractor import WellsFargoPayloadExtractor as DebitExtractor from ynab_client import YNABClient, YNABTransaction # Maps the extractor name in ynab.yml to the correct extractor class EXTRACTOR_MAP = { "wf_credit_extractor": CreditExtractor, "wf_debit_extractor": DebitExtractor, } # Maps CLI flag name to the extractor name in ynab.yml FLAG_TO_EXTRACTOR = { "credit": "wf_credit_extractor", "debit": "wf_debit_extractor", } # Maps extractor name to the balance type to display EXTRACTOR_BALANCE_TYPE = { "wf_credit_extractor": "OUTSTANDING", "wf_debit_extractor": "AVAILABLE", } def _default_output_path(input_path: Path) -> Path: """Derive a JSON output path from the input file path.""" return input_path.with_suffix(".json") def _extract_raw_transactions(extracted_objects: list[Any]) -> list[dict]: """Pull the raw transaction list out of extracted WF payload objects. Supports multiple HAR structures: - /transactions/transactionData/transactions (posted.dat / checking.dat style) - /accountModel/applicationData/transactionData/transactions (credit fetch style) Each transaction is tagged with a "status" field: "POSTED" or "PENDING". TEMP_AUTH transactionType in requestedCriteria marks pending transactions. """ transactions: list[dict] = [] for obj in extracted_objects: _collect_from_block(obj, transactions) return transactions def _collect_from_block(obj: dict, out: list[dict]) -> None: """Recurse into a single extracted object to find all transactionData blocks.""" # Path 1: /transactions/transactionData (old credit / debit style, and TEMP_AUTH) txn_wrapper = obj.get("transactions", {}) if isinstance(txn_wrapper, dict): td = txn_wrapper.get("transactionData") if isinstance(td, dict): _append_transactions(td, out) # Path 2: /accountModel/applicationData/transactionData (new credit fetch style) acct_td = ( obj.get("accountModel", {}) .get("applicationData", {}) .get("transactionData") ) if isinstance(acct_td, dict): _append_transactions(acct_td, out) def _append_transactions(td: dict, out: list[dict]) -> None: """Extract transactions from a transactionData block and tag POSTED/PENDING.""" txn_type = ( td.get("requestedCriteria", {}).get("transactionType", "") or td.get("type", "") ) status = "PENDING" if txn_type == "TEMP_AUTH" else "POSTED" txn_list = td.get("transactions", []) if isinstance(txn_list, list): for txn in txn_list: txn_copy = dict(txn) txn_copy["status"] = status if "transactionDescription" in txn_copy: txn_copy["transactionDescription"] = " ".join(txn_copy["transactionDescription"].split()) out.append(txn_copy) def _wf_date_to_iso(timestamp_ms: int) -> str: """Convert WF millisecond timestamp to YYYY-MM-DD string.""" return datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc).strftime( "%Y-%m-%d" ) def _wf_amount_to_cents(amount: float) -> int: """Convert WF dollar amount to integer cents.""" return round(amount * 100) def _ynab_amount_to_cents(amount_milli: int) -> int: """Convert YNAB milliCurrency to integer cents (divide by 10).""" return round(amount_milli / 10) def _extract_account_balance( extracted_objects: list[dict], balance_type: str, ) -> float | None: """Extract the dollar balance from extracted WF objects. Searches each object's accountModel.applicationData.account.balance[] for an entry whose 'type' matches *balance_type* and returns its 'amount'. Returns None if not found. """ for obj in extracted_objects: balances = ( obj.get("accountModel", {}) .get("applicationData", {}) .get("account", {}) .get("balance", []) ) for bal in balances: if bal.get("type") == balance_type: return bal.get("amount") return None def _run_extraction( label: str, input_file: str, extractor: CreditExtractor | DebitExtractor, ) -> tuple[list[dict], list[dict]]: """Run extractor, save JSON output, and return (raw_txns, extracted_objects).""" input_path = Path(input_file) extracted = extractor.extract_from_file(input_path) output_path = _default_output_path(input_path) with output_path.open("w", encoding="utf-8") as f: json.dump(extracted, f, indent=2) print(f"[{label}] Extracted {len(extracted)} object(s) to {output_path}") raw_txns = _extract_raw_transactions(extracted) print(f"[{label}] Found {len(raw_txns)} raw transaction(s)") if extractor.errors: print(f"[{label}] Errors:") for error in extractor.errors: print(f" - {error}") return raw_txns, extracted def _run_ynab_transactions(days: int) -> None: """Fetch and display YNAB transactions for the past N days, excluding reconciled.""" client = YNABClient("ynab.yml") transactions = client.get_transactions_for_days( days=days, exclude_cleared_statuses=["reconciled"], ) print( f"[ynab] Found {len(transactions)} transaction(s) " f"for the past {days} day(s), excluding reconciled" ) print(json.dumps(transactions, indent=2)) def _parse_record_days(value: str) -> int: """Validate the supported lookback for transaction CSV exports.""" try: days = int(value) except ValueError as exc: raise argparse.ArgumentTypeError("days must be an integer from 1 to 240") from exc if not 1 <= days <= 240: raise argparse.ArgumentTypeError("days must be between 1 and 240") return days def _write_transactions_csv( transactions: list[YNABTransaction], output_path: Path, ) -> int: """Write YNAB transactions as one CSV row per category line item.""" output_path.parent.mkdir(parents=True, exist_ok=True) fieldnames = ["payee", "category", "inflow", "outflow"] row_count = 0 with output_path.open("w", encoding="utf-8", newline="") as csv_file: writer = csv.DictWriter(csv_file, fieldnames=fieldnames) writer.writeheader() for transaction in transactions: line_items = transaction.get("subtransactions") or [transaction] for line_item in line_items: amount = Decimal(line_item["amount_milliCurrency"]) / Decimal(1000) if amount >= 0: inflow = f"{amount:.2f}" outflow = "" else: inflow = "" outflow = f"{abs(amount):.2f}" writer.writerow({ "payee": transaction.get("payee_name") or "", "category": line_item.get("category_name") or "", "inflow": inflow, "outflow": outflow, }) row_count += 1 return row_count def _record_transactions_csv( ynab_client: YNABClient, days: int, output_path: Path, ) -> None: """Fetch credit and debit account transactions and save their CSV rows.""" target_extractors = set(FLAG_TO_EXTRACTOR.values()) target_names = {"Credit Card", "Debit Card", "Wells Fargo Bank"} target_account_ids = { account["id"] for account in ynab_client.account_configs if account.get("extractor") in target_extractors or account.get("name") in target_names } if not target_account_ids: raise ValueError( "No Credit Card or Wells Fargo Bank accounts found in ynab.yml" ) transactions = ynab_client.get_transactions_for_days( days=days, include_subtransactions=True, ) selected_transactions = [ transaction for transaction in transactions if transaction["account_id"] in target_account_ids ] history, memory_was_readable = _load_category_history( output_path, report_empty=False, ) row_count = _write_transactions_csv(selected_transactions, output_path) memory_path = CategoryHistory.memory_path_for_csv(output_path) try: history.merge(CategoryHistory.from_csv(output_path)) history.save(memory_path) action = "Updated" if memory_was_readable else "Rebuilt" print(f"[category] {action} reusable category patterns in {memory_path}") except (OSError, ValueError) as exc: print(f"[category] Could not update {memory_path}: {exc}") print( f"[ynab] Wrote {row_count} transaction row(s) from the past {days} day(s) " f"to {output_path}" ) def _load_category_history( csv_path: Path, *, report_empty: bool = True, ) -> tuple[CategoryHistory, bool]: """Load persistent patterns and merge the current CSV without losing old data.""" memory_path = CategoryHistory.memory_path_for_csv(csv_path) memory_exists = memory_path.exists() can_update_memory = True if memory_exists: try: history = CategoryHistory.load(memory_path) except ( OSError, ValueError, TypeError, KeyError, AttributeError, json.JSONDecodeError, ) as exc: print(f"[category] Could not read {memory_path}: {exc}") history = CategoryHistory() can_update_memory = False else: history = CategoryHistory() try: current_csv = CategoryHistory.from_csv(csv_path) added_patterns = history.merge(current_csv) if can_update_memory and (not memory_exists or added_patterns): history.save(memory_path) except FileNotFoundError: pass except (OSError, ValueError, csv.Error) as exc: print(f"[category] Could not read transaction history {csv_path}: {exc}") if report_empty and not history.patterns: print( f"[category] No usable history found in {csv_path} or {memory_path}; " "new transactions will be imported without category suggestions." ) return history, can_update_memory def _apply_category_suggestions( transactions: list[dict], history: CategoryHistory, category_ids_by_name: dict[str, list[str]], ) -> None: """Annotate import candidates and set category_id only for confident matches.""" for transaction in transactions: suggestion = history.suggest( transaction.get("payee_name") or "", transaction["amount"], category_ids_by_name, ) if suggestion is None: continue transaction["_category_suggestion"] = suggestion.category_name transaction["_category_confidence"] = suggestion.confidence transaction["_category_applied"] = suggestion.applied if suggestion.category_id is not None: transaction["category_id"] = suggestion.category_id def _filter_wf_from_date(wf_transactions: list[dict], start_iso: str) -> list[dict]: """Keep only WF transactions whose transactionDate is on or after start_iso.""" return [ txn for txn in wf_transactions if _wf_date_to_iso(txn["transactionDate"]) >= start_iso ] def _find_new_transactions( wf_transactions: list[dict], ynab_transactions: list[dict], ) -> list[dict]: """ Return WF transactions that do NOT already exist in YNAB. Match on (date, abs(amount_cents)): - WF: transactionDate (ms) -> YYYY-MM-DD, transactionAmount * 100 -> cents - YNAB: date (YYYY-MM-DD), abs(amount_milliCurrency / 10) -> cents Uses a multiset so duplicate amounts on the same date are handled correctly. """ ynab_keys: dict[tuple[str, int], int] = {} for txn in ynab_transactions: key = (txn["date"], abs(_ynab_amount_to_cents(txn["amount_milliCurrency"]))) ynab_keys[key] = ynab_keys.get(key, 0) + 1 new_transactions: list[dict] = [] for txn in wf_transactions: date_str = _wf_date_to_iso(txn["transactionDate"]) cents = abs(_wf_amount_to_cents(txn["transactionAmount"])) key = (date_str, cents) if ynab_keys.get(key, 0) > 0: ynab_keys[key] -= 1 else: new_transactions.append(txn) return new_transactions # --------------------------------------------------------------------------- # YNAB Import helpers # --------------------------------------------------------------------------- def _wf_to_ynab_millis(wf_txn: dict) -> int: """Convert a WF transaction amount to YNAB milliCurrency. Sign logic (unified for credit & debit cards): - debitCreditType == "CREDIT" → inflow (positive) - absent or "DEBIT" → outflow (negative) """ amount = wf_txn["transactionAmount"] millis = round(amount * 1000) if wf_txn.get("debitCreditType") == "CREDIT": return millis # inflow return -millis # outflow def _build_import_ids( amounts: list[int], dates: list[str], ) -> list[str]: """Build YNAB import_id strings: ``YNAB:{amount}:{date}:{occurrence}``. Occurrence starts at 1 and increments for duplicate (amount, date) pairs within the batch. """ seen: dict[tuple[int, str], int] = {} import_ids: list[str] = [] for amount, date_str in zip(amounts, dates): key = (amount, date_str) occurrence = seen.get(key, 0) + 1 seen[key] = occurrence import_ids.append(f"YNAB:{amount}:{date_str}:{occurrence}") return import_ids def _convert_wf_to_ynab_transactions( wf_transactions: list[dict], account_id: str, ) -> list[dict]: """Convert raw WF transactions to YNAB-ready dicts. Each dict has the keys expected by ``ynab.NewTransaction`` plus a ``_wf_source`` reference back to the original WF transaction. """ amounts: list[int] = [] dates: list[str] = [] for txn in wf_transactions: amounts.append(_wf_to_ynab_millis(txn)) dates.append(_wf_date_to_iso(txn["transactionDate"])) import_ids = _build_import_ids(amounts, dates) results: list[dict] = [] for txn, amount, date_str, import_id in zip( wf_transactions, amounts, dates, import_ids ): status = txn.get("status", "POSTED") cleared = "cleared" if status == "POSTED" else "uncleared" payee = " ".join(txn.get("transactionDescription", "").split()) results.append({ "account_id": account_id, "date": date_str, "amount": amount, "payee_name": payee[:200] if payee else None, "cleared": cleared, "flag_color": "blue", "import_id": import_id, "approved": False, "_wf_source": txn, }) return results class _ImportClassification: """Holds the three buckets produced by ``_classify_for_import``.""" def __init__(self) -> None: self.new: list[dict] = [] self.pending_to_posted: list[tuple[dict, YNABTransaction]] = [] self.duplicates: list[dict] = [] def _classify_for_import( ynab_ready: list[dict], ynab_transactions: list[YNABTransaction], ) -> _ImportClassification: """Classify incoming transactions into new / pending→posted / duplicate. Matching rules (all use (date, abs(amount)) via multiset): 1. If a posted incoming txn matches an **uncleared** YNAB txn → pending→posted (update existing to cleared). 2. If a cleared YNAB txn already has the same import_id → duplicate (create with RED flag). 3. Otherwise → new. """ result = _ImportClassification() # Build a set of existing cleared import_ids cleared_import_ids: set[str] = set() for t in ynab_transactions: if t["cleared"] in ("cleared", "reconciled") and t.get("import_id"): cleared_import_ids.add(t["import_id"]) # Build a multiset of uncleared YNAB txns keyed by (date, abs_amount) # Each entry stores the list of YNAB transactions for that key. uncleared_pool: dict[tuple[str, int], list[YNABTransaction]] = {} for t in ynab_transactions: if t["cleared"] == "uncleared": key = (t["date"], abs(t["amount_milliCurrency"])) uncleared_pool.setdefault(key, []).append(t) for txn in ynab_ready: import_id = txn["import_id"] abs_amount = abs(txn["amount"]) txn_key = (txn["date"], abs_amount) # 1. Pending→Posted: incoming is cleared, matches an uncleared YNAB txn if txn["cleared"] == "cleared" and uncleared_pool.get(txn_key): matched_ynab = uncleared_pool[txn_key].pop(0) if not uncleared_pool[txn_key]: del uncleared_pool[txn_key] result.pending_to_posted.append((txn, matched_ynab)) # 2. Duplicate: import_id already exists on a cleared YNAB txn elif import_id in cleared_import_ids: txn["flag_color"] = "red" result.duplicates.append(txn) # 3. New else: result.new.append(txn) return result def _format_amount_dollars(millis: int) -> str: """Format milliCurrency as a dollar string with sign.""" dollars = millis / 1000.0 return f"${dollars:+,.2f}" def _preview_import(classification: _ImportClassification) -> None: """Print a formatted preview of the import classification.""" col_date = 12 col_amount = 14 col_desc = 34 col_category = 28 report_width = col_date + col_amount + col_desc + col_category + 10 header = ( f" {'Date':<{col_date}} {'Amount':>{col_amount}} " f"{'Description':<{col_desc}} {'Category suggestion':<{col_category}}" ) separator = ( f" {'-' * col_date} {'-' * col_amount} {'-' * col_desc} " f"{'-' * col_category}" ) def category_text(txn: dict) -> str: suggestion = txn.get("_category_suggestion") if not suggestion: return "(uncategorized)" confidence = txn.get("_category_confidence", 0.0) prefix = "" if txn.get("_category_applied") else "? " return f"{prefix}{suggestion} ({confidence:.0%})" def row_text(txn: dict, category: str) -> str: desc = (txn.get("payee_name") or "")[:col_desc] category = category[:col_category] return ( f" {txn['date']:<{col_date}} " f"{_format_amount_dollars(txn['amount']):>{col_amount}} " f"{desc:<{col_desc}} {category:<{col_category}}" ) # --- New --- print(f"\n{'=' * report_width}") print(f" NEW TRANSACTIONS ({len(classification.new)}) — will be created with BLUE flag") print(f"{'=' * report_width}") if classification.new: print(header) print(separator) for txn in classification.new: print(row_text(txn, category_text(txn))) else: print(" (none)") # --- Pending→Posted --- print(f"\n{'=' * report_width}") print(f" PENDING → POSTED ({len(classification.pending_to_posted)}) — existing uncleared will be marked cleared") print(f"{'=' * report_width}") if classification.pending_to_posted: print(header) print(separator) for txn, matched in classification.pending_to_posted: existing_category = matched.get("category_name") or "(unchanged)" print(row_text(txn, existing_category)) else: print(" (none)") # --- Duplicates --- print(f"\n{'=' * report_width}") print(f" POSSIBLE DUPLICATES ({len(classification.duplicates)}) — will be created with RED flag") print(f"{'=' * report_width}") if classification.duplicates: print(header) print(separator) for txn in classification.duplicates: print(row_text(txn, category_text(txn))) else: print(" (none)") total = ( len(classification.new) + len(classification.pending_to_posted) + len(classification.duplicates) ) print(f"\n Total: {total} transaction(s)") print() def _execute_import( classification: _ImportClassification, ynab_client: YNABClient, ) -> None: """Send classified transactions to YNAB.""" # --- Create new + duplicates --- to_create = classification.new + classification.duplicates if to_create: today = date.today() earliest_ynab_date = today - relativedelta(years=5) eligible_to_create = [] skipped_for_date = [] for transaction in to_create: transaction_date = date.fromisoformat(transaction["date"]) if earliest_ynab_date <= transaction_date <= today: eligible_to_create.append(transaction) else: skipped_for_date.append(transaction) if skipped_for_date: print( f" [ynab] Skipped {len(skipped_for_date)} transaction(s) outside " f"the supported date range ({earliest_ynab_date} through {today}):" ) for transaction in skipped_for_date: payee = transaction.get("payee_name") or "(no payee)" print(f" {transaction['date']} | {payee}") if eligible_to_create: new_txns = [ ynab.NewTransaction( account_id=t["account_id"], date=date.fromisoformat(t["date"]), amount=t["amount"], payee_name=t["payee_name"], category_id=t.get("category_id"), cleared=ynab.TransactionClearedStatus(t["cleared"]), flag_color=ynab.TransactionFlagColor(t["flag_color"]), import_id=t["import_id"], approved=t.get("approved", False), ) for t in eligible_to_create ] resp = ynab_client.create_transactions(new_txns) created_ids = resp.transaction_ids or [] dup_ids = resp.duplicate_import_ids or [] print( f" [ynab] Created {len(created_ids)} transaction(s)" + (f", {len(dup_ids)} server-side duplicate(s) skipped" if dup_ids else "") ) # --- Update pending→posted --- if classification.pending_to_posted: updated = 0 for _txn, matched_ynab in classification.pending_to_posted: try: ynab_client.update_transaction( transaction_id=matched_ynab["id"], cleared="cleared", ) updated += 1 except Exception as e: print(f" [ynab] Error updating {matched_ynab['id']}: {e}") print(f" [ynab] Updated {updated} transaction(s) from uncleared → cleared") def main() -> None: parser = argparse.ArgumentParser( description="Extract WF HAR files, compare with YNAB, find new transactions" ) parser.add_argument( "--credit", default="", help="Optional credit HAR input file", ) parser.add_argument( "--debit", default="", help="Optional debit HAR input file", ) parser.add_argument( "--lookback", type=int, default=3, metavar="DAYS", help="Days before last reconciled YNAB transaction to start comparison (default: 3)", ) parser.add_argument( "--ynab-transactions", type=int, default=None, metavar="DAYS", help="List YNAB transactions for the past DAYS (excluding reconciled)", ) parser.add_argument( "--record-transactions", "--record-trasactions", dest="record_transactions", type=_parse_record_days, nargs="?", const=60, default=None, metavar="DAYS", help="Write Credit Card and Wells Fargo Bank transactions to CSV (default: 60 days; max: 240)", ) parser.add_argument( "--transactions-csv", type=Path, default=Path("transactions.csv"), metavar="PATH", help="CSV history/export path for category learning (default: transactions.csv)", ) parser.add_argument( "--ynab-import", action="store_true", default=False, help="Import new WF transactions into YNAB (requires --credit and/or --debit)", ) args = parser.parse_args() # Load YNAB config to map extractors -> account IDs ynab_client = YNABClient("ynab.yml") if args.record_transactions is not None: _record_transactions_csv( ynab_client, args.record_transactions, args.transactions_csv, ) account_configs = ynab_client.account_configs # Build extractor_name -> [account_id, ...] mapping from ynab.yml extractor_to_accounts: dict[str, list[str]] = {} for acct in account_configs: ext_name = acct.get("extractor", "") if ext_name: extractor_to_accounts.setdefault(ext_name, []).append(acct["id"]) # -- Extract WF transactions ------------------------------------------- extracted_by_extractor: dict[str, list[dict]] = {} objects_by_extractor: dict[str, list[dict]] = {} for flag, extractor_name in FLAG_TO_EXTRACTOR.items(): input_file = getattr(args, flag, "") if not input_file: continue extractor_cls = EXTRACTOR_MAP[extractor_name] raw_txns, extracted_objects = _run_extraction(flag, input_file, extractor_cls()) extracted_by_extractor[extractor_name] = raw_txns objects_by_extractor[extractor_name] = extracted_objects # -- Compare with YNAB ------------------------------------------------- if extracted_by_extractor: # Gather all relevant account IDs across active extractors all_account_ids = [ aid for ext_name in extracted_by_extractor for aid in extractor_to_accounts.get(ext_name, []) ] # Find the last reconciled transaction date in YNAB last_reconciled = ynab_client.get_last_reconciled_date( account_ids=all_account_ids ) if last_reconciled is None: print("\n[ynab] No reconciled transactions found; defaulting to 30 day lookback") start_date = (datetime.now() - timedelta(days=30)).date() else: start_date = last_reconciled - timedelta(days=args.lookback) print( f"\n[ynab] Last reconciled date: {last_reconciled}" f" → start comparison from {start_date} " f"({args.lookback} day lookback)" ) start_iso = start_date.isoformat() days_from_start = (datetime.now().date() - start_date).days + 1 # Fetch ALL YNAB transactions from start_date onward (including reconciled) # so that reconciled matches also get consumed during comparison. all_ynab_txns = ynab_client.get_transactions_for_days( days=days_from_start, ) print(f"[ynab] Retrieved {len(all_ynab_txns)} transaction(s) from {start_iso} onward") # Compare per-extractor, scoped to the matching YNAB account IDs for extractor_name, wf_txns in extracted_by_extractor.items(): account_ids = set(extractor_to_accounts.get(extractor_name, [])) scoped_ynab = [t for t in all_ynab_txns if t["account_id"] in account_ids] # Filter WF transactions to the same start date filtered_wf = _filter_wf_from_date(wf_txns, start_iso) new_txns = _find_new_transactions(filtered_wf, scoped_ynab) label = extractor_name.replace("wf_", "").replace("_extractor", "") print( f"\n[{label}] {len(new_txns)} new transaction(s) " f"not yet in YNAB (of {len(filtered_wf)} extracted from {start_iso}, " f"{len(scoped_ynab)} in YNAB)" ) if new_txns: print(json.dumps(new_txns, indent=2)) # -- YNAB Import ------------------------------------------------------- if args.ynab_import and extracted_by_extractor: all_ynab_ready: list[dict] = [] balances: dict[str, float] = {} # label -> balance amount category_history, _ = _load_category_history(args.transactions_csv) try: category_ids_by_name = ynab_client.get_category_ids_by_name() except Exception as exc: print( f"[category] Could not load YNAB category IDs ({exc}); " "suggestions will not be assigned." ) category_ids_by_name = {} for extractor_name, wf_txns in extracted_by_extractor.items(): account_ids_for_ext = extractor_to_accounts.get(extractor_name, []) if not account_ids_for_ext: continue # Use the first account ID for this extractor target_account_id = account_ids_for_ext[0] scoped_ynab = [ t for t in all_ynab_txns if t["account_id"] in set(account_ids_for_ext) ] # Filter WF to the comparison window filtered_wf = _filter_wf_from_date(wf_txns, start_iso) # For import: only exclude WF txns that already match a cleared/reconciled # YNAB transaction. Posted WF txns matching *uncleared* YNAB entries must # survive so _classify_for_import can promote them to cleared. settled_ynab = [ t for t in scoped_ynab if t["cleared"] in ("cleared", "reconciled") ] import_candidates = _find_new_transactions(filtered_wf, settled_ynab) # Convert the candidates to YNAB-ready dicts ynab_ready = _convert_wf_to_ynab_transactions( import_candidates, target_account_id, ) _apply_category_suggestions( ynab_ready, category_history, category_ids_by_name, ) # Classify against existing YNAB transactions classification = _classify_for_import(ynab_ready, scoped_ynab) label = extractor_name.replace("wf_", "").replace("_extractor", "") print(f"\n--- {label.upper()} IMPORT PREVIEW ---") # Collect account balance for bottom-of-report summary balance_type = EXTRACTOR_BALANCE_TYPE.get(extractor_name) if balance_type: balance = _extract_account_balance( objects_by_extractor.get(extractor_name, []), balance_type, ) if balance is not None: balances[label] = balance _preview_import(classification) all_ynab_ready.append({ "label": label, "classification": classification, }) # Display account balances at the bottom of the report if balances: parts = [] for lbl, amt in balances.items(): parts.append(f"{lbl.title()} ${amt:,.2f}") print(f"{'=' * 80}") print(" " + " ".join(parts)) print(f"{'=' * 80}\n") # Check if there's anything to import has_work = any( entry["classification"].new or entry["classification"].pending_to_posted or entry["classification"].duplicates for entry in all_ynab_ready ) if has_work: answer = input("Proceed with import? [y/N]: ").strip().lower() if answer == "y": for entry in all_ynab_ready: label = entry["label"] classification = entry["classification"] print(f"\n[{label}] Importing...") _execute_import(classification, ynab_client) print("\n[ynab] Import complete.") else: print("\n[ynab] Import cancelled.") else: print("\n[ynab] Nothing to import.") elif args.ynab_import and not extracted_by_extractor: print("[ynab-import] No extracted transactions. Use --credit and/or --debit.") # -- Standalone YNAB listing ------------------------------------------- if args.ynab_transactions is not None: _run_ynab_transactions(args.ynab_transactions) if ( not args.credit and not args.debit and args.ynab_transactions is None and args.record_transactions is None ): print( "No input provided. Use --credit, --debit, --ynab-import, " "--ynab-transactions, and/or --record-transactions." ) if __name__ == "__main__": main()