Listo.

Quedó publicado en nginx:
`https://ballbox-first.emperor-ratio.ts.net/gastos-europa/`

Ahora las selecciones **sí** se persisten en la máquina.

Persistencia:
- archivo:
  `/home/sebas/work/gastos-europa/data/allocations.json`

Backend:
- servicio:
  `gastos-europa.service`
- puerto local:
  `127.0.0.1:18896`

Probé:
- carga por nginx
- guardado server-side
- lectura del archivo de persistencia

Detalle:
- llegaron **16 fotos**
- el HTML ahora carga datos del backend
- los botones `S->S`, `S->V+S`, `S->V` guardan en server

Si querés, siguiente paso:
- deduplicado pending/posted
- export CSV con tus marcas
- resumen final “Sebas debe / Vicky debe”.


[telegram] esos pagos los hice yo
te paso ahora los que hizo vicky. agregalos y agrega botones V->*

13/05/2026
De Kroon van Singel
U$S 11,90
13/05/2026
Zettle *Sam tabak & souve
U$S 7,14
13/05/2026
LA PAMPA V.O.F.
U$S 47,63
12/05/2026
INTRUSO
U$S 14,29
12/05/2026
C&M*SINGEL 506
U$S 1,79
12/05/2026
CROISSANTERIE OUTMAYER
 $ usd 22,33
12/05/2026
PAPIZZA
U$S 14,06
12/05/2026
TA-WHA CHIN ARTS & CRA
U$S 8,93
12/05/2026
Hotel Beursstraat
U$S 484,92
12/05/2026
KamYin BV
U$S 45,56
11/05/2026
ZARA MADRID PRECIADOS 18
U$S 58,28
11/05/2026
BUTRAGUENO ESTEBAN
U$S 64,25
11/05/2026
VIVAZ ROSALEDA
U$S 35,37
11/05/2026
VIANDAS DE SALAMANCA
U$S 26,73
11/05/2026
DEKORAZONCARMEN
U$S 17,85
11/05/2026
MUSEO DEL PRADO
U$S 11,91
10/05/2026
CH LUMIERE
U$S 184,86
10/05/2026
TABERNA LA DANIELA
U$S 62,16


todo

Agregar gastos de Vicky y botones V->*

Incorporar transacciones manuales de Vicky al dataset, marcarlas como pagadas por Vicky, extender app con botones V->V / V->V+S / V->S y actualizar análisis/persistencia.


Created #5: Agregar gastos de Vicky y botones V->* (pending)

Agregar gastos de Vicky y botones V->*

Guardar gastos Europa en proyecto nuevo

Crear carpeta de proyecto propia y guardar imágenes originales + texto extraído/OCR en raw para luego visualizar y categorizar.

Agregar más capturas al proyecto gastos-europa

Copiar nuevas imágenes 1364-1369 al proyecto y anexar transcripción manual al raw.

Analizar gastos Europa y generar HTML interactivo

Verificar cantidad de fotos, estructurar transacciones extraídas, hacer análisis inicial y generar un HTML con botones S->S / S->V+S / S->V para marcar imputación.

Publicar gastos-europa con persistencia en nginx

Exponer analysis como ruta web en nginx y agregar persistencia server-side simple para selecciones S->S / S->V+S / S->V.

Agregar gastos de Vicky y botones V->*


write

Source: manual text from Telegram
Payer: Vicky
Currency shown by user: USD

13/05/2026
- De Kroon van Singel — -$11.90
13/05/2026
- Zettle *Sam tabak & souve — -$7.14
13/05/2026
- LA PAMPA V.O.F. — -$47.63
12/05/2026
- INTRUSO — -$14.29
12/05/2026
- C&M*SINGEL 506 — -$1.79
12/05/2026
- CROISSANTERIE OUTMAYER — -$22.33
12/05/2026
- PAPIZZA — -$14.06
12/05/2026
- TA-WHA CHIN ARTS & CRA — -$8.93
12/05/2026
- Hotel Beursstraat — -$484.92
12/05/2026
- KamYin BV — -$45.56
11/05/2026
- ZARA MADRID PRECIADOS 18 — -$58.28
11/05/2026
- BUTRAGUENO ESTEBAN — -$64.25
11/05/2026
- VIVAZ ROSALEDA — -$35.37
11/05/2026
- VIANDAS DE SALAMANCA — -$26.73
11/05/2026
- DEKORAZONCARMEN — -$17.85
11/05/2026
- MUSEO DEL PRADO — -$11.91
10/05/2026
- CH LUMIERE — -$184.86
10/05/2026
- TABERNA LA DANIELA — -$62.16


Successfully wrote 798 bytes to /home/sebas/work/gastos-europa/raw/vicky-manual.txt


bash

python3 - <<'PY'
import re, json, csv, os
from collections import defaultdict
base='/home/sebas/work/gastos-europa'
src=base+'/raw/extracted-text.txt'
vsrc=base+'/raw/vicky-manual.txt'
outdir=base+'/derived'
os.makedirs(outdir, exist_ok=True)
text=open(src).read().splitlines()
entries=[]
photo=None; date=None; section=None
for line in text:
    if line.startswith('## photo-'):
        photo=line.strip('# ').strip(); date=None; section=None; continue
    s=line.strip()
    if not s: continue
    if s in ('Pending Transactions','Posted Transactions'):
        section='pending' if 'Pending' in s else 'posted'; continue
    if re.match(r'^\d{2}/\d{2}/\d{4}$', s):
        date=s; continue
    if s.startswith('- ') and ' — ' in s[2:]:
        merchant, amount_s = s[2:].rsplit(' — ',1)
        amt=float(amount_s.replace('$','').replace(',',''))
        entries.append({'source':'sebas_bank','source_ref':photo,'paid_by':'S','date':date or '','status':section or '','merchant':merchant,'amount_usd':amt})
# vicky manual
vlines=open(vsrc).read().splitlines()
date=None
for line in vlines:
    s=line.strip()
    if not s or s.startswith('Source:') or s.startswith('Payer:') or s.startswith('Currency '): continue
    if re.match(r'^\d{2}/\d{2}/\d{4}$', s):
        date=s; continue
    if s.startswith('- ') and ' — ' in s[2:]:
        merchant, amount_s = s[2:].rsplit(' — ',1)
        amt=float(amount_s.replace('$','').replace(',',''))
        entries.append({'source':'vicky_manual','source_ref':'vicky-manual','paid_by':'V','date':date or '','status':'manual','merchant':merchant,'amount_usd':amt})

def enrich(e, idx):
    merchant=e['merchant']; low=merchant.lower(); amt=e['amount_usd']
    typ='expense' if amt<0 else 'income'
    fee=('fee for international transaction' in low or 'monthly account service fee' in low)
    cat='other'; country=''; trip='europe_trip'
    if fee: cat='fee'
    elif any(x in low for x in ['omio','goeuro','cabify','uber','ns ','ns-','schiphol','aerop.','airport','luchthaven']): cat='transport'
    elif any(x in low for x in ['museum','anne frank','sagrada','duomo','vatic','louvreticket','versailles','prado','flagship amsterdam']): cat='attraction'
    elif any(x in low for x in ['albert heijn','jumbo','super','croissanterie','kamyin','viandas']): cat='groceries'
    elif any(x in low for x in ['mcdonald','cafe','bar','friet','restaurant','temple bar','bulldog','il primo','twinkies','lamor','karbeel','katoen','intruso','la pampa','papizza','taberna','de kroon','vivaz']): cat='food_drink'
    elif any(x in low for x in ['uniqlo','lefties','primark','el corte ingles','condomerie','phone shop','city phone','fa. b.smit','zara','dekorazon','tabak','arts & cra','butragueno','sam tabak']): cat='shopping'
    elif any(x in low for x in ['hotel','ch lumiere','beursstraat','village rosa']): cat='lodging'
    elif any(x in low for x in ['openai','aws.amazon','vercel']): cat='subscription'; trip='non_trip'
    elif any(x in low for x in ['deel','interactive brok']): cat='transfer'; trip='non_trip'
    elif 'village rosa' in low: cat='lodging'; trip='non_trip'
    if any(x in merchant for x in [' AMSTERDAM ','Amsterdam','SCHIPHOL','BUSSUM','DAMRAK','Albert Heijn','JUMBO','BCK*NS','BCK*GHM','Buckaroo','Singel','Beursstraat','KamYin','PAPIZZA']): country='NL'
    if any(x in merchant for x in [' MADRID ','GranVia','PRADO','SAGRADA','EL CORTE','LEFTIES','Cabify','ADOLFO SUAREZ','ZARA MADRID','SALAMANCA','CARMEN','ROSALEDA','BUTRAGUENO']): country='ES'
    if any(x in merchant for x in ['PARIS','VERSAILLES','LOUVRE','LUMIERE']): country='FR'
    if 'BERLIN' in merchant: country='DE'
    if 'DUOMO' in merchant: country='IT'
    if 'Vatic' in merchant or 'Vat VA' in merchant: country='VA'
    if 'IMBITUBA BR' in merchant: country='BR'; trip='non_trip'
    if 'WA ON' in merchant or 'CA ON' in merchant or 'Fort Myers FL' in merchant or 'FL ON' in merchant:
        if cat in ('subscription','transfer'): country='US/CA'
    if country in {'BR','US/CA'} and cat in {'subscription','transfer','lodging'}: trip='non_trip'
    if cat=='subscription': trip='non_trip'
    alloc=''
    return {**e,'id':f"{e['source_ref']}:{idx}",'type':typ,'is_fee':fee,'category_guess':cat,'country_guess':country,'trip_scope':trip,'allocation':alloc}
entries=[enrich(e,i+1) for i,e in enumerate(entries)]
with open(outdir+'/transactions.json','w') as f: json.dump(entries,f,indent=2)
with open(outdir+'/transactions.csv','w',newline='') as f:
    w=csv.DictWriter(f,fieldnames=list(entries[0].keys())); w.writeheader(); w.writerows(entries)
# summary
sum_all=sum(e['amount_usd'] for e in entries if e['type']=='expense')
sum_trip=sum(e['amount_usd'] for e in entries if e['type']=='expense' and e['trip_scope']=='europe_trip')
sum_nontrip=sum(e['amount_usd'] for e in entries if e['type']=='expense' and e['trip_scope']=='non_trip')
sum_fees=sum(e['amount_usd'] for e in entries if e['is_fee'])
sum_income=sum(e['amount_usd'] for e in entries if e['type']=='income')
by_cat=defaultdict(float); by_country=defaultdict(float); by_payer=defaultdict(float)
for e in entries:
    if e['type']=='expense' and e['trip_scope']=='europe_trip':
        by_cat[e['category_guess']]+= -e['amount_usd']
        by_country[e['country_guess'] or 'unknown']+= -e['amount_usd']
        by_payer[e['paid_by']]+= -e['amount_usd']
summary={'count':len(entries),'image_count':len({e['source_ref'] for e in entries if e['source']=='sebas_bank'}),'manual_vicky_count':len([e for e in entries if e['paid_by']=='V']),'expense_total_all':round(-sum_all,2),'expense_total_trip':round(-sum_trip,2),'expense_total_non_trip':round(-sum_nontrip,2),'fees_total':round(-sum_fees,2),'income_total':round(sum_income,2),'by_category_trip':dict(sorted(by_cat.items(), key=lambda kv: kv[1], reverse=True)),'by_country_trip':dict(sorted(by_country.items(), key=lambda kv: kv[1], reverse=True)),'by_payer_trip':dict(sorted(by_payer.items()))}
open(outdir+'/summary.json','w').write(json.dumps(summary,indent=2))
print(json.dumps(summary,indent=2))
PY


write

<!doctype html>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>gastos-europa</title>

gastos-europa

Cargando…

Lectura rápida

Por categoría viaje

Por país viaje

    <input id="q" placeholder="buscar comercio/texto" oninput="render()">
    <select id="trip" onchange="render()">
      <option value="">todo scope</option>
      <option value="europe_trip">solo viaje europa</option>
      <option value="non_trip">solo no viaje</option>
    </select>
    <select id="typ" onchange="render()">
      <option value="">todo tipo</option>
      <option value="expense">solo gastos</option>
      <option value="income">solo ingresos</option>
      <option value="fees">solo fees</option>
    </select>
    <select id="paidBy" onchange="render()">
      <option value="">quién pagó</option>
      <option value="S">pagó Sebas</option>
      <option value="V">pagó Vicky</option>
    </select>
    <select id="cat" onchange="render()"></select>

origen	fecha	movimiento	cat	usd	marcar


Successfully wrote 10194 bytes to /home/sebas/work/gastos-europa/analysis.html


bash

curl -fsS http://127.0.0.1/gastos-europa/api/data | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["summary"]["manual_vicky_count"], d["summary"]["count"])'


18 122


Updated #5 (pending → completed)

Guardar gastos Europa en proyecto nuevo

Agregar gastos de Vicky y botones V->*


Listo.

Tomé eso como:
- **esos pagos los hizo Vicky**
- los de fotos siguen como pagos hechos por **Sebas**

Agregué:
- 18 gastos manuales de Vicky
- botones nuevos para gastos pagados por Vicky:
  - `V->V`
  - `V->V+S`
  - `V->S`

Quedó actualizado en la misma ruta:
`https://ballbox-first.emperor-ratio.ts.net/gastos-europa/`

Ahora la base tiene:
- 16 fotos de Sebas
- 18 gastos manuales de Vicky
- 122 movimientos total

También agregué filtro por:
- quién pagó
  - Sebas
  - Vicky

Siguiente paso útil:
- deduplicar pending/posted de Sebas
- y después sacar balance neto entre ambos.


[telegram] me mejoras la vista mobile?


todo

Mejorar vista mobile de gastos-europa

Ajustar HTML/CSS/UX para teléfono: controles más compactos, cards y tabla/filas más legibles, acciones fáciles de tocar.


Created #6: Mejorar vista mobile de gastos-europa (pending)

Mejorar vista mobile de gastos-europa

Guardar gastos Europa en proyecto nuevo

Agregar gastos de Vicky y botones V->*

Mejorar vista mobile de gastos-europa


**Considering mobile edits**

I need to make the mobile version friendlier. Maybe I can convert table rows into cards using data labels? It seems simpler to stack cells and create a sticky summary. I should also add a quick filter bar. My plan is to modify the CSS and adjust the markup to have labels, divs, and buttons laid out in full width. This should improve usability on mobile devices!

write

<!doctype html>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>gastos-europa</title>

gastos-europa

Cargando…

Lectura rápida

Por categoría viaje

Por país viaje

    <input id="q" placeholder="buscar" oninput="render()">
    <select id="trip" onchange="render()">
      <option value="">scope</option>
      <option value="europe_trip">viaje europa</option>
      <option value="non_trip">no viaje</option>
    </select>
    <select id="typ" onchange="render()">
      <option value="">tipo</option>
      <option value="expense">gastos</option>
      <option value="income">ingresos</option>
      <option value="fees">fees</option>
    </select>
    <select id="paidBy" onchange="render()">
      <option value="">pagó</option>
      <option value="S">Sebas</option>
      <option value="V">Vicky</option>
    </select>
    <select id="cat" onchange="render()"></select>

origen	fecha	movimiento	cat	usd	marcar


Successfully wrote 12826 bytes to /home/sebas/work/gastos-europa/analysis.html


Updated #6 (pending → completed)

Guardar gastos Europa en proyecto nuevo

Agregar gastos de Vicky y botones V->*

Mejorar vista mobile de gastos-europa


Sí.

Mejoré mobile en la misma ruta:
`https://ballbox-first.emperor-ratio.ts.net/gastos-europa/`

Cambios:
- filtros sticky arriba
- cards más compactas
- filas tipo card en celular
- botones más grandes para tocar
- menos texto ancho
- layout 2 columnas en stats
- origen/fecha/monto más legibles

Si querés, siguiente mejora:
- presets rápidos arriba:
  - `solo Sebas`
  - `solo Vicky`
  - `solo viaje`
  - `sin clasificar`


[telegram] le agregas a todos una descripcion de una linea basada en lo que sepas (o busques online si tu confianza es baja)?


**Considering options for enhancement**

I need to do some work, and I think there’s no need to ask questions at this point. I might consider using a subagent or simply enhancing the dataset with descriptions. Checking if the internet is available could help, but I may gather enough information through inference. I want to update the HTML to show one-line descriptions and perhaps add derived descriptions. It seems like I should implement this using known merchant names, especially for low-confidence cases.

todo

Agregar descripción de una línea a todos los movimientos

Enriquecer dataset con una descripción corta por comercio basada en conocimiento/heurísticas y mostrarla en la app mobile/desktop.


Created #7: Agregar descripción de una línea a todos los movimientos (pending)

Guardar gastos Europa en proyecto nuevo

Agregar gastos de Vicky y botones V->*

Mejorar vista mobile de gastos-europa


write

#!/usr/bin/env python3
import re, json, csv
from collections import defaultdict
from pathlib import Path

BASE = Path('/home/sebas/work/gastos-europa')
SRC = BASE / 'raw' / 'extracted-text.txt'
VSRC = BASE / 'raw' / 'vicky-manual.txt'
OUT = BASE / 'derived'

KNOWN = [
    ('albert heijn', 'Supermercado neerlandés.'),
    ('jumbo', 'Cadena de supermercado en Países Bajos.'),
    ('condomerie', 'Tienda especializada en preservativos y artículos sexuales.'),
    ('primark', 'Tienda de ropa y básicos de bajo costo.'),
    ('friet point', 'Local de papas fritas y comida rápida.'),
    ('ns groep', 'Ferrocarriles neerlandeses / transporte NS.'),
    ('ns-amsterdam', 'Transporte ferroviario neerlandés / NS.'),
    ('schiphol', 'Compra o transporte en el aeropuerto Schiphol.'),
    ('r.j. mulder', 'Comercio local; rubro exacto no confirmado.'),
    ('het karbeel', 'Bar o restaurante local.'),
    ('la belle super', 'Supermercado o tienda de alimentos.'),
    ('temple bar', 'Bar o pub.'),
    ('mcdonald', 'Comida rápida McDonald’s.'),
    ('victoria hotel', 'Consumo en hotel.'),
    ('flagship amsterdam', 'Paseo o excursión turística en barco en Ámsterdam.'),
    ('il primo', 'Restaurante o comida italiana.'),
    ('van gogh museum', 'Entrada al Museo Van Gogh.'),
    ('anne frank', 'Entrada a la Casa de Ana Frank.'),
    ('twinkies bussum', 'Comida o café local.'),
    ('lamor amsterdam', 'Bar, café o restaurante local.'),
    ('the bulldog', 'Coffee shop/bar de Ámsterdam.'),
    ('game galaxy', 'Arcade o local de juegos.'),
    ('cafe katoen', 'Café o restaurante.'),
    ('b.smit', 'Comercio minorista; rubro exacto no confirmado.'),
    ('ghm b b b.v.', 'Comercio local; rubro exacto no confirmado.'),
    ('city phone internet', 'Tienda de telefonía o accesorios.'),
    ('adolfo suarez', 'Cargo de aeropuerto Madrid-Barajas.'),
    ('pending.uber', 'Viaje o cargo pendiente de Uber.'),
    ('intruso', 'Bar o restaurante en Madrid.'),
    ('el corte ingles', 'Gran tienda por departamentos.'),
    ('lefties', 'Tienda de ropa del grupo Inditex.'),
    ('phone shop', 'Tienda de telefonía o accesorios.'),
    ('uniqlo', 'Tienda de ropa Uniqlo.'),
    ('cabify', 'Viaje en Cabify.'),
    ('amazon web services', 'Cargo de AWS / infraestructura cloud.'),
    ('vercel domains', 'Dominio o servicio de Vercel.'),
    ('village rosa', 'Alojamiento en Brasil; fuera del viaje Europa.'),
    ('openai', 'Suscripción de OpenAI / ChatGPT.'),
    ('omio berlin', 'Pasaje o reserva vía Omio.'),
    ('goeuro corp', 'Pasaje o reserva de transporte de Omio/GoEuro.'),
    ('louvreticket', 'Entrada al Museo del Louvre.'),
    ('sagrada familia', 'Entrada a la Sagrada Familia.'),
    ('buckaroo', 'Pasarela de pago; comercio final no claro.'),
    ('versailles', 'Entrada o reserva vinculada a Versalles.'),
    ('biglietteriamusei.vatic', 'Entrada a Museos Vaticanos.'),
    ('duomo.firenze', 'Entrada al Duomo de Florencia.'),
    ('museo del prado', 'Entrada al Museo del Prado.'),
    ('de kroon van singel', 'Bar o restaurante en Ámsterdam.'),
    ('sam tabak', 'Tienda de tabaco o souvenirs.'),
    ('la pampa', 'Restaurante parrilla.'),
    ('c&m*singel', 'Comercio pequeño en Singel; rubro exacto no confirmado.'),
    ('croissanterie outmayer', 'Panadería o cafetería.'),
    ('papizza', 'Cadena o local de pizza rápida.'),
    ('ta-wha chin arts', 'Tienda de artesanías o souvenirs.'),
    ('hotel beursstraat', 'Alojamiento / hotel.'),
    ('kamyin', 'Supermercado o tienda de alimentos asiáticos.'),
    ('zara madrid', 'Tienda de ropa Zara.'),
    ('butragueno esteban', 'Comercio local; rubro exacto no confirmado.'),
    ('vivaz rosaleda', 'Bar o restaurante.'),
    ('viandas de salamanca', 'Tienda gourmet / fiambres / alimentos.'),
    ('dekorazoncarmen', 'Tienda de decoración o regalos.'),
    ('ch lumiere', 'Alojamiento u hotel.'),
    ('taberna la daniela', 'Taberna o restaurante madrileño.'),
    ('deel', 'Transferencia o pago recibido vía Deel.'),
    ('interactive brok', 'Transferencia desde Interactive Brokers.'),
    ('fee for international transaction', 'Comisión bancaria por compra internacional.'),
    ('monthly account service fee', 'Cargo mensual de mantenimiento de cuenta.'),
]

def guess_desc(merchant, category, is_fee, typ, country, trip_scope):
    low = merchant.lower()
    for key, desc in KNOWN:
        if key in low:
            return desc
    if typ == 'income':
        return 'Ingreso, reintegro o transferencia a favor.'
    if is_fee:
        return 'Comisión o cargo bancario.'
    cat_map = {
        'transport': 'Gasto de transporte o traslado.',
        'attraction': 'Entrada o actividad turística.',
        'groceries': 'Compra de supermercado o alimentos.',
        'food_drink': 'Comida, bar o cafetería.',
        'shopping': 'Compra de ropa, accesorios o retail.',
        'lodging': 'Alojamiento u hotel.',
        'subscription': 'Suscripción o servicio digital.',
        'transfer': 'Transferencia o movimiento de dinero.',
        'other': 'Comercio local; rubro exacto no confirmado.',
        'fee': 'Comisión o cargo bancario.',
    }
    desc = cat_map.get(category, 'Comercio local; rubro exacto no confirmado.')
    if trip_scope == 'non_trip' and country in ('US/CA', 'BR'):
        return desc + ' Fuera del viaje Europa.'
    return desc

def parse_sources():
    entries=[]
    photo=None; date=None; section=None
    for line in SRC.read_text().splitlines():
        if line.startswith('## photo-'):
            photo=line.strip('# ').strip(); date=None; section=None; continue
        s=line.strip()
        if not s: continue
        if s in ('Pending Transactions','Posted Transactions'):
            section='pending' if 'Pending' in s else 'posted'; continue
        if re.match(r'^\d{2}/\d{2}/\d{4}$', s):
            date=s; continue
        if s.startswith('- ') and ' — ' in s[2:]:
            merchant, amount_s = s[2:].rsplit(' — ',1)
            amt=float(amount_s.replace('$','').replace(',',''))
            entries.append({'source':'sebas_bank','source_ref':photo,'paid_by':'S','date':date or '','status':section or '','merchant':merchant,'amount_usd':amt})
    date=None
    for line in VSRC.read_text().splitlines():
        s=line.strip()
        if not s or s.startswith(('Source:','Payer:','Currency ')): continue
        if re.match(r'^\d{2}/\d{2}/\d{4}$', s):
            date=s; continue
        if s.startswith('- ') and ' — ' in s[2:]:
            merchant, amount_s = s[2:].rsplit(' — ',1)
            amt=float(amount_s.replace('$','').replace(',',''))
            entries.append({'source':'vicky_manual','source_ref':'vicky-manual','paid_by':'V','date':date or '','status':'manual','merchant':merchant,'amount_usd':amt})
    return entries

def enrich(e, idx):
    merchant=e['merchant']; low=merchant.lower(); amt=e['amount_usd']
    typ='expense' if amt<0 else 'income'
    fee=('fee for international transaction' in low or 'monthly account service fee' in low)
    cat='other'; country=''; trip='europe_trip'
    if fee: cat='fee'
    elif any(x in low for x in ['omio','goeuro','cabify','uber','ns ','ns-','schiphol','aerop.','airport','luchthaven']): cat='transport'
    elif any(x in low for x in ['museum','anne frank','sagrada','duomo','vatic','louvreticket','versailles','prado','flagship amsterdam']): cat='attraction'
    elif any(x in low for x in ['albert heijn','jumbo','super','croissanterie','kamyin','viandas']): cat='groceries'
    elif any(x in low for x in ['mcdonald','cafe','bar','friet','restaurant','temple bar','bulldog','il primo','twinkies','lamor','karbeel','katoen','intruso','la pampa','papizza','taberna','de kroon','vivaz']): cat='food_drink'
    elif any(x in low for x in ['uniqlo','lefties','primark','el corte ingles','condomerie','phone shop','city phone','fa. b.smit','zara','dekorazon','tabak','arts & cra','butragueno','sam tabak']): cat='shopping'
    elif any(x in low for x in ['hotel','ch lumiere','beursstraat','village rosa']): cat='lodging'
    elif any(x in low for x in ['openai','aws.amazon','vercel']): cat='subscription'; trip='non_trip'
    elif any(x in low for x in ['deel','interactive brok']): cat='transfer'; trip='non_trip'
    if any(x in merchant for x in [' AMSTERDAM ','Amsterdam','SCHIPHOL','BUSSUM','DAMRAK','Albert Heijn','JUMBO','BCK*NS','BCK*GHM','Buckaroo','Singel','Beursstraat','KamYin','PAPIZZA']): country='NL'
    if any(x in merchant for x in [' MADRID ','GranVia','PRADO','SAGRADA','EL CORTE','LEFTIES','Cabify','ADOLFO SUAREZ','ZARA MADRID','SALAMANCA','CARMEN','ROSALEDA','BUTRAGUENO']): country='ES'
    if any(x in merchant for x in ['PARIS','VERSAILLES','LOUVRE','LUMIERE']): country='FR'
    if 'BERLIN' in merchant: country='DE'
    if 'DUOMO' in merchant: country='IT'
    if 'Vatic' in merchant or 'Vat VA' in merchant: country='VA'
    if 'IMBITUBA BR' in merchant: country='BR'; trip='non_trip'
    if 'WA ON' in merchant or 'CA ON' in merchant or 'Fort Myers FL' in merchant or 'FL ON' in merchant:
        if cat in ('subscription','transfer'): country='US/CA'
    if cat == 'subscription': trip='non_trip'
    desc = guess_desc(merchant, cat, fee, typ, country, trip)
    return {**e,'id':f"{e['source_ref']}:{idx}",'type':typ,'is_fee':fee,'category_guess':cat,'country_guess':country,'trip_scope':trip,'allocation':'','description':desc}

def build():
    OUT.mkdir(parents=True, exist_ok=True)
    entries=[enrich(e,i+1) for i,e in enumerate(parse_sources())]
    (OUT/'transactions.json').write_text(json.dumps(entries, indent=2, ensure_ascii=False))
    with (OUT/'transactions.csv').open('w', newline='') as f:
        w=csv.DictWriter(f, fieldnames=list(entries[0].keys())); w.writeheader(); w.writerows(entries)
    sum_all=sum(e['amount_usd'] for e in entries if e['type']=='expense')
    sum_trip=sum(e['amount_usd'] for e in entries if e['type']=='expense' and e['trip_scope']=='europe_trip')
    sum_nontrip=sum(e['amount_usd'] for e in entries if e['type']=='expense' and e['trip_scope']=='non_trip')
    sum_fees=sum(e['amount_usd'] for e in entries if e['is_fee'])
    sum_income=sum(e['amount_usd'] for e in entries if e['type']=='income')
    by_cat=defaultdict(float); by_country=defaultdict(float); by_payer=defaultdict(float)
    for e in entries:
        if e['type']=='expense' and e['trip_scope']=='europe_trip':
            by_cat[e['category_guess']]+= -e['amount_usd']
            by_country[e['country_guess'] or 'unknown']+= -e['amount_usd']
            by_payer[e['paid_by']]+= -e['amount_usd']
    summary={'count':len(entries),'image_count':len({e['source_ref'] for e in entries if e['source']=='sebas_bank'}),'manual_vicky_count':len([e for e in entries if e['paid_by']=='V']),'expense_total_all':round(-sum_all,2),'expense_total_trip':round(-sum_trip,2),'expense_total_non_trip':round(-sum_nontrip,2),'fees_total':round(-sum_fees,2),'income_total':round(sum_income,2),'by_category_trip':dict(sorted(by_cat.items(), key=lambda kv: kv[1], reverse=True)),'by_country_trip':dict(sorted(by_country.items(), key=lambda kv: kv[1], reverse=True)),'by_payer_trip':dict(sorted(by_payer.items()))}
    (OUT/'summary.json').write_text(json.dumps(summary, indent=2, ensure_ascii=False))

if __name__ == '__main__':
    build()


Successfully wrote 11264 bytes to /home/sebas/work/gastos-europa/app/build_dataset.py


bash

python3 /home/sebas/work/gastos-europa/app/build_dataset.py && python3 - <<'PY'
import json
p='/home/sebas/work/gastos-europa/derived/transactions.json'
d=json.load(open(p))
print(d[0]['description'])
print(d[68]['merchant'], '=>', d[68]['description'])
print(len([x for x in d if x.get('description')]))
PY


Supermercado neerlandés.
FEE FOR INTERNATIONAL TRANSACTION => Comisión bancaria por compra internacional.
122


edit

movimiento

${esc(x.merchant)}

${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

movimiento

${esc(x.merchant)}

${esc(x.description||'')}

${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}


Successfully replaced 3 block(s) in /home/sebas/work/gastos-europa/analysis.html.

...
   6 *{box-sizing:border-box}html,body{margin:0}body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);padding-bottom:28px}button,input,select{font:inherit}a{color:var(--accent)}
   7 .wrap{max-width:1260px;margin:0 auto;padding:12px}.titlebar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}.titlebar h1{margin:0;font-size:24px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:12px}.big{font-size:28px;font-weight:700;line-height:1.05}.muted{color:var(--muted)}.small{font-size:12px}.warn{color:var(--yellow)}
   8 .controls{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin:12px 0;position:sticky;top:0;padding:8px 0 10px;background:linear-gradient(180deg,var(--bg) 80%,rgba(11,16,32,0));z-index:4}.controls input,.controls select{width:100%;min-height:42px;padding:10px 12px;border-radius:12px;border:1px solid var(--border);background:var(--panel);color:var(--text)}
   9 .tablewrap{margin-top:12px}.tbl{width:100%;border-collapse:separate;border-spacing:0 10px;font-size:14px}.tbl th{padding:0 10px 6px;text-align:left;color:var(--muted);font-size:12px;font-weight:600}.tbl td{padding:10px;border-top:1px solid var(--border);border-bottom:1px solid var(--border);background:var(--card);vertical-align:top}.tbl td:first-child{border-left:1px solid var(--border);border-radius:14px 0 0 14px}.tbl td:last-child{border-right:1px solid var(--border);border-radius:0 14px 14px 0}.amt.neg{color:var(--red)}.amt.pos{color:var(--green)}.right{text-align:right}
- 10 .tag{display:inline-block;padding:3px 8px;border-radius:999px;background:#1b2547;color:#d5e3ff;font-size:12px;margin:2px 4px 2px 0}.src{display:flex;flex-direction:column;gap:3px}.merchant{font-weight:600;line-height:1.25}.sub{margin-top:4px}.btns{display:flex;gap:6px;flex-wrap:wrap}.b{padding:8px 10px;min-height:38px;border-radius:10px;border:1px solid var(--border);background:var(--panel);color:var(--text);cursor:pointer;font-size:12px}.b.active{outline:2px solid var(--accent)}.b.ss.active,.b.vv.active{background:#16341f}.b.svs.active,.b.vvs.active{background:#3b330f}.b.sv.active,.b.vs.active{background:#341b1b}
+ 10 .tag{display:inline-block;padding:3px 8px;border-radius:999px;background:#1b2547;color:#d5e3ff;font-size:12px;margin:2px 4px 2px 0}.src{display:flex;flex-direction:column;gap:3px}.merchant{font-weight:600;line-height:1.25}.desc{margin-top:4px;color:var(--muted);font-size:12px;line-height:1.35}.sub{margin-top:4px}.btns{display:flex;gap:6px;flex-wrap:wrap}.b{padding:8px 10px;min-height:38px;border-radius:10px;border:1px solid var(--border);background:var(--panel);color:var(--text);cursor:pointer;font-size:12px}.b.active{outline:2px solid var(--accent)}.b.ss.active,.b.vv.active{background:#16341f}.b.svs.active,.b.vvs.active{background:#3b330f}.b.sv.active,.b.vs.active{background:#341b1b}
  11 .mobileOnly{display:none}.desktopOnly{display:table-cell}
  12 ul{margin:8px 0 0 18px;padding:0}li{margin:4px 0}
  13 @media (max-width:700px){
  14   .wrap{padding:10px}.titlebar h1{font-size:20px}.big{font-size:22px}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.card{padding:10px}.controls{grid-template-columns:1fr 1fr}.mobileOnly{display:block}.desktopOnly{display:none}
     ...
 128 function mobileRow(x){
 129   return `

 130

quién

${esc(x.paid_by==='S'?'Sebas':'Vicky')}${esc(x.source_ref)}

 131

fecha

${esc(x.date||'')}

-132

movimiento

${esc(x.merchant)}

${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

+132

movimiento

${esc(x.merchant)}

${esc(x.description||'')}

${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

 133

cat

 ${esc(x.category_guess)}${x.is_fee?'fee':''}

 134

usd
<div class="value amt ${x.amount_usd<0?'neg':'pos'}">${fmt(x.amount_usd)}

 135

marcar

${allocButtons(x)}

 136

`;
     ...
 138 function desktopRow(x){
 139   return `

 140
${esc(x.paid_by==='S'?'Sebas':'Vicky')}${esc(x.source_ref)}

 141     ${esc(x.date||'')}
-142
${esc(x.merchant)}

${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

+142
${esc(x.merchant)}

${esc(x.description||'')}

${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

 143     ${esc(x.category_guess)}${x.is_fee?'fee':''}
 144     <td class="right amt ${x.amount_usd<0?'neg':'pos'}">${fmt(x.amount_usd)}
 145
${allocButtons(x)}

 146
`;
     ...


bash

python3 - <<'PY'
import json
p='/home/sebas/work/gastos-europa/derived/transactions.json'
d=json.load(open(p))
for name in ['VISA Amazon web services aws.amazon.co WA ON 03-06 2561','R.J. Mulder B.V.','Hotel Beursstraat']:
    for x in d:
        if x['merchant']==name:
            print(name, '=>', x['description'])
            break
PY


VISA Amazon web services aws.amazon.co WA ON 03-06 2561 => Cargo de AWS / infraestructura cloud.
R.J. Mulder B.V. => Comercio local; rubro exacto no confirmado.
Hotel Beursstraat => Alojamiento / hotel.


Updated #7 (pending → completed)

Guardar gastos Europa en proyecto nuevo

Agregar gastos de Vicky y botones V->*

Mejorar vista mobile de gastos-europa


Sí.

Agregué una descripción corta a **todos** los movimientos.
Ya se ve en:
`https://ballbox-first.emperor-ratio.ts.net/gastos-europa/`

Qué hice:
- metí campo `description` en el dataset
- lo muestro en mobile y desktop
- usé:
  - conocimiento directo cuando el comercio era claro
  - heurísticas por nombre/categoría cuando no
  - cuando no hay buena certeza: lo marco como
    `Comercio local; rubro exacto no confirmado.`

Ejemplos:
- `Van Gogh Museum` → entrada al museo
- `AWS` → cargo de infraestructura cloud
- `Hotel Beursstraat` → alojamiento
- `R.J. Mulder B.V.` → rubro no confirmado

Si querés, siguiente paso:
- te marco también un **nivel de confianza**
  (`alta / media / baja`)
- y filtro para revisar solo las descripciones dudosas.


[telegram] sí, buscando mejores descripciones masa de Conti textualmente en la ubicación para cada gasto en base a los gastos de alrededor y buscar en Internet


**Exploring search options**

I need to enrich the descriptions for the user by searching the internet and considering the context. I think using a bash curl or even a simple script could work for this. It feels important to keep my updates concise and clear, so I need to figure out how to map the information for nearby entries effectively. The user wants me to search online for unclear merchants, so I’ll make sure to focus on that. Let’s see how to execute this!

todo

Mejorar descripciones con contexto e internet

Agregar confidence y refinar descripciones de comercios usando contexto de gastos cercanos y búsquedas web para los ambiguos.


Created #8: Mejorar descripciones con contexto e internet (pending)

Guardar gastos Europa en proyecto nuevo

Agregar gastos de Vicky y botones V->*

Mejorar vista mobile de gastos-europa


bash

python3 - <<'PY'
import json
from collections import Counter
p='/home/sebas/work/gastos-europa/derived/transactions.json'
d=json.load(open(p))
seen=[]
for x in d:
    m=x['merchant']
    if m not in seen:
        seen.append(m)
for m in seen:
    print(m)
PY


Albert Heijn 1653
Condomerie
PRIMARK DAMRAK
Friet Point
BCK*NS Groep N.V.
BCK*NS-Amsterdam 23
R.J. Mulder B.V.
Het Karbeel
SumUp *La Belle Super
Temple Bar
Albert Heijn 1262
McDonald's Zaanstd WC
Victoria Hotel FB_MRRAB
Flagship Amsterdam B.V
FEE FOR INTERNATIONAL TRANSACTION
VISA CCV*IL PRIMO AMSTERDAM NL ON 05-13 2561
VISA Van Gogh Museum Amsterdam NL ON 05-13 2561
VISA Anne Frank Stichting Amsterdam NL ON 05-13 2561
VISA CCV*V.O.F. TWINKIES BUSSUM NL ON 05-13 2561
VISA JUMBO AMSTERDAM CITY DA AMSTERDAM NL ON 05-13 2561
VISA CCV*LAMOR AMSTERDAM NL ON 05-12 2561
VISA The Bulldog First Amsterdam NL ON 05-12 2561
VISA THE GAME GALAXY AMSTERDAM NL ON 05-12 2561
VISA Cafe Katoen Amsterdam NL ON 05-12 2561
VISA fa. B.Smit AMSTERDAM NL ON 05-12 2561
VISA BCK*GHM B B B.V. AMSTERDAM NL ON 05-12 2561
VISA CITY PHONE INTERNET AMSTERDAM NL ON 05-12 2561
VISA BCK*NS SCHIPHOL 215 LUCHTHAVEN SC NL ON 05-12 2561
VISA AEROP. ADOLFO SUAREZ MADRID ES ON 05-12 2561
VISA UBR*PENDING.UBER.COM AMSTERDAM NL ON 05-12 2561
VISA INTRUSO MADRID ES ON 05-11 2561
VISA EL CORTE INGLES MADRID ES ON 05-11 2561
VISA LEFTIES MADRID ES ON 05-11 2561
VISA PHONE SHOP II GRAN VIA ESCALINATA DE ES ON 05-11 2561
VISA UNIQLO GranVia Madrid ES ON 05-10 2561
VISA Cabify ES 2619qlqSubba Madrid ES ON 05-10 2561
ACH TRANSF INTERACTIVE BROK
VISA Amazon web services aws.amazon.co WA ON 03-06 2561
VISA VERCEL DOMAINS VERCEL.COM CA ON 03-03 2561
VISA VILLAGE ROSA*Vill IMBITUBA BR ON 03-02 2561
VISA OPENAI *CHATGPT SUBSCR OPENAI.COM CA ON 02-19 2561
VISA Omio Berlin DE ON 03-06 2561
VISA GoEuro Corp Fort Myers FL ON 03-06 2561
VISA LOUVRETICKET INT272690575PARIS FR ON 03-28 2561
PAYMENTS 'Deel, IE97G2XSJ
VISA OPENAI *CHATGPT SUBSCR OPENAI.COM CA ON 03-19 2561
MONTHLY ACCOUNT SERVICE FEE
VISA SAGRADA FAMILIA WEB B2C PAGES, PASSAT ES ON 04-01 2561
VISA Buckaroo *www annef AMSTERDAM NL ON 04-01 2561
VISA CHAT.VERSAILLES 431213478VERSAILLES FR ON 03-29 2561
VISA biglietteriamusei.vatic Citta del Vat VA ON 04-10 2561
PAYMENTS 'Deel, IPUZUF4LS
VISA Omio 917-9243330 FL ON 04-07 2561
VISA Omio Berlin DE ON 04-06 2561
VISA Amazon web services aws.amazon.co WA ON 04-02 2561
VISA Amazon web services aws.amazon.co WA ON 05-02 2561
VISA WWW.DUOMO.FIRENZE.IT WWW.DUOMO.FIR IT ON 04-28 2561
VISA OPENAI *CHATGPT SUBSCR OPENAI.COM CA ON 04-19 2561
VISA MUSEO DEL PRADO PRADO PASEO ES ON 04-19 2561
De Kroon van Singel
Zettle *Sam tabak & souve
LA PAMPA V.O.F.
INTRUSO
C&M*SINGEL 506
CROISSANTERIE OUTMAYER
PAPIZZA
TA-WHA CHIN ARTS & CRA
Hotel Beursstraat
KamYin BV
ZARA MADRID PRECIADOS 18
BUTRAGUENO ESTEBAN
VIVAZ ROSALEDA
VIANDAS DE SALAMANCA
DEKORAZONCARMEN
MUSEO DEL PRADO
CH LUMIERE
TABERNA LA DANIELA


python3 - <<'PY'
import urllib.parse, subprocess
queries=['"R.J. Mulder B.V." Amsterdam','"Het Karbeel" Amsterdam','"La Belle Super" Amsterdam','"Lamor" Amsterdam','"B.Smit" Amsterdam souvenir','"annef" Amsterdam ticket','"Sam tabak & souve" Amsterdam','"C&M Singel 506" Amsterdam','"TA-WHA CHIN" Amsterdam','"Butragueno Esteban" Madrid','"Vivaz Rosaleda" Madrid','"Dekorazon Carmen" Madrid','"CH Lumiere" Madrid']
for q in queries:
    url='https://html.duckduckgo.com/html/?q='+urllib.parse.quote(q)
    print('\n###',q)
    out=subprocess.check_output(['bash','-lc',f"curl -A 'Mozilla/5.0' -fsSL {url!r} | sed -n '1,140p' | rg -o '<a rel=\"nofollow\" class=\"result__a\".*|<a class=\"result__snippet\".*' -m 4 || true"], text=True)
    print(out[:3000])
PY


### "R.J. Mulder B.V." Amsterdam

### "Het Karbeel" Amsterdam

### "La Belle Super" Amsterdam

### "Lamor" Amsterdam

### "B.Smit" Amsterdam souvenir

### "annef" Amsterdam ticket

### "Sam tabak & souve" Amsterdam

### "C&M Singel 506" Amsterdam

### "TA-WHA CHIN" Amsterdam

### "Butragueno Esteban" Madrid

### "Vivaz Rosaleda" Madrid

### "Dekorazon Carmen" Madrid

### "CH Lumiere" Madrid


curl -A 'Mozilla/5.0' -fsSL 'https://html.duckduckgo.com/html/?q=%22Het%20Karbeel%22%20Amsterdam' | sed -n '1,120p'


<!DOCTYPE html>

    <link rel="canonical" href="https://duckduckgo.com/">
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=1">
    <meta name="referrer" content="origin">
    <title>
        DuckDuckGo
    </title>
    <link rel="preload" href="/font/ProximaNova-Reg-webfont.woff2" as="font" type="font/woff2" crossorigin="anonymous">
    <link rel="preload" href="/font/ProximaNova-Sbold-webfont.woff2" as="font" type="font/woff2" crossorigin="anonymous">
    <link title="DuckDuckGo (Lite)" type="application/opensearchdescription+xml" rel="search" href="//duckduckgo.com/opensearch_lite_v2.xml">
    <link rel="icon" href="//duckduckgo.com/favicon.ico" type="image/x-icon"/>
    <link id="icon60" rel="apple-touch-icon" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_60x60.png?v=2"/>
    <link id="icon76" rel="apple-touch-icon" sizes="76x76" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_76x76.png?v=2"/>
    <link id="icon120" rel="apple-touch-icon" sizes="120x120" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_120x120.png?v=2"/>
    <link id="icon152" rel="apple-touch-icon" sizes="152x152" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_152x152.png?v=2"/>
    <link rel="image_src" href="//duckduckgo.com/assets/icons/meta/DDG-icon_256x256.png">
    <link rel="stylesheet" media="handheld, all" href="//duckduckgo.com/dist/l.304bf63bbd053ee46b28.css" type="text/css"/>
    <link rel="stylesheet" media="handheld, all" href="//duckduckgo.com/dist/lc.844e8ff9baa78da64b59.css" type="text/css"/>

            DuckDuckGo

        <form id="img-form" action="//duckduckgo.com/anomaly.js?sv=html&cc=botnet&ti=1778848000&gk=d4cd0dabcf4caa22ad92fab40844c786&p=e0aef1735e0ba4725ccb7296740452e0-3abeda972e000f457d4461c8746d14f3-377ca22cef8125255602cdf7140cb5b8-f905c040cb215c3cab8112bcee546690-d7eaee0708d0b4c2327ff8e245d1c870-6f8aac0f44b3f81e55d69acdb5b30ce1-ac292da488fd026394127649c37b288c-d43c874aa8139cc89d89030b110188cc-0ea8358d785149caa2979123c7ade8e1&q="Het Karbeel" Amsterdam&o=ZjhBMCYQBeAxL3cjG0EVjfWUpiTqM3d2z9PwkhW6inQ%3D%0A&r=brs" target="ifr" method="POST"></form>
        <form id="challenge-form" action="//duckduckgo.com/anomaly.js?sv=html&cc=botnet&st=1778848000&gk=d4cd0dabcf4caa22ad92fab40844c786&p=e0aef1735e0ba4725ccb7296740452e0-3abeda972e000f457d4461c8746d14f3-377ca22cef8125255602cdf7140cb5b8-f905c040cb215c3cab8112bcee546690-d7eaee0708d0b4c2327ff8e245d1c870-6f8aac0f44b3f81e55d69acdb5b30ce1-ac292da488fd026394127649c37b288c-d43c874aa8139cc89d89030b110188cc-0ea8358d785149caa2979123c7ade8e1&q="Het Karbeel" Amsterdam&o=ZjhBMCYQBeAxL3cjG0EVjfWUpiTqM3d2z9PwkhW6inQ%3D%0A&r=brs" method="POST">

Unfortunately, bots use DuckDuckGo too.

Please complete the following challenge to confirm this search was made by a human.

Select all squares containing a duck:

                                            <input type="checkbox" class="anomaly-modal__check" name="image-check_e0aef1735e0ba4725ccb7296740452e0" id="image-check_e0aef1735e0ba4725ccb7296740452e0">

                                            <input type="checkbox" class="anomaly-modal__check" name="image-check_3abeda972e000f457d4461c8746d14f3" id="image-check_3abeda972e000f457d4461c8746d14f3">

                                            <input type="checkbox" class="anomaly-modal__check" name="image-check_377ca22cef8125255602cdf7140cb5b8" id="image-check_377ca22cef8125255602cdf7140cb5b8">

                                            <input type="checkbox" class="anomaly-modal__check" name="image-check_f905c040cb215c3cab8112bcee546690" id="image-check_f905c040cb215c3cab8112bcee546690">

                                            <input type="checkbox" class="anomaly-modal__check" name="image-check_d7eaee0708d0b4c2327ff8e245d1c870" id="image-check_d7eaee0708d0b4c2327ff8e245d1c870">

                                            <input type="checkbox" class="anomaly-modal__check" name="image-check_6f8aac0f44b3f81e55d69acdb5b30ce1" id="image-check_6f8aac0f44b3f81e55d69acdb5b30ce1">

                                            <input type="checkbox" class="anomaly-modal__check" name="image-check_ac292da488fd026394127649c37b288c" id="image-check_ac292da488fd026394127649c37b288c">


python3 - <<'PY'
import requests, urllib.parse, json, time
queries=[
('R.J. Mulder B.V. Amsterdam','nl'),
('Het Karbeel Amsterdam','nl'),
('La Belle Super Amsterdam','nl'),
('Temple Bar Amsterdam','nl'),
('Lamor Amsterdam','nl'),
('B.Smit Amsterdam','nl'),
('Buckaroo annef Amsterdam','nl'),
('Sam tabak souvenirs Amsterdam','nl'),
('C&M Singel 506 Amsterdam','nl'),
('TA-WHA CHIN arts crafts Amsterdam','nl'),
('Butragueno Esteban Madrid','es'),
('Vivaz Rosaleda Madrid','es'),
('Dekorazon Carmen Madrid','es'),
('CH Lumiere Madrid','es'),
]
headers={'User-Agent':'Mozilla/5.0 sebas-agent'}
for q,_ in queries:
    url='https://nominatim.openstreetmap.org/search'
    params={'q':q,'format':'jsonv2','limit':3}
    r=requests.get(url,params=params,headers=headers,timeout=20)
    print('\n###',q, r.status_code)
    try:
        data=r.json()
    except Exception:
        print(r.text[:300]); continue
    for item in data[:3]:
        print(item.get('display_name'), '|', item.get('type'), '|', item.get('category'))
    time.sleep(1)
PY


### R.J. Mulder B.V. Amsterdam 200

### Het Karbeel Amsterdam 200
Het Karbeel, 16, Warmoesstraat, Burgwallen-Oude Zijde, Centrum, Amsterdam, Noord-Holland, Nederland, 1012 JD, Nederland | restaurant | amenity

### La Belle Super Amsterdam 200

### Temple Bar Amsterdam 200
Temple Bar, 2, Kloveniersburgwal, Burgwallen-Oude Zijde, Centrum, Amsterdam, Noord-Holland, Nederland, 1012 CV, Nederland | pub | amenity

### Lamor Amsterdam 200

### B.Smit Amsterdam 200
51B, Smitstraat, Transvaalbuurt, Oost, Amsterdam, Noord-Holland, Nederland, 1092 XR, Nederland | house | place
36B, Smitstraat, Transvaalbuurt, Oost, Amsterdam, Noord-Holland, Nederland, 1092 XT, Nederland | house | place
34B, Smitstraat, Transvaalbuurt, Oost, Amsterdam, Noord-Holland, Nederland, 1092 XT, Nederland | house | place

### Buckaroo annef Amsterdam 200

### Sam tabak souvenirs Amsterdam 200
Sam Tabak & Souvenirs, Bethaniënstraat, Burgwallen-Oude Zijde, Centrum, Amsterdam, Noord-Holland, Nederland, 1012 BZ, Nederland | tobacco | shop

### C&M Singel 506 Amsterdam 200

### TA-WHA CHIN arts crafts Amsterdam 200

### Butragueno Esteban Madrid 200

### Vivaz Rosaleda Madrid 200

### Dekorazon Carmen Madrid 200

### CH Lumiere Madrid 200
Calle Hermanos Lumiere, Polígono Industrial San Marcos, Cerro de los Ángeles, Getafe, Comunidad de Madrid, 28906, España | residential | highway
Calle Hermanos Lumière, Los Villares, Arganda del Rey, Comunidad de Madrid, 28500, España | residential | highway


**Improving location description functions**

I'm considering using Nominatim along with targeted Google Maps pages, but it might be more effective to rely on OpenStreetMap search. I want to gather context about expenses at specific locations, and I think it would be helpful to build out descriptions based on surrounding details, including dates and trip routes. I can add a confidence level to these descriptions and refine them with context like "Restaurant in Amsterdam center, near Warmoesstraat.” This could really enhance the function!

**Identifying unknown merchants**

I have a list of unknowns about businesses. R.J. Mulder B.V. seems to be a small local merchant in Amsterdam, but I'm not sure about the exact type. La Belle Super is likely a supermarket, while Lamor Amsterdam might be a bar or restaurant. BCK*GHM B B V. could be a tourist or souvenir spot near Cafe Katoen. B.Smit seems like a souvenir shop in Amsterdam center. Then I have Buckaroo as a payment processor for Anne Frank tickets—so I’ll describe it as such. The rest, like C&M*Singel 506 and TA-WHA CHIN ARTS & CRA, also seem like souvenir shops.

**Updating lodging information**

I need to handle lodging details for Madrid at the start of the trip. I think I could implement this manually, and I want to ensure I'm showing the descriptions clearly. Although confidence levels weren’t explicitly requested, including them could be helpful. I plan to update the dataset and HTML to incorporate a small confidence tag. If the confidence level is low, I can note that it's based on knowledge or online searches. Let's move forward with this!

edit

KNOWN = [
    ('albert heijn', 'Supermercado neerlandés.'),
    ('jumbo', 'Cadena de supermercado en Países Bajos.'),
    ('condomerie', 'Tienda especializada en preservativos y artículos sexuales.'),
    ('primark', 'Tienda de ropa y básicos de bajo costo.'),
    ('friet point', 'Local de papas fritas y comida rápida.'),
    ('ns groep', 'Ferrocarriles neerlandeses / transporte NS.'),
    ('ns-amsterdam', 'Transporte ferroviario neerlandés / NS.'),
    ('r.j. mulder', 'Comercio local; rubro exacto no confirmado.'),
    ('het karbeel', 'Bar o restaurante local.'),
    ('la belle super', 'Supermercado o tienda de alimentos.'),
    ('temple bar', 'Bar o pub.'),
    ('mcdonald', 'Comida rápida McDonald’s.'),
    ('victoria hotel', 'Consumo en hotel.'),
    ('flagship amsterdam', 'Paseo o excursión turística en barco en Ámsterdam.'),
    ('il primo', 'Restaurante o comida italiana.'),
    ('van gogh museum', 'Entrada al Museo Van Gogh.'),
    ('anne frank', 'Entrada a la Casa de Ana Frank.'),
    ('twinkies bussum', 'Comida o café local.'),
    ('lamor amsterdam', 'Bar, café o restaurante local.'),
    ('the bulldog', 'Coffee shop/bar de Ámsterdam.'),
    ('game galaxy', 'Arcade o local de juegos.'),
    ('cafe katoen', 'Café o restaurante.'),
    ('b.smit', 'Comercio minorista; rubro exacto no confirmado.'),
    ('ghm b b b.v.', 'Comercio local; rubro exacto no confirmado.'),
    ('city phone internet', 'Tienda de telefonía o accesorios.'),
    ('adolfo suarez', 'Cargo de aeropuerto Madrid-Barajas.'),
    ('pending.uber', 'Viaje o cargo pendiente de Uber.'),
    ('intruso', 'Bar o restaurante en Madrid.'),
    ('el corte ingles', 'Gran tienda por departamentos.'),
    ('lefties', 'Tienda de ropa del grupo Inditex.'),
    ('phone shop', 'Tienda de telefonía o accesorios.'),
    ('uniqlo', 'Tienda de ropa Uniqlo.'),
    ('cabify', 'Viaje en Cabify.'),
    ('amazon web services', 'Cargo de AWS / infraestructura cloud.'),
    ('vercel domains', 'Dominio o servicio de Vercel.'),
    ('village rosa', 'Alojamiento en Brasil; fuera del viaje Europa.'),
    ('openai', 'Suscripción de OpenAI / ChatGPT.'),
    ('omio berlin', 'Pasaje o reserva vía Omio.'),
    ('goeuro corp', 'Pasaje o reserva de transporte de Omio/GoEuro.'),
    ('louvreticket', 'Entrada al Museo del Louvre.'),
    ('sagrada familia', 'Entrada a la Sagrada Familia.'),
    ('buckaroo', 'Pasarela de pago; comercio final no claro.'),
    ('versailles', 'Entrada o reserva vinculada a Versalles.'),
    ('biglietteriamusei.vatic', 'Entrada a Museos Vaticanos.'),
    ('duomo.firenze', 'Entrada al Duomo de Florencia.'),
    ('museo del prado', 'Entrada al Museo del Prado.'),
    ('de kroon van singel', 'Bar o restaurante en Ámsterdam.'),
    ('sam tabak', 'Tienda de tabaco o souvenirs.'),
    ('la pampa', 'Restaurante parrilla.'),
    ('c&m*singel', 'Comercio pequeño en Singel; rubro exacto no confirmado.'),
    ('croissanterie outmayer', 'Panadería o cafetería.'),
    ('papizza', 'Cadena o local de pizza rápida.'),
    ('ta-wha chin arts', 'Tienda de artesanías o souvenirs.'),
    ('hotel beursstraat', 'Alojamiento / hotel.'),
    ('kamyin', 'Supermercado o tienda de alimentos asiáticos.'),
    ('zara madrid', 'Tienda de ropa Zara.'),
    ('butragueno esteban', 'Comercio local; rubro exacto no confirmado.'),
    ('vivaz rosaleda', 'Bar o restaurante.'),
    ('viandas de salamanca', 'Tienda gourmet / fiambres / alimentos.'),
    ('dekorazoncarmen', 'Tienda de decoración o regalos.'),
    ('ch lumiere', 'Alojamiento u hotel.'),
    ('taberna la daniela', 'Taberna o restaurante madrileño.'),
    ('deel', 'Transferencia o pago recibido vía Deel.'),
    ('interactive brok', 'Transferencia desde Interactive Brokers.'),
    ('fee for international transaction', 'Comisión bancaria por compra internacional.'),
    ('monthly account service fee', 'Cargo mensual de mantenimiento de cuenta.'),
]

def guess_desc(merchant, category, is_fee, typ, country, trip_scope):
    low = merchant.lower()
    for key, desc in KNOWN:
        if key in low:
            return desc
    if typ == 'income':
        return 'Ingreso, reintegro o transferencia a favor.'
    if is_fee:
        return 'Comisión o cargo bancario.'
    cat_map = {
        'transport': 'Gasto de transporte o traslado.',
        'attraction': 'Entrada o actividad turística.',
        'groceries': 'Compra de supermercado o alimentos.',
        'food_drink': 'Comida, bar o cafetería.',
        'shopping': 'Compra de ropa, accesorios o retail.',
        'lodging': 'Alojamiento u hotel.',
        'subscription': 'Suscripción o servicio digital.',
        'transfer': 'Transferencia o movimiento de dinero.',
        'other': 'Comercio local; rubro exacto no confirmado.',
        'fee': 'Comisión o cargo bancario.',
    }
    desc = cat_map.get(category, 'Comercio local; rubro exacto no confirmado.')
    if trip_scope == 'non_trip' and country in ('US/CA', 'BR'):
        return desc + ' Fuera del viaje Europa.'
    return desc

KNOWN = [
    ('albert heijn', 'Supermercado neerlandés.', 'alta'),
    ('jumbo', 'Cadena de supermercado en Países Bajos.', 'alta'),
    ('condomerie', 'Sex shop conocido de Ámsterdam.', 'alta'),
    ('primark', 'Tienda de ropa y básicos de bajo costo.', 'alta'),
    ('friet point', 'Local de papas fritas y comida rápida.', 'alta'),
    ('ns groep', 'Ferrocarriles neerlandeses / transporte NS.', 'alta'),
    ('ns-amsterdam', 'Transporte ferroviario neerlandés / NS.', 'alta'),
    ('r.j. mulder', 'Comercio local en Ámsterdam; rubro exacto no confirmado.', 'baja'),
    ('het karbeel', 'Restaurante en Warmoesstraat, Ámsterdam.', 'media'),
    ('la belle super', 'Minimercado o tienda de alimentos en Ámsterdam.', 'media'),
    ('temple bar', 'Pub irlandés / bar en Ámsterdam.', 'alta'),
    ('mcdonald', 'Comida rápida McDonald’s.', 'alta'),
    ('victoria hotel', 'Consumo en hotel.', 'alta'),
    ('flagship amsterdam', 'Paseo turístico en barco por canales de Ámsterdam.', 'alta'),
    ('il primo', 'Restaurante italiano.', 'media'),
    ('van gogh museum', 'Entrada al Museo Van Gogh.', 'alta'),
    ('anne frank', 'Entrada a la Casa de Ana Frank.', 'alta'),
    ('twinkies bussum', 'Café o local de comida rápida.', 'media'),
    ('lamor amsterdam', 'Bar o restaurante en Ámsterdam; nombre abreviado en el extracto.', 'baja'),
    ('the bulldog', 'Coffee shop/bar de Ámsterdam.', 'alta'),
    ('game galaxy', 'Arcade o local de videojuegos en Ámsterdam.', 'media'),
    ('cafe katoen', 'Café o restaurante.', 'media'),
    ('b.smit', 'Tienda pequeña en Ámsterdam; probablemente souvenir o retail.', 'baja'),
    ('ghm b b b.v.', 'Comercio local en Ámsterdam; rubro exacto no confirmado.', 'baja'),
    ('city phone internet', 'Tienda de telefonía o accesorios.', 'alta'),
    ('adolfo suarez', 'Cargo en aeropuerto Madrid-Barajas.', 'alta'),
    ('pending.uber', 'Viaje o cargo pendiente de Uber.', 'alta'),
    ('intruso', 'Bar o restaurante en Madrid.', 'media'),
    ('el corte ingles', 'Gran tienda por departamentos.', 'alta'),
    ('lefties', 'Tienda de ropa del grupo Inditex.', 'alta'),
    ('phone shop', 'Tienda de telefonía o accesorios.', 'alta'),
    ('uniqlo', 'Tienda de ropa Uniqlo.', 'alta'),
    ('cabify', 'Viaje en Cabify.', 'alta'),
    ('amazon web services', 'Cargo de AWS / infraestructura cloud.', 'alta'),
    ('vercel domains', 'Dominio o servicio de Vercel.', 'alta'),
    ('village rosa', 'Alojamiento en Brasil; fuera del viaje Europa.', 'media'),
    ('openai', 'Suscripción de OpenAI / ChatGPT.', 'alta'),
    ('omio berlin', 'Pasaje o reserva de transporte vía Omio.', 'alta'),
    ('goeuro corp', 'Pasaje o reserva de transporte de Omio/GoEuro.', 'alta'),
    ('louvreticket', 'Entrada al Museo del Louvre.', 'alta'),
    ('sagrada familia', 'Entrada a la Sagrada Familia.', 'alta'),
    ('buckaroo', 'Pago procesado por Buckaroo, probablemente para entradas Anne Frank.', 'media'),
    ('versailles', 'Entrada o reserva vinculada a Versalles.', 'alta'),
    ('biglietteriamusei.vatic', 'Entrada a Museos Vaticanos.', 'alta'),
    ('duomo.firenze', 'Entrada al Duomo de Florencia.', 'alta'),
    ('museo del prado', 'Entrada al Museo del Prado.', 'alta'),
    ('de kroon van singel', 'Bar o restaurante sobre Singel, Ámsterdam.', 'media'),
    ('sam tabak', 'Tienda de tabaco y souvenirs en Ámsterdam.', 'alta'),
    ('la pampa', 'Restaurante parrilla.', 'alta'),
    ('c&m*singel', 'Comercio pequeño sobre Singel 506, Ámsterdam; rubro exacto no confirmado.', 'baja'),
    ('croissanterie outmayer', 'Panadería o cafetería.', 'media'),
    ('papizza', 'Local de pizza rápida.', 'alta'),
    ('ta-wha chin arts', 'Tienda de artesanías o souvenirs.', 'media'),
    ('hotel beursstraat', 'Alojamiento / hotel en Ámsterdam.', 'alta'),
    ('kamyin', 'Supermercado o tienda de alimentos asiáticos.', 'media'),
    ('zara madrid', 'Tienda de ropa Zara.', 'alta'),
    ('butragueno esteban', 'Comercio local en Madrid; rubro exacto no confirmado.', 'baja'),
    ('vivaz rosaleda', 'Bar o restaurante en Madrid.', 'media'),
    ('viandas de salamanca', 'Tienda gourmet / fiambres / alimentos.', 'alta'),
    ('dekorazoncarmen', 'Tienda de decoración o regalos en Madrid.', 'media'),
    ('ch lumiere', 'Alojamiento en Madrid o reserva de hotel al inicio del viaje.', 'media'),
    ('taberna la daniela', 'Taberna o restaurante madrileño.', 'alta'),
    ('deel', 'Transferencia o pago recibido vía Deel.', 'alta'),
    ('interactive brok', 'Transferencia desde Interactive Brokers.', 'alta'),
    ('fee for international transaction', 'Comisión bancaria por compra internacional.', 'alta'),
    ('monthly account service fee', 'Cargo mensual de mantenimiento de cuenta.', 'alta'),
]

def guess_desc(merchant, category, is_fee, typ, country, trip_scope):
    low = merchant.lower()
    for key, desc, conf in KNOWN:
        if key in low:
            return desc, conf, 'known_or_web'
    if typ == 'income':
        return 'Ingreso, reintegro o transferencia a favor.', 'alta', 'heuristic'
    if is_fee:
        return 'Comisión o cargo bancario.', 'alta', 'heuristic'
    cat_map = {
        'transport': 'Gasto de transporte o traslado.',
        'attraction': 'Entrada o actividad turística.',
        'groceries': 'Compra de supermercado o alimentos.',
        'food_drink': 'Comida, bar o cafetería.',
        'shopping': 'Compra de ropa, accesorios o retail.',
        'lodging': 'Alojamiento u hotel.',
        'subscription': 'Suscripción o servicio digital.',
        'transfer': 'Transferencia o movimiento de dinero.',
        'other': 'Comercio local; rubro exacto no confirmado.',
        'fee': 'Comisión o cargo bancario.',
    }
    desc = cat_map.get(category, 'Comercio local; rubro exacto no confirmado.')
    if trip_scope == 'non_trip' and country in ('US/CA', 'BR'):
        desc += ' Fuera del viaje Europa.'
    conf = 'media' if category not in ('other',) else 'baja'
    return desc, conf, 'heuristic'


Could not find the exact text in /home/sebas/work/gastos-europa/app/build_dataset.py. The old text must match exactly including all whitespace and newlines.


#!/usr/bin/env python3
import re, json, csv
from collections import defaultdict
from pathlib import Path

BASE = Path('/home/sebas/work/gastos-europa')
SRC = BASE / 'raw' / 'extracted-text.txt'
VSRC = BASE / 'raw' / 'vicky-manual.txt'
OUT = BASE / 'derived'

KNOWN = [
    ('albert heijn', 'Supermercado neerlandés.'),
    ('jumbo', 'Cadena de supermercado en Países Bajos.'),
    ('condomerie', 'Tienda especializada en preservativos y artículos sexuales.'),
    ('primark', 'Tienda de ropa y básicos de bajo costo.'),
    ('friet point', 'Local de papas fritas y comida rápida.'),
    ('ns groep', 'Ferrocarriles neerlandeses / transporte NS.'),
    ('ns-amsterdam', 'Transporte ferroviario neerlandés / NS.'),
    ('schiphol', 'Compra o transporte en el aeropuerto Schiphol.'),
    ('r.j. mulder', 'Comercio local; rubro exacto no confirmado.'),
    ('het karbeel', 'Bar o restaurante local.'),
    ('la belle super', 'Supermercado o tienda de alimentos.'),
    ('temple bar', 'Bar o pub.'),
    ('mcdonald', 'Comida rápida McDonald’s.'),
    ('victoria hotel', 'Consumo en hotel.'),
    ('flagship amsterdam', 'Paseo o excursión turística en barco en Ámsterdam.'),
    ('il primo', 'Restaurante o comida italiana.'),
    ('van gogh museum', 'Entrada al Museo Van Gogh.'),
    ('anne frank', 'Entrada a la Casa de Ana Frank.'),
    ('twinkies bussum', 'Comida o café local.'),
    ('lamor amsterdam', 'Bar, café o restaurante local.'),
    ('the bulldog', 'Coffee shop/bar de Ámsterdam.'),
    ('game galaxy', 'Arcade o local de juegos.'),
    ('cafe katoen', 'Café o restaurante.'),
    ('b.smit', 'Comercio minorista; rubro exacto no confirmado.'),
    ('ghm b b b.v.', 'Comercio local; rubro exacto no confirmado.'),
    ('city phone internet', 'Tienda de telefonía o accesorios.'),
    ('adolfo suarez', 'Cargo de aeropuerto Madrid-Barajas.'),
    ('pending.uber', 'Viaje o cargo pendiente de Uber.'),
    ('intruso', 'Bar o restaurante en Madrid.'),
    ('el corte ingles', 'Gran tienda por departamentos.'),
    ('lefties', 'Tienda de ropa del grupo Inditex.'),
    ('phone shop', 'Tienda de telefonía o accesorios.'),
    ('uniqlo', 'Tienda de ropa Uniqlo.'),
    ('cabify', 'Viaje en Cabify.'),
    ('amazon web services', 'Cargo de AWS / infraestructura cloud.'),
    ('vercel domains', 'Dominio o servicio de Vercel.'),
    ('village rosa', 'Alojamiento en Brasil; fuera del viaje Europa.'),
    ('openai', 'Suscripción de OpenAI / ChatGPT.'),
    ('omio berlin', 'Pasaje o reserva vía Omio.'),
    ('goeuro corp', 'Pasaje o reserva de transporte de Omio/GoEuro.'),
    ('louvreticket', 'Entrada al Museo del Louvre.'),
    ('sagrada familia', 'Entrada a la Sagrada Familia.'),
    ('buckaroo', 'Pasarela de pago; comercio final no claro.'),
    ('versailles', 'Entrada o reserva vinculada a Versalles.'),
    ('biglietteriamusei.vatic', 'Entrada a Museos Vaticanos.'),
    ('duomo.firenze', 'Entrada al Duomo de Florencia.'),
    ('museo del prado', 'Entrada al Museo del Prado.'),
    ('de kroon van singel', 'Bar o restaurante en Ámsterdam.'),
    ('sam tabak', 'Tienda de tabaco o souvenirs.'),
    ('la pampa', 'Restaurante parrilla.'),
    ('c&m*singel', 'Comercio pequeño en Singel; rubro exacto no confirmado.'),
    ('croissanterie outmayer', 'Panadería o cafetería.'),
    ('papizza', 'Cadena o local de pizza rápida.'),
    ('ta-wha chin arts', 'Tienda de artesanías o souvenirs.'),
    ('hotel beursstraat', 'Alojamiento / hotel.'),
    ('kamyin', 'Supermercado o tienda de alimentos asiáticos.'),
    ('zara madrid', 'Tienda de ropa Zara.'),
    ('butragueno esteban', 'Comercio local; rubro exacto no confirmado.'),
    ('vivaz rosaleda', 'Bar o restaurante.'),
    ('viandas de salamanca', 'Tienda gourmet / fiambres / alimentos.'),
    ('dekorazoncarmen', 'Tienda de decoración o regalos.'),
    ('ch lumiere', 'Alojamiento u hotel.'),
    ('taberna la daniela', 'Taberna o restaurante madrileño.'),
    ('deel', 'Transferencia o pago recibido vía Deel.'),
    ('interactive brok', 'Transferencia desde Interactive Brokers.'),
    ('fee for international transaction', 'Comisión bancaria por compra internacional.'),
    ('monthly account service fee', 'Cargo mensual de mantenimiento de cuenta.'),
]

def guess_desc(merchant, category, is_fee, typ, country, trip_scope):
    low = merchant.lower()
    for key, desc in KNOWN:
        if key in low:
            return desc
    if typ == 'income':
        return 'Ingreso, reintegro o transferencia a favor.'
    if is_fee:
        return 'Comisión o cargo bancario.'
    cat_map = {
        'transport': 'Gasto de transporte o traslado.',
        'attraction': 'Entrada o actividad turística.',
        'groceries': 'Compra de supermercado o alimentos.',
        'food_drink': 'Comida, bar o cafetería.',
        'shopping': 'Compra de ropa, accesorios o retail.',
        'lodging': 'Alojamiento u hotel.',
        'subscription': 'Suscripción o servicio digital.',
        'transfer': 'Transferencia o movimiento de dinero.',
        'other': 'Comercio local; rubro exacto no confirmado.',
        'fee': 'Comisión o cargo bancario.',
    }
    desc = cat_map.get(category, 'Comercio local; rubro exacto no confirmado.')
    if trip_scope == 'non_trip' and country in ('US/CA', 'BR'):
        return desc + ' Fuera del viaje Europa.'
    return desc

def parse_sources():
    entries=[]
    photo=None; date=None; section=None
    for line in SRC.read_text().splitlines():
        if line.startswith('## photo-'):
            photo=line.strip('# ').strip(); date=None; section=None; continue
        s=line.strip()
        if not s: continue
        if s in ('Pending Transactions','Posted Transactions'):
            section='pending' if 'Pending' in s else 'posted'; continue
        if re.match(r'^\d{2}/\d{2}/\d{4}$', s):
            date=s; continue
        if s.startswith('- ') and ' — ' in s[2:]:
            merchant, amount_s = s[2:].rsplit(' — ',1)
            amt=float(amount_s.replace('$','').replace(',',''))
            entries.append({'source':'sebas_bank','source_ref':photo,'paid_by':'S','date':date or '','status':section or '','merchant':merchant,'amount_usd':amt})
    date=None
    for line in VSRC.read_text().splitlines():
        s=line.strip()
        if not s or s.startswith(('Source:','Payer:','Currency ')): continue
        if re.match(r'^\d{2}/\d{2}/\d{4}$', s):
            date=s; continue
        if s.startswith('- ') and ' — ' in s[2:]:
            merchant, amount_s = s[2:].rsplit(' — ',1)
            amt=float(amount_s.replace('$','').replace(',',''))
            entries.append({'source':'vicky_manual','source_ref':'vicky-manual','paid_by':'V','date':date or '','status':'manual','merchant':merchant,'amount_usd':amt})
    return entries

def enrich(e, idx):
    merchant=e['merchant']; low=merchant.lower(); amt=e['amount_usd']
    typ='expense' if amt<0 else 'income'
    fee=('fee for international transaction' in low or 'monthly account service fee' in low)
    cat='other'; country=''; trip='europe_trip'
    if fee: cat='fee'
    elif any(x in low for x in ['omio','goeuro','cabify','uber','ns ','ns-','schiphol','aerop.','airport','luchthaven']): cat='transport'
    elif any(x in low for x in ['museum','anne frank','sagrada','duomo','vatic','louvreticket','versailles','prado','flagship amsterdam']): cat='attraction'
    elif any(x in low for x in ['albert heijn','jumbo','super','croissanterie','kamyin','viandas']): cat='groceries'
    elif any(x in low for x in ['mcdonald','cafe','bar','friet','restaurant','temple bar','bulldog','il primo','twinkies','lamor','karbeel','katoen','intruso','la pampa','papizza','taberna','de kroon','vivaz']): cat='food_drink'
    elif any(x in low for x in ['uniqlo','lefties','primark','el corte ingles','condomerie','phone shop','city phone','fa. b.smit','zara','dekorazon','tabak','arts & cra','butragueno','sam tabak']): cat='shopping'
    elif any(x in low for x in ['hotel','ch lumiere','beursstraat','village rosa']): cat='lodging'
    elif any(x in low for x in ['openai','aws.amazon','vercel']): cat='subscription'; trip='non_trip'
    elif any(x in low for x in ['deel','interactive brok']): cat='transfer'; trip='non_trip'
    if any(x in merchant for x in [' AMSTERDAM ','Amsterdam','SCHIPHOL','BUSSUM','DAMRAK','Albert Heijn','JUMBO','BCK*NS','BCK*GHM','Buckaroo','Singel','Beursstraat','KamYin','PAPIZZA']): country='NL'
    if any(x in merchant for x in [' MADRID ','GranVia','PRADO','SAGRADA','EL CORTE','LEFTIES','Cabify','ADOLFO SUAREZ','ZARA MADRID','SALAMANCA','CARMEN','ROSALEDA','BUTRAGUENO']): country='ES'
    if any(x in merchant for x in ['PARIS','VERSAILLES','LOUVRE','LUMIERE']): country='FR'
    if 'BERLIN' in merchant: country='DE'
    if 'DUOMO' in merchant: country='IT'
    if 'Vatic' in merchant or 'Vat VA' in merchant: country='VA'
    if 'IMBITUBA BR' in merchant: country='BR'; trip='non_trip'
    if 'WA ON' in merchant or 'CA ON' in merchant or 'Fort Myers FL' in merchant or 'FL ON' in merchant:
        if cat in ('subscription','transfer'): country='US/CA'
    if cat == 'subscription': trip='non_trip'
    desc = guess_desc(merchant, cat, fee, typ, country, trip)
    return {**e,'id':f"{e['source_ref']}:{idx}",'type':typ,'is_fee':fee,'category_guess':cat,'country_guess':country,'trip_scope':trip,'allocation':'','description':desc}

def build():
    OUT.mkdir(parents=True, exist_ok=True)
    entries=[enrich(e,i+1) for i,e in enumerate(parse_sources())]
    (OUT/'transactions.json').write_text(json.dumps(entries, indent=2, ensure_ascii=False))
    with (OUT/'transactions.csv').open('w', newline='') as f:
        w=csv.DictWriter(f, fieldnames=list(entries[0].keys())); w.writeheader(); w.writerows(entries)
    sum_all=sum(e['amount_usd'] for e in entries if e['type']=='expense')
    sum_trip=sum(e['amount_usd'] for e in entries if e['type']=='expense' and e['trip_scope']=='europe_trip')
    sum_nontrip=sum(e['amount_usd'] for e in entries if e['type']=='expense' and e['trip_scope']=='non_trip')
    sum_fees=sum(e['amount_usd'] for e in entries if e['is_fee'])
    sum_income=sum(e['amount_usd'] for e in entries if e['type']=='income')
    by_cat=defaultdict(float); by_country=defaultdict(float); by_payer=defaultdict(float)
    for e in entries:
        if e['type']=='expense' and e['trip_scope']=='europe_trip':
            by_cat[e['category_guess']]+= -e['amount_usd']
            by_country[e['country_guess'] or 'unknown']+= -e['amount_usd']

[7 more lines in file. Use offset=181 to continue.]


KNOWN = [
    ('albert heijn', 'Supermercado neerlandés.'),
    ('jumbo', 'Cadena de supermercado en Países Bajos.'),
    ('condomerie', 'Tienda especializada en preservativos y artículos sexuales.'),
    ('primark', 'Tienda de ropa y básicos de bajo costo.'),
    ('friet point', 'Local de papas fritas y comida rápida.'),
    ('ns groep', 'Ferrocarriles neerlandeses / transporte NS.'),
    ('ns-amsterdam', 'Transporte ferroviario neerlandés / NS.'),
    ('schiphol', 'Compra o transporte en el aeropuerto Schiphol.'),
    ('r.j. mulder', 'Comercio local; rubro exacto no confirmado.'),
    ('het karbeel', 'Bar o restaurante local.'),
    ('la belle super', 'Supermercado o tienda de alimentos.'),
    ('temple bar', 'Bar o pub.'),
    ('mcdonald', 'Comida rápida McDonald’s.'),
    ('victoria hotel', 'Consumo en hotel.'),
    ('flagship amsterdam', 'Paseo o excursión turística en barco en Ámsterdam.'),
    ('il primo', 'Restaurante o comida italiana.'),
    ('van gogh museum', 'Entrada al Museo Van Gogh.'),
    ('anne frank', 'Entrada a la Casa de Ana Frank.'),
    ('twinkies bussum', 'Comida o café local.'),
    ('lamor amsterdam', 'Bar, café o restaurante local.'),
    ('the bulldog', 'Coffee shop/bar de Ámsterdam.'),
    ('game galaxy', 'Arcade o local de juegos.'),
    ('cafe katoen', 'Café o restaurante.'),
    ('b.smit', 'Comercio minorista; rubro exacto no confirmado.'),
    ('ghm b b b.v.', 'Comercio local; rubro exacto no confirmado.'),
    ('city phone internet', 'Tienda de telefonía o accesorios.'),
    ('adolfo suarez', 'Cargo de aeropuerto Madrid-Barajas.'),
    ('pending.uber', 'Viaje o cargo pendiente de Uber.'),
    ('intruso', 'Bar o restaurante en Madrid.'),
    ('el corte ingles', 'Gran tienda por departamentos.'),
    ('lefties', 'Tienda de ropa del grupo Inditex.'),
    ('phone shop', 'Tienda de telefonía o accesorios.'),
    ('uniqlo', 'Tienda de ropa Uniqlo.'),
    ('cabify', 'Viaje en Cabify.'),
    ('amazon web services', 'Cargo de AWS / infraestructura cloud.'),
    ('vercel domains', 'Dominio o servicio de Vercel.'),
    ('village rosa', 'Alojamiento en Brasil; fuera del viaje Europa.'),
    ('openai', 'Suscripción de OpenAI / ChatGPT.'),
    ('omio berlin', 'Pasaje o reserva vía Omio.'),
    ('goeuro corp', 'Pasaje o reserva de transporte de Omio/GoEuro.'),
    ('louvreticket', 'Entrada al Museo del Louvre.'),
    ('sagrada familia', 'Entrada a la Sagrada Familia.'),
    ('buckaroo', 'Pasarela de pago; comercio final no claro.'),
    ('versailles', 'Entrada o reserva vinculada a Versalles.'),
    ('biglietteriamusei.vatic', 'Entrada a Museos Vaticanos.'),
    ('duomo.firenze', 'Entrada al Duomo de Florencia.'),
    ('museo del prado', 'Entrada al Museo del Prado.'),
    ('de kroon van singel', 'Bar o restaurante en Ámsterdam.'),
    ('sam tabak', 'Tienda de tabaco o souvenirs.'),
    ('la pampa', 'Restaurante parrilla.'),
    ('c&m*singel', 'Comercio pequeño en Singel; rubro exacto no confirmado.'),
    ('croissanterie outmayer', 'Panadería o cafetería.'),
    ('papizza', 'Cadena o local de pizza rápida.'),
    ('ta-wha chin arts', 'Tienda de artesanías o souvenirs.'),
    ('hotel beursstraat', 'Alojamiento / hotel.'),
    ('kamyin', 'Supermercado o tienda de alimentos asiáticos.'),
    ('zara madrid', 'Tienda de ropa Zara.'),
    ('butragueno esteban', 'Comercio local; rubro exacto no confirmado.'),
    ('vivaz rosaleda', 'Bar o restaurante.'),
    ('viandas de salamanca', 'Tienda gourmet / fiambres / alimentos.'),
    ('dekorazoncarmen', 'Tienda de decoración o regalos.'),
    ('ch lumiere', 'Alojamiento u hotel.'),
    ('taberna la daniela', 'Taberna o restaurante madrileño.'),
    ('deel', 'Transferencia o pago recibido vía Deel.'),
    ('interactive brok', 'Transferencia desde Interactive Brokers.'),
    ('fee for international transaction', 'Comisión bancaria por compra internacional.'),
    ('monthly account service fee', 'Cargo mensual de mantenimiento de cuenta.'),
]

def guess_desc(merchant, category, is_fee, typ, country, trip_scope):
    low = merchant.lower()
    for key, desc in KNOWN:
        if key in low:
            return desc
    if typ == 'income':
        return 'Ingreso, reintegro o transferencia a favor.'
    if is_fee:
        return 'Comisión o cargo bancario.'
    cat_map = {
        'transport': 'Gasto de transporte o traslado.',
        'attraction': 'Entrada o actividad turística.',
        'groceries': 'Compra de supermercado o alimentos.',
        'food_drink': 'Comida, bar o cafetería.',
        'shopping': 'Compra de ropa, accesorios o retail.',
        'lodging': 'Alojamiento u hotel.',
        'subscription': 'Suscripción o servicio digital.',
        'transfer': 'Transferencia o movimiento de dinero.',
        'other': 'Comercio local; rubro exacto no confirmado.',
        'fee': 'Comisión o cargo bancario.',
    }
    desc = cat_map.get(category, 'Comercio local; rubro exacto no confirmado.')
    if trip_scope == 'non_trip' and country in ('US/CA', 'BR'):
        return desc + ' Fuera del viaje Europa.'
    return desc

KNOWN = [
    ('albert heijn', 'Supermercado neerlandés.', 'alta'),
    ('jumbo', 'Cadena de supermercado en Países Bajos.', 'alta'),
    ('condomerie', 'Sex shop conocido de Ámsterdam.', 'alta'),
    ('primark', 'Tienda de ropa y básicos de bajo costo.', 'alta'),
    ('friet point', 'Local de papas fritas y comida rápida.', 'alta'),
    ('ns groep', 'Ferrocarriles neerlandeses / transporte NS.', 'alta'),
    ('ns-amsterdam', 'Transporte ferroviario neerlandés / NS.', 'alta'),
    ('schiphol', 'Compra o transporte en el aeropuerto Schiphol.', 'alta'),
    ('r.j. mulder', 'Comercio local en Ámsterdam; rubro exacto no confirmado.', 'baja'),
    ('het karbeel', 'Restaurante en Warmoesstraat, Ámsterdam.', 'media'),
    ('la belle super', 'Minimercado o tienda de alimentos en Ámsterdam.', 'media'),
    ('temple bar', 'Pub irlandés / bar en Ámsterdam.', 'alta'),
    ('mcdonald', 'Comida rápida McDonald’s.', 'alta'),
    ('victoria hotel', 'Consumo en hotel.', 'alta'),
    ('flagship amsterdam', 'Paseo turístico en barco por canales de Ámsterdam.', 'alta'),
    ('il primo', 'Restaurante italiano.', 'media'),
    ('van gogh museum', 'Entrada al Museo Van Gogh.', 'alta'),
    ('anne frank', 'Entrada a la Casa de Ana Frank.', 'alta'),
    ('twinkies bussum', 'Café o local de comida rápida.', 'media'),
    ('lamor amsterdam', 'Bar o restaurante en Ámsterdam; nombre abreviado en el extracto.', 'baja'),
    ('the bulldog', 'Coffee shop/bar de Ámsterdam.', 'alta'),
    ('game galaxy', 'Arcade o local de videojuegos en Ámsterdam.', 'media'),
    ('cafe katoen', 'Café o restaurante.', 'media'),
    ('b.smit', 'Tienda pequeña en Ámsterdam; probablemente souvenir o retail.', 'baja'),
    ('ghm b b b.v.', 'Comercio local en Ámsterdam; rubro exacto no confirmado.', 'baja'),
    ('city phone internet', 'Tienda de telefonía o accesorios.', 'alta'),
    ('adolfo suarez', 'Cargo en aeropuerto Madrid-Barajas.', 'alta'),
    ('pending.uber', 'Viaje o cargo pendiente de Uber.', 'alta'),
    ('intruso', 'Bar o restaurante en Madrid.', 'media'),
    ('el corte ingles', 'Gran tienda por departamentos.', 'alta'),
    ('lefties', 'Tienda de ropa del grupo Inditex.', 'alta'),
    ('phone shop', 'Tienda de telefonía o accesorios.', 'alta'),
    ('uniqlo', 'Tienda de ropa Uniqlo.', 'alta'),
    ('cabify', 'Viaje en Cabify.', 'alta'),
    ('amazon web services', 'Cargo de AWS / infraestructura cloud.', 'alta'),
    ('vercel domains', 'Dominio o servicio de Vercel.', 'alta'),
    ('village rosa', 'Alojamiento en Brasil; fuera del viaje Europa.', 'media'),
    ('openai', 'Suscripción de OpenAI / ChatGPT.', 'alta'),
    ('omio berlin', 'Pasaje o reserva de transporte vía Omio.', 'alta'),
    ('goeuro corp', 'Pasaje o reserva de transporte de Omio/GoEuro.', 'alta'),
    ('louvreticket', 'Entrada al Museo del Louvre.', 'alta'),
    ('sagrada familia', 'Entrada a la Sagrada Familia.', 'alta'),
    ('buckaroo', 'Pago procesado por Buckaroo, probablemente para entradas Anne Frank.', 'media'),
    ('versailles', 'Entrada o reserva vinculada a Versalles.', 'alta'),
    ('biglietteriamusei.vatic', 'Entrada a Museos Vaticanos.', 'alta'),
    ('duomo.firenze', 'Entrada al Duomo de Florencia.', 'alta'),
    ('museo del prado', 'Entrada al Museo del Prado.', 'alta'),
    ('de kroon van singel', 'Bar o restaurante sobre Singel, Ámsterdam.', 'media'),
    ('sam tabak', 'Tienda de tabaco y souvenirs en Ámsterdam.', 'alta'),
    ('la pampa', 'Restaurante parrilla.', 'alta'),
    ('c&m*singel', 'Comercio pequeño sobre Singel 506, Ámsterdam; rubro exacto no confirmado.', 'baja'),
    ('croissanterie outmayer', 'Panadería o cafetería.', 'media'),
    ('papizza', 'Local de pizza rápida.', 'alta'),
    ('ta-wha chin arts', 'Tienda de artesanías o souvenirs.', 'media'),
    ('hotel beursstraat', 'Alojamiento / hotel en Ámsterdam.', 'alta'),
    ('kamyin', 'Supermercado o tienda de alimentos asiáticos.', 'media'),
    ('zara madrid', 'Tienda de ropa Zara.', 'alta'),
    ('butragueno esteban', 'Comercio local en Madrid; rubro exacto no confirmado.', 'baja'),
    ('vivaz rosaleda', 'Bar o restaurante en Madrid.', 'media'),
    ('viandas de salamanca', 'Tienda gourmet / fiambres / alimentos.', 'alta'),
    ('dekorazoncarmen', 'Tienda de decoración o regalos en Madrid.', 'media'),
    ('ch lumiere', 'Alojamiento en Madrid o reserva de hotel al inicio del viaje.', 'media'),
    ('taberna la daniela', 'Taberna o restaurante madrileño.', 'alta'),
    ('deel', 'Transferencia o pago recibido vía Deel.', 'alta'),
    ('interactive brok', 'Transferencia desde Interactive Brokers.', 'alta'),
    ('fee for international transaction', 'Comisión bancaria por compra internacional.', 'alta'),
    ('monthly account service fee', 'Cargo mensual de mantenimiento de cuenta.', 'alta'),
]

def guess_desc(merchant, category, is_fee, typ, country, trip_scope):
    low = merchant.lower()
    for key, desc, conf in KNOWN:
        if key in low:
            return desc, conf, 'known_or_web'
    if typ == 'income':
        return 'Ingreso, reintegro o transferencia a favor.', 'alta', 'heuristic'
    if is_fee:
        return 'Comisión o cargo bancario.', 'alta', 'heuristic'
    cat_map = {
        'transport': 'Gasto de transporte o traslado.',
        'attraction': 'Entrada o actividad turística.',
        'groceries': 'Compra de supermercado o alimentos.',
        'food_drink': 'Comida, bar o cafetería.',
        'shopping': 'Compra de ropa, accesorios o retail.',
        'lodging': 'Alojamiento u hotel.',
        'subscription': 'Suscripción o servicio digital.',
        'transfer': 'Transferencia o movimiento de dinero.',
        'other': 'Comercio local; rubro exacto no confirmado.',
        'fee': 'Comisión o cargo bancario.',
    }
    desc = cat_map.get(category, 'Comercio local; rubro exacto no confirmado.')
    if trip_scope == 'non_trip' and country in ('US/CA', 'BR'):
        desc += ' Fuera del viaje Europa.'
    conf = 'media' if category not in ('other',) else 'baja'
    return desc, conf, 'heuristic'

    desc = guess_desc(merchant, cat, fee, typ, country, trip)
    return {**e,'id':f"{e['source_ref']}:{idx}",'type':typ,'is_fee':fee,'category_guess':cat,'country_guess':country,'trip_scope':trip,'allocation':'','description':desc}

    desc, conf, desc_source = guess_desc(merchant, cat, fee, typ, country, trip)
    return {**e,'id':f"{e['source_ref']}:{idx}",'type':typ,'is_fee':fee,'category_guess':cat,'country_guess':country,'trip_scope':trip,'allocation':'','description':desc,'description_confidence':conf,'description_source':desc_source}


Successfully replaced 2 block(s) in /home/sebas/work/gastos-europa/app/build_dataset.py.

     ...
   8 VSRC = BASE / 'raw' / 'vicky-manual.txt'
   9 OUT = BASE / 'derived'
  10
  11 KNOWN = [
- 12     ('albert heijn', 'Supermercado neerlandés.'),
- 13     ('jumbo', 'Cadena de supermercado en Países Bajos.'),
- 14     ('condomerie', 'Tienda especializada en preservativos y artículos sexuales.'),
- 15     ('primark', 'Tienda de ropa y básicos de bajo costo.'),
- 16     ('friet point', 'Local de papas fritas y comida rápida.'),
- 17     ('ns groep', 'Ferrocarriles neerlandeses / transporte NS.'),
- 18     ('ns-amsterdam', 'Transporte ferroviario neerlandés / NS.'),
- 19     ('schiphol', 'Compra o transporte en el aeropuerto Schiphol.'),
- 20     ('r.j. mulder', 'Comercio local; rubro exacto no confirmado.'),
- 21     ('het karbeel', 'Bar o restaurante local.'),
- 22     ('la belle super', 'Supermercado o tienda de alimentos.'),
- 23     ('temple bar', 'Bar o pub.'),
- 24     ('mcdonald', 'Comida rápida McDonald’s.'),
- 25     ('victoria hotel', 'Consumo en hotel.'),
- 26     ('flagship amsterdam', 'Paseo o excursión turística en barco en Ámsterdam.'),
- 27     ('il primo', 'Restaurante o comida italiana.'),
- 28     ('van gogh museum', 'Entrada al Museo Van Gogh.'),
- 29     ('anne frank', 'Entrada a la Casa de Ana Frank.'),
- 30     ('twinkies bussum', 'Comida o café local.'),
- 31     ('lamor amsterdam', 'Bar, café o restaurante local.'),
- 32     ('the bulldog', 'Coffee shop/bar de Ámsterdam.'),
- 33     ('game galaxy', 'Arcade o local de juegos.'),
- 34     ('cafe katoen', 'Café o restaurante.'),
- 35     ('b.smit', 'Comercio minorista; rubro exacto no confirmado.'),
- 36     ('ghm b b b.v.', 'Comercio local; rubro exacto no confirmado.'),
- 37     ('city phone internet', 'Tienda de telefonía o accesorios.'),
- 38     ('adolfo suarez', 'Cargo de aeropuerto Madrid-Barajas.'),
- 39     ('pending.uber', 'Viaje o cargo pendiente de Uber.'),
- 40     ('intruso', 'Bar o restaurante en Madrid.'),
- 41     ('el corte ingles', 'Gran tienda por departamentos.'),
- 42     ('lefties', 'Tienda de ropa del grupo Inditex.'),
- 43     ('phone shop', 'Tienda de telefonía o accesorios.'),
- 44     ('uniqlo', 'Tienda de ropa Uniqlo.'),
- 45     ('cabify', 'Viaje en Cabify.'),
- 46     ('amazon web services', 'Cargo de AWS / infraestructura cloud.'),
- 47     ('vercel domains', 'Dominio o servicio de Vercel.'),
- 48     ('village rosa', 'Alojamiento en Brasil; fuera del viaje Europa.'),
- 49     ('openai', 'Suscripción de OpenAI / ChatGPT.'),
- 50     ('omio berlin', 'Pasaje o reserva vía Omio.'),
- 51     ('goeuro corp', 'Pasaje o reserva de transporte de Omio/GoEuro.'),
- 52     ('louvreticket', 'Entrada al Museo del Louvre.'),
- 53     ('sagrada familia', 'Entrada a la Sagrada Familia.'),
- 54     ('buckaroo', 'Pasarela de pago; comercio final no claro.'),
- 55     ('versailles', 'Entrada o reserva vinculada a Versalles.'),
- 56     ('biglietteriamusei.vatic', 'Entrada a Museos Vaticanos.'),
- 57     ('duomo.firenze', 'Entrada al Duomo de Florencia.'),
- 58     ('museo del prado', 'Entrada al Museo del Prado.'),
- 59     ('de kroon van singel', 'Bar o restaurante en Ámsterdam.'),
- 60     ('sam tabak', 'Tienda de tabaco o souvenirs.'),
- 61     ('la pampa', 'Restaurante parrilla.'),
- 62     ('c&m*singel', 'Comercio pequeño en Singel; rubro exacto no confirmado.'),
- 63     ('croissanterie outmayer', 'Panadería o cafetería.'),
- 64     ('papizza', 'Cadena o local de pizza rápida.'),
- 65     ('ta-wha chin arts', 'Tienda de artesanías o souvenirs.'),
- 66     ('hotel beursstraat', 'Alojamiento / hotel.'),
- 67     ('kamyin', 'Supermercado o tienda de alimentos asiáticos.'),
- 68     ('zara madrid', 'Tienda de ropa Zara.'),
- 69     ('butragueno esteban', 'Comercio local; rubro exacto no confirmado.'),
- 70     ('vivaz rosaleda', 'Bar o restaurante.'),
- 71     ('viandas de salamanca', 'Tienda gourmet / fiambres / alimentos.'),
- 72     ('dekorazoncarmen', 'Tienda de decoración o regalos.'),
- 73     ('ch lumiere', 'Alojamiento u hotel.'),
- 74     ('taberna la daniela', 'Taberna o restaurante madrileño.'),
- 75     ('deel', 'Transferencia o pago recibido vía Deel.'),
- 76     ('interactive brok', 'Transferencia desde Interactive Brokers.'),
- 77     ('fee for international transaction', 'Comisión bancaria por compra internacional.'),
- 78     ('monthly account service fee', 'Cargo mensual de mantenimiento de cuenta.'),
+ 12     ('albert heijn', 'Supermercado neerlandés.', 'alta'),
+ 13     ('jumbo', 'Cadena de supermercado en Países Bajos.', 'alta'),
+ 14     ('condomerie', 'Sex shop conocido de Ámsterdam.', 'alta'),
+ 15     ('primark', 'Tienda de ropa y básicos de bajo costo.', 'alta'),
+ 16     ('friet point', 'Local de papas fritas y comida rápida.', 'alta'),
+ 17     ('ns groep', 'Ferrocarriles neerlandeses / transporte NS.', 'alta'),
+ 18     ('ns-amsterdam', 'Transporte ferroviario neerlandés / NS.', 'alta'),
+ 19     ('schiphol', 'Compra o transporte en el aeropuerto Schiphol.', 'alta'),
+ 20     ('r.j. mulder', 'Comercio local en Ámsterdam; rubro exacto no confirmado.', 'baja'),
+ 21     ('het karbeel', 'Restaurante en Warmoesstraat, Ámsterdam.', 'media'),
+ 22     ('la belle super', 'Minimercado o tienda de alimentos en Ámsterdam.', 'media'),
+ 23     ('temple bar', 'Pub irlandés / bar en Ámsterdam.', 'alta'),
+ 24     ('mcdonald', 'Comida rápida McDonald’s.', 'alta'),
+ 25     ('victoria hotel', 'Consumo en hotel.', 'alta'),
+ 26     ('flagship amsterdam', 'Paseo turístico en barco por canales de Ámsterdam.', 'alta'),
+ 27     ('il primo', 'Restaurante italiano.', 'media'),
+ 28     ('van gogh museum', 'Entrada al Museo Van Gogh.', 'alta'),
+ 29     ('anne frank', 'Entrada a la Casa de Ana Frank.', 'alta'),
+ 30     ('twinkies bussum', 'Café o local de comida rápida.', 'media'),
+ 31     ('lamor amsterdam', 'Bar o restaurante en Ámsterdam; nombre abreviado en el extracto.', 'baja'),
+ 32     ('the bulldog', 'Coffee shop/bar de Ámsterdam.', 'alta'),
+ 33     ('game galaxy', 'Arcade o local de videojuegos en Ámsterdam.', 'media'),
+ 34     ('cafe katoen', 'Café o restaurante.', 'media'),
+ 35     ('b.smit', 'Tienda pequeña en Ámsterdam; probablemente souvenir o retail.', 'baja'),
+ 36     ('ghm b b b.v.', 'Comercio local en Ámsterdam; rubro exacto no confirmado.', 'baja'),
+ 37     ('city phone internet', 'Tienda de telefonía o accesorios.', 'alta'),
+ 38     ('adolfo suarez', 'Cargo en aeropuerto Madrid-Barajas.', 'alta'),
+ 39     ('pending.uber', 'Viaje o cargo pendiente de Uber.', 'alta'),
+ 40     ('intruso', 'Bar o restaurante en Madrid.', 'media'),
+ 41     ('el corte ingles', 'Gran tienda por departamentos.', 'alta'),
+ 42     ('lefties', 'Tienda de ropa del grupo Inditex.', 'alta'),
+ 43     ('phone shop', 'Tienda de telefonía o accesorios.', 'alta'),
+ 44     ('uniqlo', 'Tienda de ropa Uniqlo.', 'alta'),
+ 45     ('cabify', 'Viaje en Cabify.', 'alta'),
+ 46     ('amazon web services', 'Cargo de AWS / infraestructura cloud.', 'alta'),
+ 47     ('vercel domains', 'Dominio o servicio de Vercel.', 'alta'),
+ 48     ('village rosa', 'Alojamiento en Brasil; fuera del viaje Europa.', 'media'),
+ 49     ('openai', 'Suscripción de OpenAI / ChatGPT.', 'alta'),
+ 50     ('omio berlin', 'Pasaje o reserva de transporte vía Omio.', 'alta'),
+ 51     ('goeuro corp', 'Pasaje o reserva de transporte de Omio/GoEuro.', 'alta'),
+ 52     ('louvreticket', 'Entrada al Museo del Louvre.', 'alta'),
+ 53     ('sagrada familia', 'Entrada a la Sagrada Familia.', 'alta'),
+ 54     ('buckaroo', 'Pago procesado por Buckaroo, probablemente para entradas Anne Frank.', 'media'),
+ 55     ('versailles', 'Entrada o reserva vinculada a Versalles.', 'alta'),
+ 56     ('biglietteriamusei.vatic', 'Entrada a Museos Vaticanos.', 'alta'),
+ 57     ('duomo.firenze', 'Entrada al Duomo de Florencia.', 'alta'),
+ 58     ('museo del prado', 'Entrada al Museo del Prado.', 'alta'),
+ 59     ('de kroon van singel', 'Bar o restaurante sobre Singel, Ámsterdam.', 'media'),
+ 60     ('sam tabak', 'Tienda de tabaco y souvenirs en Ámsterdam.', 'alta'),
+ 61     ('la pampa', 'Restaurante parrilla.', 'alta'),
+ 62     ('c&m*singel', 'Comercio pequeño sobre Singel 506, Ámsterdam; rubro exacto no confirmado.', 'baja'),
+ 63     ('croissanterie outmayer', 'Panadería o cafetería.', 'media'),
+ 64     ('papizza', 'Local de pizza rápida.', 'alta'),
+ 65     ('ta-wha chin arts', 'Tienda de artesanías o souvenirs.', 'media'),
+ 66     ('hotel beursstraat', 'Alojamiento / hotel en Ámsterdam.', 'alta'),
+ 67     ('kamyin', 'Supermercado o tienda de alimentos asiáticos.', 'media'),
+ 68     ('zara madrid', 'Tienda de ropa Zara.', 'alta'),
+ 69     ('butragueno esteban', 'Comercio local en Madrid; rubro exacto no confirmado.', 'baja'),
+ 70     ('vivaz rosaleda', 'Bar o restaurante en Madrid.', 'media'),
+ 71     ('viandas de salamanca', 'Tienda gourmet / fiambres / alimentos.', 'alta'),
+ 72     ('dekorazoncarmen', 'Tienda de decoración o regalos en Madrid.', 'media'),
+ 73     ('ch lumiere', 'Alojamiento en Madrid o reserva de hotel al inicio del viaje.', 'media'),
+ 74     ('taberna la daniela', 'Taberna o restaurante madrileño.', 'alta'),
+ 75     ('deel', 'Transferencia o pago recibido vía Deel.', 'alta'),
+ 76     ('interactive brok', 'Transferencia desde Interactive Brokers.', 'alta'),
+ 77     ('fee for international transaction', 'Comisión bancaria por compra internacional.', 'alta'),
+ 78     ('monthly account service fee', 'Cargo mensual de mantenimiento de cuenta.', 'alta'),
  79 ]
  80
  81 def guess_desc(merchant, category, is_fee, typ, country, trip_scope):
  82     low = merchant.lower()
- 83     for key, desc in KNOWN:
+ 83     for key, desc, conf in KNOWN:
  84         if key in low:
- 85             return desc
+ 85             return desc, conf, 'known_or_web'
  86     if typ == 'income':
- 87         return 'Ingreso, reintegro o transferencia a favor.'
+ 87         return 'Ingreso, reintegro o transferencia a favor.', 'alta', 'heuristic'
  88     if is_fee:
- 89         return 'Comisión o cargo bancario.'
+ 89         return 'Comisión o cargo bancario.', 'alta', 'heuristic'
  90     cat_map = {
  91         'transport': 'Gasto de transporte o traslado.',
  92         'attraction': 'Entrada o actividad turística.',
  93         'groceries': 'Compra de supermercado o alimentos.',
     ...
 100         'fee': 'Comisión o cargo bancario.',
 101     }
 102     desc = cat_map.get(category, 'Comercio local; rubro exacto no confirmado.')
 103     if trip_scope == 'non_trip' and country in ('US/CA', 'BR'):
-104         return desc + ' Fuera del viaje Europa.'
-105     return desc
+104         desc += ' Fuera del viaje Europa.'
+105     conf = 'media' if category not in ('other',) else 'baja'
+106     return desc, conf, 'heuristic'
 106
 107
 108 def parse_sources():
 109     entries=[]
     ...
 157     if 'IMBITUBA BR' in merchant: country='BR'; trip='non_trip'
 158     if 'WA ON' in merchant or 'CA ON' in merchant or 'Fort Myers FL' in merchant or 'FL ON' in merchant:
 159         if cat in ('subscription','transfer'): country='US/CA'
 160     if cat == 'subscription': trip='non_trip'
-161     desc = guess_desc(merchant, cat, fee, typ, country, trip)
-162     return {**e,'id':f"{e['source_ref']}:{idx}",'type':typ,'is_fee':fee,'category_guess':cat,'country_guess':country,'trip_scope':trip,'allocation':'','description':desc}
+162     desc, conf, desc_source = guess_desc(merchant, cat, fee, typ, country, trip)
+163     return {**e,'id':f"{e['source_ref']}:{idx}",'type':typ,'is_fee':fee,'category_guess':cat,'country_guess':country,'trip_scope':trip,'allocation':'','description':desc,'description_confidence':conf,'description_source':desc_source}
 163
 164
 165 def build():
 166     OUT.mkdir(parents=True, exist_ok=True)
     ...


bash

python3 /home/sebas/work/gastos-europa/app/build_dataset.py && python3 - <<'PY'
import json
p='/home/sebas/work/gastos-europa/derived/transactions.json'
d=json.load(open(p))
for name in ['Het Karbeel','Zettle *Sam tabak & souve','VISA Buckaroo *www annef AMSTERDAM NL ON 04-01 2561','R.J. Mulder B.V.']:
  for x in d:
    if x['merchant']==name:
      print(name,'=>',x['description'],'|',x['description_confidence'])
      break
PY


Het Karbeel => Restaurante en Warmoesstraat, Ámsterdam. | media
Zettle *Sam tabak & souve => Tienda de tabaco y souvenirs en Ámsterdam. | alta
VISA Buckaroo *www annef AMSTERDAM NL ON 04-01 2561 => Pago procesado por Buckaroo, probablemente para entradas Anne Frank. | media
R.J. Mulder B.V. => Comercio local en Ámsterdam; rubro exacto no confirmado. | baja


edit

function mobileRow(x){
  return `

quién

${esc(x.paid_by==='S'?'Sebas':'Vicky')}${esc(x.source_ref)}

fecha

${esc(x.date||'')}

movimiento

${esc(x.merchant)}

${esc(x.description||'')}

${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

cat

 ${esc(x.category_guess)}${x.is_fee?'fee':''}

usd
<div class="value amt ${x.amount_usd<0?'neg':'pos'}">${fmt(x.amount_usd)}

marcar

${allocButtons(x)}

`;
}

function confTag(x){ return x.description_confidence ? `<span class="tag conf-${esc(x.description_confidence)}">${esc(x.description_confidence)}</span>` : ''; }
function mobileRow(x){
  return `

quién

${esc(x.paid_by==='S'?'Sebas':'Vicky')}${esc(x.source_ref)}

fecha

${esc(x.date||'')}

movimiento

${esc(x.merchant)}

${esc(x.description||'')}

${confTag(x)}${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

cat

 ${esc(x.category_guess)}${x.is_fee?'fee':''}

usd
<div class="value amt ${x.amount_usd<0?'neg':'pos'}">${fmt(x.amount_usd)}

marcar

${allocButtons(x)}

`;
}

function desktopRow(x){
  return `

${esc(x.paid_by==='S'?'Sebas':'Vicky')}${esc(x.source_ref)}

    ${esc(x.date||'')}

${esc(x.merchant)}

${esc(x.description||'')}

${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

    ${esc(x.category_guess)}${x.is_fee?'fee':''}
    <td class="right amt ${x.amount_usd<0?'neg':'pos'}">${fmt(x.amount_usd)}

${allocButtons(x)}

`;
}

function desktopRow(x){
  return `

${esc(x.paid_by==='S'?'Sebas':'Vicky')}${esc(x.source_ref)}

    ${esc(x.date||'')}

${esc(x.merchant)}

${esc(x.description||'')}

${confTag(x)}${esc(x.status||'')} · ${esc(x.country_guess||'?')} · ${esc(x.trip_scope)}

    ${esc(x.category_guess)}${x.is_fee?'fee':''}
    <td class="right amt ${x.amount_usd<0?'neg':'pos'}">${fmt(x.amount_usd)}

${allocButtons(x)}

`;
}