import argparse import json from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Any import ynab 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 _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 = 50 header = f" {'Date':<{col_date}} {'Amount':>{col_amount}} {'Description':<{col_desc}}" separator = f" {'-' * col_date} {'-' * col_amount} {'-' * col_desc}" # --- New --- print(f"\n{'=' * 80}") print(f" NEW TRANSACTIONS ({len(classification.new)}) — will be created with BLUE flag") print(f"{'=' * 80}") if classification.new: print(header) print(separator) for txn in classification.new: desc = (txn.get("payee_name") or "")[:col_desc] print(f" {txn['date']:<{col_date}} {_format_amount_dollars(txn['amount']):>{col_amount}} {desc:<{col_desc}}") else: print(" (none)") # --- Pending→Posted --- print(f"\n{'=' * 80}") print(f" PENDING → POSTED ({len(classification.pending_to_posted)}) — existing uncleared will be marked cleared") print(f"{'=' * 80}") if classification.pending_to_posted: print(header) print(separator) for txn, matched in classification.pending_to_posted: desc = (txn.get("payee_name") or "")[:col_desc] print(f" {txn['date']:<{col_date}} {_format_amount_dollars(txn['amount']):>{col_amount}} {desc:<{col_desc}}") else: print(" (none)") # --- Duplicates --- print(f"\n{'=' * 80}") print(f" POSSIBLE DUPLICATES ({len(classification.duplicates)}) — will be created with RED flag") print(f"{'=' * 80}") if classification.duplicates: print(header) print(separator) for txn in classification.duplicates: desc = (txn.get("payee_name") or "")[:col_desc] print(f" {txn['date']:<{col_date}} {_format_amount_dollars(txn['amount']):>{col_amount}} {desc:<{col_desc}}") 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: new_txns = [ ynab.NewTransaction( account_id=t["account_id"], date=date.fromisoformat(t["date"]), amount=t["amount"], payee_name=t["payee_name"], 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 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( "--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") 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 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, ) # 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: print("No input provided. Use --credit, --debit, --ynab-import, and/or --ynab-transactions.") if __name__ == "__main__": main()