659 lines
24 KiB
Python
659 lines
24 KiB
Python
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()
|
|
|
|
|