164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
"""Categorize Vinted transactions in YNAB from an undated, reverse-chronological item list.
|
|
|
|
Usage: uv run scripts/vinted.py vinted-transactions.csv [--dry-run] [--days 90]
|
|
|
|
The CSV must have `item`, `price` and `category` columns, ordered newest first. Since there are
|
|
no dates, each YNAB transaction (newest first) is matched to the first remaining item with the
|
|
same amount, and that item is removed from the pool.
|
|
"""
|
|
import csv
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
import os
|
|
import sys
|
|
|
|
import click
|
|
import ynab
|
|
from ynab.models.transaction_detail import TransactionDetail
|
|
|
|
from catnab.ynab_client import YnabClient
|
|
|
|
|
|
@dataclass
|
|
class VintedItem:
|
|
name: str
|
|
# milliunits, negative for outflows, to match YNAB
|
|
amount: int
|
|
category: str
|
|
|
|
|
|
def load_items(path: str) -> list[VintedItem]:
|
|
with open(path, newline='') as f:
|
|
return [
|
|
VintedItem(
|
|
name=row['item'].strip(),
|
|
amount=-int(Decimal(row['price']) * 1000),
|
|
category=row['category'].strip(),
|
|
)
|
|
for row in csv.DictReader(f)
|
|
]
|
|
|
|
|
|
def is_vinted(tx: TransactionDetail) -> bool:
|
|
return tx.payee_name is not None and 'vinted' in tx.payee_name.lower() and not tx.deleted
|
|
|
|
|
|
def is_updatable(tx: TransactionDetail) -> bool:
|
|
# same guards as the Amazon classifier: don't touch anything already handled by us or the user
|
|
return (
|
|
tx.flag_color != 'purple'
|
|
and not tx.approved
|
|
and len(tx.subtransactions) == 0
|
|
)
|
|
|
|
|
|
def match_items(
|
|
txs: list[TransactionDetail],
|
|
items: list[VintedItem],
|
|
) -> tuple[list[tuple[TransactionDetail, VintedItem]], list[TransactionDetail], list[VintedItem]]:
|
|
"""Returns (matches, unmatched transactions, leftover items)."""
|
|
remaining = list(items)
|
|
matches = []
|
|
unmatched = []
|
|
for tx in sorted(txs, key=lambda t: t.var_date, reverse=True):
|
|
for i, item in enumerate(remaining):
|
|
if item.amount == tx.amount:
|
|
matches.append((tx, remaining.pop(i)))
|
|
break
|
|
else:
|
|
unmatched.append(tx)
|
|
return matches, unmatched, remaining
|
|
|
|
|
|
def resolve_category(name: str, category_ids: dict[str, str]) -> str | None:
|
|
"""Find a YNAB category id by `Group: Name` or by bare name (case-insensitive)."""
|
|
name = name.lower()
|
|
candidates = [
|
|
cid for full, cid in category_ids.items()
|
|
if full.lower() == name or full.split(': ', 1)[-1].lower() == name
|
|
]
|
|
return candidates[0] if len(candidates) == 1 else None
|
|
|
|
|
|
def fmt_amount(milliunits: int) -> str:
|
|
return f'${abs(milliunits) / 1000:.2f}'
|
|
|
|
|
|
@click.command
|
|
@click.argument('csv_path', type=click.Path(exists=True, dir_okay=False))
|
|
@click.option('--days', type=int, default=90, help='How far back to look for YNAB transactions.')
|
|
@click.option('--dry-run', is_flag=True, help="Show what would change without updating YNAB.")
|
|
def main(csv_path: str, days: int, dry_run: bool):
|
|
"""Categorize Vinted transactions in YNAB from a list of purchased items."""
|
|
client = YnabClient(
|
|
budget_id=os.environ['YNAB_BUDGET_ID'],
|
|
ynab_token=os.environ['YNAB_TOKEN'],
|
|
sync_days=days,
|
|
)
|
|
items = load_items(csv_path)
|
|
print(f'Loaded {len(items)} items from {csv_path}')
|
|
|
|
print('Fetching YNAB transactions...')
|
|
# Match against every Vinted transaction, including ones we won't update, so that an
|
|
# already-approved transaction still consumes its item and doesn't shift later matches.
|
|
txs = [tx for tx in client.history if is_vinted(tx)]
|
|
print(f'Found {len(txs)} Vinted transactions in the last {days} days')
|
|
|
|
matches, unmatched, leftover = match_items(txs, items)
|
|
|
|
updates = []
|
|
bad_categories = set()
|
|
print('\nMatches:')
|
|
for tx, item in matches:
|
|
line = f' {tx.var_date} {fmt_amount(tx.amount):>8} {item.name}'
|
|
if not is_updatable(tx):
|
|
print(f'{line} (skipped: already approved/flagged/split)')
|
|
continue
|
|
|
|
category_id = None
|
|
if item.category:
|
|
category_id = resolve_category(item.category, client.category_ids)
|
|
if category_id is None:
|
|
bad_categories.add(item.category)
|
|
print(f'{line} -> ??? "{item.category}" not found or ambiguous')
|
|
continue
|
|
print(f'{line} -> {item.category or "(no category, memo only)"}')
|
|
|
|
updates.append(ynab.SaveTransactionWithIdOrImportId(
|
|
id=tx.id,
|
|
category_id=category_id,
|
|
memo=item.name,
|
|
flag_color=ynab.TransactionFlagColor.PURPLE,
|
|
))
|
|
|
|
if unmatched:
|
|
print('\nYNAB transactions with no matching item:')
|
|
for tx in unmatched:
|
|
print(f' {tx.var_date} {fmt_amount(tx.amount):>8} {tx.payee_name}')
|
|
|
|
if leftover:
|
|
print('\nItems with no matching YNAB transaction:')
|
|
for item in leftover:
|
|
print(f' {fmt_amount(item.amount):>8} {item.name}')
|
|
|
|
if bad_categories:
|
|
print('\nUnresolved categories (fix the CSV, or use the full "Group: Name"):')
|
|
for c in sorted(bad_categories):
|
|
print(f' {c}')
|
|
|
|
print(f'\n{len(updates)} transactions to update.')
|
|
if dry_run:
|
|
print('Dry run, not updating YNAB.')
|
|
return
|
|
if bad_categories:
|
|
print('Refusing to update while categories are unresolved.')
|
|
sys.exit(1)
|
|
if updates:
|
|
print('Updating YNAB...')
|
|
client.update_transactions(updates)
|
|
print('Done.')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|