first commit
This commit is contained in:
658
main.py
Normal file
658
main.py
Normal file
@@ -0,0 +1,658 @@
|
||||
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, then find only new ones
|
||||
filtered_wf = _filter_wf_from_date(wf_txns, start_iso)
|
||||
new_wf = _find_new_transactions(filtered_wf, scoped_ynab)
|
||||
|
||||
# Convert the new WF transactions to YNAB-ready dicts
|
||||
ynab_ready = _convert_wf_to_ynab_transactions(new_wf, 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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user