---
name: shared-expense-bill-splitter-and-settle-up-planner
description: Divide un registro CSV de costos compartidos para un viaje, piso o evento - igual, acciones, exacto, porcentaje y facturas detalladas con impuestos y propina prorrateados, exclusiones 'todos excepto', varios pagadores, reembolsos, pagos y múltiples monedas a tasas acordadas - en centavos, luego encuentra la cantidad comprobablemente mínima de transferencias de liquidación (búsqueda exacta de subgrupos de suma cero). Devuelve saldos, una auditoría por persona y un mensaje de chat. Incluye un planificador en Python de la biblioteca estándar con --self-test.
---
# Divisor de Gastos Compartidos y Planificador de Liquidación
Conviertes una lista desordenada de gastos compartidos en una división justa y verificable y la lista más corta posible de pagos. Mantienes el registro como un CSV que el grupo puede editar en cualquier hoja de cálculo, aplicas la regla de división que cada línea realmente necesita (quién estuvo, quién pidió qué, quién se quedó menos noches), conviertes monedas a tasas que el grupo acuerda, y liquidas a todos con la menor cantidad de transferencias. Cada centavo está contabilizado, y cualquiera puede rastrear su propio número línea por línea.
## Archivos en esta habilidad
- `scripts/settle_up.py` - lee el registro CSV, divide cada línea en centavos enteros (redondeo de resto mayor), calcula pagado/parte/neta por persona, y busca todos los subgrupos de suma cero para encontrar el número mínimo de transferencias (exacto hasta 18 personas con saldo, codicioso más allá) (solo biblioteca estándar de Python 3.8+)
- `references/split-methods.md` - el formato CSV, qué división usar para qué costo, facturas detalladas con impuestos y propina, exclusiones, pagadores, reembolsos y pagos
- `references/settle-up-and-currency.md` - por qué el mínimo puede estar por debajo de "personas - 1", cómo funciona la búsqueda, redondeo, tasas de cambio y comisiones de tarjeta, etiqueta de pago
- `templates/settle-up-summary.md` - el resumen para entregar y pegar en el chat del grupo
- `examples/lisbon-trip.csv` - 5 amigos, 11 líneas: partes por noches, una persona excluida, una cena detallada, una reserva en USD, asientos premium, dos pagadores, un reembolso, bocadillos en GBP y un pago ya realizado
- `examples/lisbon-settle-output.md` - la salida real del planificador en ese registro
- `examples/flat-october.csv` - 6 compañeros de piso donde el mínimo (4 transferencias) supera a pagar la deuda más grande al crédito más grande (5)
## Flujo de trabajo
### 1. Recopilar el registro
Pide cada costo compartido con: qué, quién pagó (y cuánto cada uno, si varias personas pagaron), el monto y la moneda, y para quién fue. Haz las preguntas que cambian la división: ¿Alguien llegó tarde o se fue temprano? ¿Alguien se saltó una comida o actividad? ¿Una cena fue compartida o pedida individualmente? ¿Alguien ya le pagó a otro? Acuerda la moneda base y las tasas (una tasa por moneda para todo el viaje a menos que el grupo quiera las tasas del estado de cuenta de la tarjeta). Escríbelo como el CSV en `references/split-methods.md`, con `# people:` para que se detecten errores tipográficos en los nombres.
### 2. Ejecutar el planificador
```
python3 scripts/settle_up.py examples/lisbon-trip.csv
python3 scripts/settle_up.py flat.csv --base GBP --rates EUR=0.86 --detail Ben
python3 scripts/settle_up.py ledger.csv --json
python3 scripts/settle_up.py --self-test
```
Salida 0 = plan impreso (lee la sección de Advertencias), 2 = el registro tiene un error (sumas que no coinciden, tasa faltante, división o nombre desconocido) - corrige la línea que nombra. Si no puedes ejecutar código, sigue los mismos pasos a mano: convierte cada línea a centavos base, divide, suma pagado menos parte por persona, luego empareja deudores con acreedores, buscando primero personas o pequeños grupos cuyos saldos se cancelen exactamente.
### 3. Verificar el resultado con el grupo
- La línea de verificación debe decir `nets add up to 0.00`; pagado y partes deben ser iguales.
- Lee los saldos en voz alta en palabras simples ("Ana pagó 990.00, su parte es 377.31 y Eli ya le dio 100.00, así que recibe 512.69").
- Señala las líneas que impulsan el resultado y son fáciles de disputar (partes por noches, comidas detalladas, un reembolso, un pago), y ofrece `--detail <nombre>` para cualquiera que quiera su propio desglose.
- Si alguien prefiere pagarle a otra persona (o no puede pagarle directamente), vuelve a ejecutar después de agregar la transferencia acordada como una línea `payment`; el plan recalcula el resto.
### 4. Entregar
Completa `templates/settle-up-summary.md`: totales, la tabla de saldos, las transferencias numeradas, los supuestos (tasas, quién fue excluido dónde, cómo se repartieron impuestos/propina), y el mensaje corto para el chat del grupo. Sugiere marcar cada transferencia como hecha agregándola al registro como una línea `payment`, para que una segunda ejecución muestre a todos en 0.00.
## Ejemplo de resultado
En `examples/lisbon-trip.csv` (5 personas, 1,583.07 EUR gastados) Ana pagó el apartamento y los boletos de fado y recibe 512.69 EUR; el plan es 4 transferencias: Ben paga a Ana 243.17, Dara paga a Ana 140.94, Eli paga a Ana 128.58 y Eli paga a Cem 0.85 (el pago anterior de 100.00 de Eli ya está contado). En `examples/flat-october.csv` Bo y Finn se cancelan mutuamente, así que 6 compañeros de piso se liquidan en 4 transferencias donde pagar la deuda más grande al crédito más grande tomaría 5.
## Reglas
- Nunca inventes montos, tasas o quién participó; pregunta, o marca la línea como un supuesto en el resumen.
- Usa una tasa acordada por moneda y muéstrala; no mezcles tasas en vivo en una división terminada.
- Mantén toda la aritmética en centavos y muestra a dónde fueron los centavos de redondeo (resto mayor, empates a la persona listada primero).
- No juzgues los gastos; señala solo líneas que parezcan duplicados, errores tipográficos o participantes faltantes.
- Un pago es una línea `payment`, nunca un gasto negativo; un reembolso es un gasto negativo compartido por las mismas personas que el costo original.
- Presenta el plan como una propuesta; el grupo puede intercambiar quién paga a quién siempre que la neta de cada persona no cambie.
FILE:references/split-methods.md
# Métodos de división y formato CSV
## Columnas
`date,description,paid_by,amount,currency,split,participants,details` - fila de encabezado requerida; cualquier hoja de cálculo puede exportarla. Líneas de comentario opcionales antes del encabezado:
```
# people: Ana, Ben, Cem, Dara, Eli (lista estricta: un nombre mal escrito es un error)
# base: EUR
# rates: USD=0.92, GBP=1.16 (1 USD = 0.92 EUR)
```
Sin `# people:` todos los nombrados en cualquier parte del archivo son miembros y `all` significa todos ellos; los nombres se comparan sin distinguir mayúsculas.
## Elegir una división
| Costo | División | Ejemplo `participants` / `details` |
|---|---|---|
| Compras, taxi, combustible, boletos compartidos | `equal` | `all`, `Ana;Ben`, `all-Eli` |
| Alojamiento cuando la gente se quedó diferentes noches, habitaciones de diferente tamaño | `shares` | `Ana:3;Ben:3;Dara:2` (noches o pesos de habitación) |
| Boletos con diferentes precios, un monto conocido por persona | `exact` | `Ana:30;Ben:45` (otros en participants dividen el resto por igual) |
| Porcentajes acordados (alquiler por ingresos, vino para quienes bebieron) | `percent` | `Ana:25;Ben:25;Dara:50` (debe totalizar 100) |
| Cuenta de restaurante donde la gente pidió por separado | `itemized` | `Ana:31;Ben:28.50;shared:42` |
| Alguien ya le pagó a otro | `payment` | participants = el único destinatario |
## Facturas detalladas
- `details` lista los artículos de cada persona antes de impuestos, servicio y propina; `shared` es la suma de entrantes, botellas o cualquier cosa que todos compartieron (dividido por igual entre `participants`).
- `amount` es la factura final. La diferencia (amount - artículos) es impuestos, servicio y propina, repartidos en proporción a lo que cada persona tuvo, así que quien pidió más paga más propina. La salida lo muestra como `+17.3% tax/tip`.
- Si los artículos suman más que la factura (un descuento o un error tipográfico), el planificador se detiene; ingresa el descuento bajando los precios de los artículos a los que se aplicó.
## Exclusiones y participación parcial
- `all-Eli` o `all except Eli` = todos menos Eli; encadena varios: `all-Bo-Finn`.
- Para personas presentes parte del tiempo, usa `shares` con noches o días en lugar de excluirlas.
- Un niño o invitado pagado por un miembro: listalo bajo el nombre del miembro (peso `shares` 1.5) o agrégalo a `# people:` y liquida con su padre.
## Pagadores
- Un pagador: `Ana`. Varios pagadores en una factura: `Ben:40;Cem:20` (montos en la moneda de la fila, deben sumar `amount`).
- Una tarjeta compartida por una pareja: trata a la pareja como una persona o divide el monto del pagador.
## Reembolsos, depósitos, pagos
- Reembolso recibido después de una cancelación: `amount` negativo, `paid_by` = quién recibió el dinero, mismos participants que el costo original.
- Depósito devuelto por un arrendador: `amount` negativo dividido igual que el depósito.
- Dinero entregado entre miembros: `payment` (de `paid_by` a la única persona en `participants`). Registra las transferencias de liquidación de la misma manera; volver a ejecutar muestra quién sigue pendiente.
## Errores comunes que el planificador detecta
- Montos exactos o porcentajes que no suman, montos de pagadores que difieren de la factura, artículos sobre la factura.
- Una moneda sin tasa, una palabra de división desconocida, un nombre mal escrito cuando `# people:` está configurado.
- Dos líneas idénticas (misma fecha, descripción, monto y pagador) - reportado como advertencia, no eliminado.
- Montos cero - omitidos con una advertencia.
FILE:references/settle-up-and-currency.md
# Liquidación y moneda
## Saldo neto
Para cada persona: `net = paid + payments sent - share - payments received`. Positivo = recibe dinero, negativo = debe. Las netas siempre suman exactamente 0.00 porque cada línea se divide en centavos enteros que suman el total de la línea.
## Por qué "menos transferencias" puede ser menos que personas - 1
- Cualquier grupo con k personas que tienen saldo puede liquidarse en como máximo k - 1 transferencias (cada transferencia limpia al menos a una persona).
- Si el grupo se divide en subgrupos cuyos saldos cada uno suma cero, cada subgrupo se liquida por su cuenta: con g tales subgrupos el mínimo es k - g. Ejemplo: Bo +300 y Finn -300 se cancelan, así que 6 compañeros de piso necesitan 4 transferencias, no 5.
- Pagar la deuda más grande al crédito más grande (el enfoque habitual de las aplicaciones) no busca estos subgrupos y puede tomar más transferencias.
- Encontrar la mejor división en subgrupos de suma cero es un problema difícil en general; el planificador lo resuelve exactamente para hasta 18 personas con saldo no cero (programación dinámica sobre subconjuntos, en menos de un segundo) y cae al plan codicioso más allá, diciéndolo.
- Dentro de cada subgrupo el plan paga la deuda más grande al crédito más grande, lo que usa exactamente (tamaño del subgrupo - 1) transferencias.
- Varios planes pueden tener el mismo conteo mínimo; cualquiera de ellos es justo, porque el total de entrada o salida de cada persona está fijado por su neta.
## Redondeo
- Cada línea se convierte a centavos base una vez (`amount x rate`, redondeado al medio hacia arriba), luego se divide con el método de resto mayor: todos reciben el monto redondeado hacia abajo, y los centavos sobrantes van a las fracciones más grandes, empates a la persona listada primero en `# people:`.
- Durante todo un viaje esto mueve como máximo unos pocos centavos por persona; dilo si alguien pregunta por qué 10.00 / 3 es 3.34 para una persona.
## Tasas de cambio
- Acuerda una tasa por moneda antes de dividir: la tasa del día en que se pagaron la mayoría de los costos, el promedio del viaje, o la tasa del estado de cuenta de la tarjeta del pagador (que incluye el margen del banco).
- Si la tarjeta del pagador cobró una comisión por transacción extranjera, inclúyela en el monto (el grupo la comparte) o déjala fuera (el pagador la absorbe) - decide una vez para todo el viaje.
- Ingresa montos en la moneda realmente pagada y deja que el planificador convierta; no pre-conviertas algunas líneas a mano.
- Liquida en la moneda base. Si alguien paga en otra moneda, convierte la transferencia a la misma tasa acordada y regístrala como una línea `payment` en esa moneda.
## Etiqueta de pago
- Envía el mensaje del chat del grupo con las transferencias y un enlace al registro; da a la gente un día o dos para disputar una línea antes de pagar.
- Las transferencias pequeñas (menos de ~2 EUR/USD) pueden omitirse o incorporarse al próximo costo compartido si ambas personas están de acuerdo - regístralo como una línea `payment` para que el registro siga exacto.
- Cuando todos hayan pagado, agrega cada transferencia como una línea `payment` y vuelve a ejecutar: cada neta debe ser 0.00.
FILE:templates/settle-up-summary.md
# Liquidación: <viaje / piso / evento>, <fechas>
**Grupo:** <Ana, Ben, Cem, Dara, Eli> **Moneda base:** <EUR> **Total gastado:** <1,583.07 EUR> en <11> líneas
**Tasas acordadas:** <1 USD = 0.92 EUR, 1 GBP = 1.16 EUR (promedio del viaje)>
## Saldos
| Persona | Pagó | Parte justa | Ya enviado / recibido | Neta |
|---|---|---|---|---|
| <Ana> | <990.00> | <377.31> | <recibido 100.00> | <+512.69 recibe> |
| <Ben> | <64.00> | <307.17> | <-> | <-243.17 debe> |
## Transferencias (<n>, el mínimo posible)
1. <Ben paga a Ana 243.17 EUR>
2. <Dara paga a Ana 140.94 EUR>
3. <...>
## Cómo se dividieron las líneas
- <Apartamento: por noches, Dara y Eli 2 de 3 noches>
- <Taxi al aeropuerto: todos excepto Eli (llegó en tren)>
- <Cena: detallada, 17.3% de impuestos y propina repartidos por lo que cada persona pidió; entrantes 42.00 compartidos>
- <Reembolso de boletos del palacio: de vuelta a Ben y Cem, quienes los pagaron>
- <Los 100.00 de Eli a Ana ya están contados>
## Supuestos y puntos abiertos
- <tasas anteriores; comisiones de tarjeta no incluidas>
- <verifica la línea 5 (clase de surf): ¿estaba Ben?>
## Mensaje para el chat del grupo
```
Liquidación (<EUR>, <11> líneas, tasas: <USD 0.92, GBP 1.16>):
- <Ben -> Ana: 243.17 EUR>
- <Dara -> Ana: 140.94 EUR>
Tabla completa: <enlace a la hoja>. Avisa dentro de 2 días si una línea está mal.
```
**Cuando se pague:** agrega cada transferencia como una línea `payment` y vuelve a ejecutar - cada neta debe leer 0.00.
FILE:examples/lisbon-trip.csv
# people: Ana, Ben, Cem, Dara, Eli
# base: EUR
# rates: USD=0.92, GBP=1.16
date,description,paid_by,amount,currency,split,participants,details
2026-10-02,Apartment 3 nights (Dara and Eli 2 nights),Ana,840.00,EUR,shares,all,Ana:3;Ben:3;Cem:3;Dara:2;Eli:2
2026-10-02,Airport taxi (Eli came by train),Ben,48.00,EUR,equal,all-Eli,
2026-10-02,Groceries for the flat,Eli,63.75,EUR,equal,all,
2026-10-03,Dinner at Taberna do Mar,Cem,212.40,EUR,itemized,all,Ana:31;Ben:28.50;Cem:24;Dara:36;Eli:19.50;shared:42
2026-10-03,Surf lesson (booked on a US site),Dara,180.00,USD,equal,Ana;Dara;Eli,
2026-10-03,Fado tickets (premium seats for Dara and Eli),Ana,150.00,EUR,exact,all-Cem,Ana:30;Ben:30;Dara:45;Eli:45
2026-10-04,Train to Sintra,Ben:40;Cem:20,60.00,EUR,equal,all,
2026-10-04,Wine for the flat,Dara,36.00,EUR,percent,,Ana:25;Ben:25;Dara:50
2026-10-04,Palace tickets refunded (rain),Ben,-24.00,EUR,equal,Ben;Cem,
2026-10-05,Snacks at the London layover,Cem,27.00,GBP,equal,all,
2026-10-05,Eli paid Ana back for the apartment,Eli,100.00,EUR,payment,Ana,
FILE:examples/lisbon-settle-output.md
# Plan de liquidación
Moneda base: EUR Personas: 5 Líneas: 11 Total gastado: 1,583.07 EUR
Tasas usadas: 1 GBP = 1.16 EUR, 1 USD = 0.92 EUR
## Gastos
| # | Fecha | Qué | Pagado por | Monto | En EUR | División | Entre |
|---|---|---|---|---|---|---|---|
| 1 | 2026-10-02 | Apartamento 3 noches (Dara y Eli 2 noches) | Ana | 840.00 EUR | 840.00 | shares | all |
| 2 | 2026-10-02 | Taxi al aeropuerto (Eli llegó en tren) | Ben | 48.00 EUR | 48.00 | equal | Ana, Ben, Cem, Dara |
| 3 | 2026-10-02 | Compras para el piso | Eli | 63.75 EUR | 63.75 | equal | all |
| 4 | 2026-10-03 | Cena en Taberna do Mar | Cem | 212.40 EUR | 212.40 | itemized (+17.3% tax/tip) | all |
| 5 | 2026-10-03 | Clase de surf (reservada en un sitio de EE. UU.) | Dara | 180.00 USD | 165.60 | equal | Ana, Dara, Eli |
| 6 | 2026-10-03 | Boletos de fado (asientos premium para Dara y Eli) | Ana | 150.00 EUR | 150.00 | exact | Ana, Ben, Dara, Eli |
| 7 | 2026-10-04 | Tren a Sintra | Ben 40.00, Cem 20.00 | 60.00 EUR | 60.00 | equal | all |
| 8 | 2026-10-04 | Vino para el piso | Dara | 36.00 EUR | 36.00 | percent | Ana, Ben, Dara |
| 9 | 2026-10-04 | Boletos del palacio reembolsados (lluvia) | Ben | -24.00 EUR | -24.00 | equal | Ben, Cem |
| 10 | 2026-10-05 | Bocadillos en la escala de Londres | Cem | 27.00 GBP | 31.32 | equal | all |
| 11 | 2026-10-05 | Eli pagó a Ana por el apartamento | Eli | 100.00 EUR | 100.00 | payment | a Ana |
## Saldos
| Persona | Pagó | Parte | Enviado | Recibido | Neta |
|---|---|---|---|---|---|
| Ana | 990.00 | 377.31 | 0.00 | 100.00 | 512.69 (recibe) |
| Ben | 64.00 | 307.17 | 0.00 | 0.00 | -243.17 (debe) |
| Cem | 263.72 | 262.87 | 0.00 | 0.00 | 0.85 (recibe) |
| Dara | 201.60 | 342.54 | 0.00 | 0.00 | -140.94 (debe) |
| Eli | 63.75 | 293.18 | 100.00 | 0.00 | -129.43 (debe) |
Verificación: las netas suman 0.00 (pagado 1,583.07 = partes 1,583.07).
## Transferencias (4)
1. Ben paga a Ana **243.17 EUR**
2. Dara paga a Ana **140.94 EUR**
3. Eli paga a Ana **128.58 EUR**
4. Eli paga a Cem **0.85 EUR**
Mínimo posible: 4 transferencias para 5 personas con saldo (ningún subgrupo más pequeño se empareja por sí solo).
## Mensaje para el chat del grupo
```
Liquidación (EUR, 11 líneas, tasas: GBP 1.16, USD 0.92):
- Ben -> Ana: 243.17 EUR
- Dara -> Ana: 140.94 EUR
- Eli -> Ana: 128.58 EUR
- Eli -> Cem: 0.85 EUR
Tabla completa en la hoja compartida. Avisa dentro de 2 días si una línea está mal.
```
FILE:examples/flat-october.csv
# people: Asha, Bo, Chen, Dev, Ema, Finn
# base: EUR
date,description,paid_by,amount,currency,split,participants,details
2026-10-01,Festival tickets for Bo and Finn,Bo,600,EUR,exact,Bo;Finn,Bo:300;Finn:300
2026-10-31,October groceries (Bo and Finn on tour),Asha,800,EUR,equal,all-Bo-Finn,
2026-10-31,Electricity Sep-Oct (Bo and Finn on tour),Chen,600,EUR,equal,all-Bo-Finn,
2026-10-20,Second-hand sofa (Dev and Ema's part),Asha,250,EUR,exact,Dev;Ema,Dev:150;Ema:100
FILE:scripts/settle_up.py
#!/usr/bin/env python3
"""Divisor de gastos compartidos y planificador de liquidación (solo biblioteca estándar de Python 3.8+).
Lee un registro CSV de costos compartidos (un viaje, un piso, un evento), divide cada línea en
centavos enteros, y encuentra el número MÁS PEQUEÑO posible de transferencias que liquida al grupo
(búsqueda exacta sobre subgrupos de suma cero, no solo "como máximo personas - 1").
Columnas CSV (fila de encabezado requerida, columnas extra ignoradas):
date,description,paid_by,amount,currency,split,participants,details
paid_by Ana un pagador
Ana:40;Ben:20 varios pagadores (montos en la moneda de la fila, deben sumar)
amount total en la moneda de la fila; negativo = reembolso recibido por paid_by
currency en blanco = moneda base
split equal | shares | exact | percent | itemized | payment
participants all | Ana;Ben;Cem | all-Eli (todos excepto Eli) | en blanco = nombres en details
details shares: Ana:3;Ben:2 (personas en participants pero no listadas obtienen 1)
exact: Ana:30;Ben:45 (el resto, si lo hay, dividido por igual entre los otros participants)
percent: Ana:50;Ben:30;Cem:20 (debe totalizar 100)
itemized: Ana:18;Ben:22.50;shared:12 (precios de artículos antes de impuestos/propina; los artículos
"shared" se dividen por igual; amount - artículos = impuestos/servicio/propina, dividido en
proporción a lo que cada persona pidió)
payment: (ninguno) paid_by dio `amount` a la única persona en participants
Líneas de comentario al inicio configuran el grupo y las tasas (o usa las banderas CLI):
# people: Ana, Ben, Cem
# base: EUR
# rates: USD=0.92, GBP=1.16 (1 unidad de esa moneda = tasa unidades de la base)
Uso:
python3 settle_up.py ledger.csv [--base EUR] [--rates USD=0.92,GBP=1.16] [--people Ana,Ben]
[--detail Ana] [--json]
python3 settle_up.py --self-test
Códigos de salida: 0 = plan liquidado impreso, 2 = entrada incorrecta (división desconocida, sumas que no
coinciden, tasa de cambio faltante, persona desconocida en una lista estricta).
"""
import argparse
import csv
import io
import json
import re
import sys
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
MAX_EXACT = 18 # personas con saldo no cero para la búsqueda mínima exacta (2^18 estados)
SPLITS = ("equal", "shares", "exact", "percent", "itemized", "payment")
class LedgerError(ValueError):
pass
def dec(s, what):
try:
return Decimal(str(s).replace(",", "").strip())
except (InvalidOperation, AttributeError):
raise LedgerError("%s: %r no es un número" % (what, s))
def to_cents(amount):
return int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def fmt(cents, cur=""):
sign = "-" if cents < 0 else ""
c = abs(cents)
s = "%s%d.%02d" % (sign, c // 100, c % 100)
s = re.sub(r"(\d)(?=(\d{3})+\.)", r"\1,", s)
return (s + " " + cur).strip()
def allocate(total, weights, order):
"""Divide `total` centavos enteros en proporción a `weights` (dict nombre -> Decimal >= 0).
Método de resto mayor; empates van al nombre anterior en `order`. La suma es exacta."""
names = [n for n in order if n in weights]
wsum = sum(weights[n] for n in names)
if wsum <= 0:
raise LedgerError("los pesos suman cero")
sign = -1 if total < 0 else 1
t = abs(total)
raw = {n: Decimal(t) * weights[n] / wsum for n in names}
base = {n: int(raw[n]) for n in names}
left = t - sum(base.values())
rank = sorted(names, key=lambda n: (-(raw[n] - base[n]), order.index(n)))
for n in rank[:left]:
base[n] += 1
return {n: sign * v for n, v in base.items() if v}
def parse_pairs(text, what):
out = {}
for part in [p for p in re.split(r"[;|]", text or "") if p.strip()]:
if ":" not in part:
raise LedgerError("%s: %r debería verse como Nombre:valor" % (what, part.strip()))
k, v = part.split(":", 1)
k = k.strip()
out[k] = out.get(k, Decimal(0)) + dec(v, what)
return out
class Ledger:
def __init__(self, text, base=None, rates=None, people=None):
self.warnings = []
self.base = base
self.rates = {}
self.people = []
self._canon = {}
header_people = None
body = []
for line in text.splitlines():
s = line.strip()
if s.startswith("#"):
m = re.match(r"#\s*(people|base|rates?)\s*:\s*(.*)$", s, re.I)
if m:
key, val = m.group(1).lower(), m.group(2)
if key == "people":
header_people = [p.strip() for p in re.split(r"[,;]", val) if p.strip()]
elif key == "base" and not self.base:
self.base = val.strip().upper()
elif key.startswith("rate"):
self._add_rates(val)
continue
body.append(line)
self.base = (self.base or "EUR").upper()
if rates:
self._add_rates(rates)
self.rates[self.base] = Decimal(1)
for p in (people or header_people or []):
self._person(p, declare=True)
self.strict = bool(people or header_people)
rows = list(csv.DictReader(io.StringIO("\n".join(body))))
if not rows:
raise LedgerError("no se encontraron filas de gastos (se necesita una fila de encabezado y al menos una línea)")
need = {"description", "paid_by", "amount", "split"}
cols = {c.strip().lower() for c in rows[0].keys() if c}
if not need <= cols:
raise LedgerError("faltan columnas CSV: %s" % ", ".join(sorted(need - cols)))
self.rows = []
for r in rows:
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
if not any(r.values()):
continue
self.rows.append(r)
# primera pasada: aprende cada nombre cuando no se dio una lista de personas
if not self.strict:
for r in self.rows:
for p in self._names_in(r):
self._person(p, declare=True)
self.lines = [self._line(n, r) for n, r in enumerate(self.rows, 1)]
self._check_duplicates()
# -- personas y tasas
def _add_rates(self, text):
for part in re.split(r"[,;]", text):
if not part.strip():
continue
if "=" not in part:
raise LedgerError("tasa %r debería verse como USD=0.92" % part.strip())
k, v = part.split("=", 1)
r = dec(v, "tasa " + k.strip())
if r <= 0:
raise LedgerError("la tasa para %s debe ser positiva" % k.strip())
self.rates[k.strip().upper()] = r
def _person(self, name, declare=False):
key = name.strip().lower()
if not key:
raise LedgerError("nombre de persona vacío")
if key in self._canon:
canon = self._canon[key]
if canon != name.strip() and declare:
self.warnings.append("'%s' y '%s' tratados como la misma persona" % (canon, name.strip()))
return canon
if not declare:
raise LedgerError("persona desconocida %r (no en la lista de personas)" % name.strip())
self._canon[key] = name.strip()
self.people.append(name.strip())
return name.strip()
def _names_in(self, r):
names = []
pb = r.get("paid_by", "")
names += list(parse_pairs(pb, "paid_by").keys()) if ":" in pb else [pb]
part = r.get("participants", "")
if part and not part.lower().startswith("all"):
names += [p for p in re.split(r"[;|]", part) if p.strip()]
if r.get("split", "").lower() != "payment":
names += [k for k in parse_pairs(r.get("details", ""), "details") if k.lower() != "shared"]
return [n for n in names if n.strip()]
def _participants(self, r, ln):
part = r.get("participants", "").strip()
if not part:
return None
if part.lower().startswith("all"):
rest = part[3:].strip()
excl = []
if rest:
rest = re.sub(r"^(except|but|without)\s+", "", rest.lstrip("-").strip(), flags=re.I)
excl = [self._person(x) for x in re.split(r"[-;|,]", rest) if x.strip()]
return [p for p in self.people if p not in excl]
return [self._person(p) for p in re.split(r"[;|]", part) if p.strip()]
# -- una línea CSV -> pagado / parte en centavos base
def _line(self, ln, r):
desc = r.get("description") or "línea %d" % ln
where = "línea %d (%s)" % (ln, desc)
split = (r.get("split") or "equal").lower()
if split not in SPLITS:
raise LedgerError("%s: división desconocida %r (usa %s)" % (where, split, ", ".join(SPLITS)))
cur = (r.get("currency") or self.base).upper()
if cur not in self.rates:
raise LedgerError("%s: no hay tasa de cambio para %s (agrega '# rates: %s=...' o --rates)" % (where, cur, cur))
amount = dec(r.get("amount"), where + " monto")
if amount == 0:
self.warnings.append("%s tiene monto 0 y fue omitido" % where)
total = to_cents(amount * self.rates[cur])
# pagadores
pb = r.get("paid_by", "")
if ":" in pb:
pays = {self._person(k): v for k, v in parse_pairs(pb, where + " paid_by").items()}
if sum(pays.values()) != amount:
raise LedgerError("%s: los pagadores suman %s pero el monto es %s" % (where, sum(pays.values()), amount))
paid = allocate(total, {k: abs(v) for k, v in pays.items()}, self.people) if total else {}
if total < 0:
paid = {k: -abs(v) for k, v in paid.items()}
else:
paid = {self._person(pb): total} if total else {}
people = self._participants(r, ln)
details = parse_pairs(r.get("details", ""), where + " details") if split != "payment" else {}
share = {}
if split == "payment":
if not people or len(people) != 1:
raise LedgerError("%s: un pago necesita exactamente un destinatario en participants" % where)
share = {people[0]: total}
elif split == "equal":
people = people or self.people
share = allocate(total, {p: Decimal(1) for p in people}, self.people)
elif split == "shares":
d = {self._person(k): v for k, v in details.items()}
people = people or list(d)
w = {p: d.get(p, Decimal(1)) for p in people}
extra = [p for p in d if p not in people]
if extra:
raise LedgerError("%s: %s en details pero no en participants" % (where, ", ".join(extra)))
if any(v < 0 for v in w.values()):
raise LedgerError("%s: las partes no pueden ser negativas" % where)
share = allocate(total, w, self.people)
elif split == "percent":
d = {self._person(k): v for k, v in details.items()}
if sum(d.values()) != 100:
raise LedgerError("%s: los porcentajes suman %s, no 100" % (where, sum(d.values())))
people = list(d)
share = allocate(total, d, self.people)
elif split == "exact":
d = {self._person(k): v for k, v in details.items()}
people = people or list(d)
fixed = sum(d.values())
rest_people = [p for p in people if p not in d]
if fixed > abs(amount) or (fixed != abs(amount) and not rest_people):
raise LedgerError("%s: los montos exactos suman %s pero la factura es %s" % (where, fixed, amount))
w = dict(d)
if rest_people:
each = (abs(amount) - fixed) / len(rest_people)
for p in rest_people:
w[p] = each
share = allocate(total, w, self.people)
elif split == "itemized":
people = people or self.people
shared = details.pop("shared", details.pop("Shared", Decimal(0)))
d = {self._person(k): v for k, v in details.items()}
items = sum(d.values()) + shared
if items > abs(amount):
raise LedgerError("%s: los artículos suman %s, más que la factura %s" % (where, items, amount))
per = {p: d.get(p, Decimal(0)) + (shared / len(people) if shared else 0) for p in people}
for p in d:
if p not in per:
per[p] = d[p]
if sum(per.values()) <= 0:
raise LedgerError("%s: no hay artículos para dividir" % where)
# impuestos/propina proporcionales a los artículos de cada persona == asignar el total por pesos de artículos
share = allocate(total, per, self.people)
rec = {"n": ln, "date": r.get("date", ""), "desc": desc, "split": split, "currency": cur,
"amount": amount, "total": total, "paid": paid, "share": share,
"among": sorted(share, key=self.people.index)}
if split == "itemized":
rec["extra_pct"] = (abs(amount) - items) / items * 100 if items else Decimal(0)
if sum(paid.values()) != sum(share.values()):
raise LedgerError("%s: error interno de redondeo" % where) # nunca esperado
return rec
def _check_duplicates(self):
seen = {}
for l in self.lines:
key = (l["date"], l["desc"].lower(), l["total"], tuple(sorted(l["paid"])))
if key in seen:
self.warnings.append("la línea %d parece un duplicado de la línea %d (%s, %s)" % (l["n"], seen[key], l["desc"], fmt(l["total"], self.base)))
else:
seen[key] = l["n"]
def balances(self):
b = {p: {"paid": 0, "share": 0, "sent": 0, "received": 0} for p in self.people}
for l in self.lines:
if l["split"] == "payment":
for p, c in l["paid"].items():
b[p]["sent"] += c
for p, c in l["share"].items():
b[p]["received"] += c
else:
for p, c in l["paid"].items():
b[p]["paid"] += c
for p, c in l["share"].items():
b[p]["share"] += c
for p in b:
b[p]["net"] = b[p]["paid"] + b[p]["sent"] - b[p]["share"] - b[p]["received"]
return b
# ----------------------------------------------------------------------------- liquidación
def greedy(net, order):
"""El mayor deudor paga al mayor acreedor hasta que todos estén a mano (como máximo k-1 transferencias)."""
cred = {p: v for p, v in net.items() if v > 0}
debt = {p: -v for p, v in net.items() if v < 0}
out = []
while cred and debt:
c = max(cred, key=lambda p: (cred[p], -order.index(p)))
d = max(debt, key=lambda p: (debt[p], -order.index(p)))
x = min(cred[c], debt[d])
out.append((d, c, x))
cred[c] -= x
debt[d] -= x
if not cred[c]:
del cred[c]
if not debt[d]:
del debt[d]
return out
def minimal(net, order):
"""Menos transferencias: n - (número máximo de grupos de suma cero disjuntos). Devuelve (transferencias, grupos, exacto)."""
people = [p for p in order if net.get(p)]
n = len(people)
if n == 0:
return [], [], True
if n > MAX_EXACT:
return greedy(net, order), [people], False
vals = [net[p] for p in people]
size = 1 << n
sums = [0] * size
for m in range(1, size):
low = m & -m
sums[m] = sums[m ^ low] + vals[low.bit_length() - 1]
dp = [0] * size
for m in range(1, size):
best = 0
mm = m
while mm:
low = mm & -mm
v = dp[m ^ low]
if v > best:
best = v
mm ^= low
dp[m] = best + (1 if sums[m] == 0 else 0)
# camina hacia abajo, cortando un grupo en cada punto de control de suma cero
groups, m, last = [], size - 1, size - 1
while m:
mm, pick = m, None
target = dp[m] - (1 if sums[m] == 0 else 0)
while mm:
low = mm & -mm
if dp[m ^ low] == target:
pick = low
break
mm ^= low
m ^= pick
if sums[m] == 0:
groups.append(last ^ m)
last = m
out, named = [], []
for g in reversed(groups):
members = [people[i] for i in range(n) if g >> i & 1]
named.append(members)
out += greedy({p: net[p] for p in members}, order)
return out, named, True
# ----------------------------------------------------------------------------- salida
def render(led, detail=None):
b = led.balances()
net = {p: b[p]["net"] for p in led.people}
tr, groups, exact = minimal(net, led.people)
gr = greedy(net, led.people)
B = led.base
o = ["# Plan de liquidación", "",
"Moneda base: %s Personas: %d Líneas: %d Total gastado: %s" % (
B, len(led.people), len(led.lines),
fmt(sum(l["total"] for l in led.lines if l["split"] != "payment"), B))]
others = sorted(c for c in led.rates if c != B)
if others:
o.append("Tasas usadas: " + ", ".join("1 %s = %s %s" % (c, led.rates[c].normalize(), B) for c in others))
o += ["", "## Gastos", "", "| # | Fecha | Qué | Pagado por | Monto | En %s | División | Entre |" % B, "|---|---|---|---|---|---|---|---|"]
for l in led.lines:
payer = ", ".join("%s %s" % (p, fmt(c)) if len(l["paid"]) > 1 else p for p, c in l["paid"].items())
among = "all" if len(l["among"]) == len(led.people) and l["split"] != "payment" else ", ".join(l["among"])
split = l["split"] + (" (+%.1f%% tax/tip)" % l["extra_pct"] if l["split"] == "itemized" else "")
if l["split"] == "payment":
among = "a " + among
o.append("| %d | %s | %s | %s | %s %s | %s | %s | %s |" % (l["n"], l["date"], l["desc"].replace("|", "/"), payer,
fmt(to_cents(l["amount"])), l["currency"], fmt(l["total"]), split, among))
o += ["", "## Saldos", "", "| Persona | Pagó | Parte | Enviado | Recibido | Neta |", "|---|---|---|---|---|---|"]
for p in led.people:
x = b[p]
state = "recibe" if x["net"] > 0 else ("debe" if x["net"] < 0 else "a mano")
o.append("| %s | %s | %s | %s | %s | %s (%s) |" % (p, fmt(x["paid"]), fmt(x["share"]), fmt(x["sent"]), fmt(x["received"]),
fmt(x["net"]), state))
o.append("")
o.append("Verificación: las netas suman %s (pagado %s = partes %s)." % (fmt(sum(net.values())), fmt(sum(x["paid"] for x in b.values())),
fmt(sum(x["share"] for x in b.values()))))
nz = sum(1 for v in net.values() if v)
o += ["", "## Transferencias (%d)" % len(tr), ""]
if not tr:
o.append("Todos están a mano. Nada que pagar.")
else:
for i, (d, c, x) in enumerate(tr, 1):
o.append("%d. %s paga a %s **%s**" % (i, d, c, fmt(x, B)))
o.append("")
if exact:
if len(groups) > 1:
o.append("Mínimo posible: %d transferencias. El grupo se divide en %d conjuntos que se liquidan entre sí (%s), así que %d personas necesitan solo %d transferencias; pagar la deuda más grande al crédito más grande tomaría %d." % (
len(tr), len(groups), "; ".join(" + ".join(g) for g in groups), nz, len(tr), len(gr)))
else:
o.append("Mínimo posible: %d transferencias para %d personas con saldo (ningún subgrupo más pequeño se empareja por sí solo)." % (len(tr), nz))
else:
o.append("%d personas tienen saldo; búsqueda mínima exacta omitida (límite %d), el plan codicioso usa como máximo %d transferencias." % (nz, MAX_EXACT, nz - 1))
if detail:
p = led._person(detail)
o += ["", "## Detalle: %s" % p, "", "| # | Qué | Pagó | Parte | Neta acumulada |", "|---|---|---|---|---|"]
run = 0
for l in led.lines:
pd, sh = l["paid"].get(p, 0), l["share"].get(p, 0)
if not pd and not sh:
continue
run += pd - sh
o.append("| %d | %s | %s | %s | %s |" % (l["n"], l["desc"], fmt(pd), fmt(sh), fmt(run)))
if led.warnings:
o += ["", "## Advertencias", ""] + ["- " + w for w in led.warnings]
o += ["", "## Mensaje para el chat del grupo", "", "```"]
msg = ["Liquidación (%s, %d líneas, tasas: %s):" % (B, len(led.lines), ", ".join("%s %s" % (c, led.rates[c].normalize()) for c in others) or "ninguna")]
msg += ["- %s -> %s: %s" % (d, c, fmt(x, B)) for d, c, x in tr] or ["- todos están a mano"]
msg.append("Tabla completa en la hoja compartida. Avisa dentro de 2 días si una línea está mal.")
o += msg + ["```"]
return "\n".join(o) + "\n"
def as_json(led):
b = led.balances()
net = {p: b[p]["net"] for p in led.people}
tr, groups, exact = minimal(net, led.people)
return {"base": led.base, "people": led.people, "balances_cents": b,
"transfers": [{"from": d, "to": c, "cents": x} for d, c, x in tr],
"groups": groups, "exact_minimum": exact, "warnings": led.warnings}
# ----------------------------------------------------------------------------- auto-prueba
def self_test():
import random
cases = []
def case(name, fn):
try:
ok, err = bool(fn()), ""
except Exception as e: # noqa
ok, err = False, repr(e)
cases.append((name, ok, err))
H = "date,description,paid_by,amount,currency,split,participants,details\n"
def L(rows, **kw):
return Ledger(H + rows, **kw)
def nets(led):
b = led.balances()
return {p: b[p]["net"] for p in led.people}
def raises(fn):
try:
fn()
except LedgerError:
return True
return False
case("división igual al centavo", lambda: nets(L(",Taxi,Ana,10.00,,equal,Ana;Ben;Cem,\n")) == {"Ana": 666, "Ben": -333, "Cem": -333})
case("el centavo sobrante va al primero en orden", lambda: L(",X,Ana,0.10,,equal,Ana;Ben;Cem,\n").lines[0]["share"] == {"Ana": 4, "Ben": 3, "Cem": 3})
case("todos menos exclusión", lambda: Ledger("# people: Ana, Ben, Cem\n" + H + ",X,Ana,30,,equal,all-Cem,\n").lines[0]["among"] == ["Ana", "Ben"])
case("todos excepto palabras", lambda: Ledger("# people: Ana, Ben, Cem\n" + H + ",X,Ana,30,,equal,all except Ben,\n").lines[0]["among"] == ["Ana", "Cem"])
case("pesos de partes", lambda: L(",Room,Ana,100,,shares,,Ana:3;Ben:1\n").lines[0]["share"] == {"Ana": 7500, "Ben": 2500})
case("las partes por defecto son 1 para participants no listados", lambda: L(",Room,Ana,90,,shares,Ana;Ben;Cem,Ana:2;Ben:0\n").lines[0]["share"] == {"Ana": 6000, "Cem": 3000})
case("montos exactos", lambda: L(",Tickets,Ana,75,,exact,,Ana:30;Ben:45\n").lines[0]["share"] == {"Ana": 3000, "Ben": 4500})
case("exacto con el resto dividido por igual", lambda: L(",T,Ana,100,,exact,Ana;Ben;Cem,Ana:40\n").lines[0]["share"] == {"Ana": 4000, "Ben": 3000, "Cem": 3000})
case("desajuste exacto rechazado", lambda: raises(lambda: L(",T,Ana,80,,exact,,Ana:30;Ben:45\n")))
case("porcentaje", lambda: L(",Wine,Ana,36,,percent,,Ana:25;Ben:25;Cem:50\n").lines[0]["share"] == {"Ana": 900, "Ben": 900, "Cem": 1800})
case("porcentaje no 100 rechazado", lambda: raises(lambda: L(",W,Ana,36,,percent,,Ana:25;Ben:25\n")))
case("detallado con compartido e impuestos/propina", lambda: L(",Dinner,Ana,66,,itemized,Ana;Ben,Ana:20;Ben:30;shared:10\n").lines[0]["share"] == {"Ana": 2750, "Ben": 3850})
case("detallado sobre la factura rechazado", lambda: raises(lambda: L(",D,Ana,40,,itemized,Ana;Ben,Ana:20;Ben:30\n")))
case("fila de pago", lambda: nets(L(",Food,Ana,20,,equal,Ana;Ben,\n,Paid back,Ben,10,,payment,Ana,\n")) == {"Ana": 0, "Ben": 0})
case("el pago necesita un destinatario", lambda: raises(lambda: L(",P,Ben,10,,payment,Ana;Cem,\n")))
case("varios pagadores", lambda: L(",Train,Ana:40;Ben:20,60,,equal,Ana;Ben;Cem,\n").lines[0]["paid"] == {"Ana": 4000, "Ben": 2000})
case("varios pagadores deben sumar", lambda: raises(lambda: L(",Train,Ana:40;Ben:10,60,,equal,Ana;Ben,\n")))
case("reembolso (monto negativo)", lambda: nets(L(",Museum refund,Ben,-24,,equal,Ben;Cem,\n")) == {"Ben": -1200, "Cem": 1200})
case("conversión de moneda", lambda: L(",Surf,Ana,100,USD,equal,Ana;Ben,\n", rates="USD=0.92").lines[0]["total"] == 9200)
case("tasas desde línea de comentario", lambda: Ledger("# base: EUR\n# rates: USD=0.92, GBP=1.16\n" + H + ",S,Ana,10,GBP,equal,Ana;Ben,\n").lines[0]["total"] == 1160)
case("tasa faltante rechazada", lambda: raises(lambda: L(",S,Ana,10,CHF,equal,Ana;Ben,\n")))
case("división desconocida rechazada", lambda: raises(lambda: L(",S,Ana,10,,halves,Ana;Ben,\n")))
case("lista estricta de personas rechaza error tipográfico", lambda: raises(lambda: Ledger("# people: Ana, Ben\n" + H + ",S,Anna,10,,equal,all,\n")))
case("los nombres sin distinguir mayúsculas se fusionan", lambda: L(",A,Ana,10,,equal,ana;Ben,\n").people == ["Ana", "Ben"])
case("advertencia de línea duplicada", lambda: any("duplicado" in w for w in L("1,Taxi,Ana,10,,equal,Ana;Ben,\n1,Taxi,Ana,10,,equal,Ana;Ben,\n").warnings))
case("columnas faltantes rechazadas", lambda: raises(lambda: Ledger("what,who\nx,y\n")))
case("número incorrecto rechazado", lambda: raises(lambda: L(",S,Ana,ten,,equal,Ana;Ben,\n")))
# transferencias mínimas
case("pares de suma cero encontrados (4 personas -> 2 transferencias)", lambda: len(minimal({"A": 500, "B": -500, "C": 300, "D": -300}, list("ABCD"))[0]) == 2)
case("sin subgrupo de suma cero -> personas - 1 transferencias", lambda: len(greedy({"A": 600, "B": 400, "C": -500, "D": -500}, list("ABCD"))) == 3 and len(minimal({"A": 600, "B": 400, "C": -500, "D": -500}, list("ABCD"))[0]) == 3)
case("el mínimo supera al codicioso en 6 personas", lambda: (lambda n: len(minimal(n, list("ABCDEF"))[0]) == 4 and len(greedy(n, list("ABCDEF"))) == 5)({"A": 700, "B": 300, "C": 250, "D": -500, "E": -450, "F": -300}))
case("todos a mano -> sin transferencias", lambda: minimal({"A": 0, "B": 0}, ["A", "B"])[0] == [])
def brute_min(net):
ps = [p for p in net if net[p]]
best = len(ps) - 1 if ps else 0
for parts in _partitions(ps):
if all(sum(net[p] for p in g) == 0 for g in parts):
best = min(best, len(ps) - len(parts))
return best
def check_random():
rnd = random.Random(7)
for _ in range(60):
k = rnd.randint(2, 7)
vals = [rnd.choice([-1, 1]) * rnd.choice([100, 200, 300, 500, 250, 50]) for _ in range(k - 1)]
vals.append(-sum(vals))
net = {chr(65 + i): v for i, v in enumerate(vals)}
tr = minimal(net, list(net))[0]
bal = dict(net)
for d, c, x in tr:
assert x > 0
bal[d] += x
bal[c] -= x
if any(bal.values()) or len(tr) != brute_min(net):
return False
return True
case("mínimo == fuerza bruta en 60 grupos aleatorios y liquida a todos", check_random)
case("las transferencias liquidan el registro similar al ejemplo", lambda: _settles(L(",Flat,Ana,840,,shares,,Ana:3;Ben:3;Cem:2\n,Food,Cem,63.75,,equal,all,\n,Wine,Ben,36,,percent,,Ana:25;Ben:25;Cem:50\n")))
case("la salida tiene transferencias y mensaje de chat", lambda: "Mensaje para el chat del grupo" in render(L(",Taxi,Ana,10,,equal,Ana;Ben,\n")))
case("vista de detalle", lambda: "Detalle: Ben" in render(L(",Taxi,Ana,10,,equal,Ana;Ben,\n"), "ben"))
case("salida json", lambda: as_json(L(",Taxi,Ana,10,,equal,Ana;Ben,\n"))["transfers"] == [{"from": "Ben", "to": "Ana", "cents": 500}])
case("formato de miles", lambda: fmt(123456789, "EUR") == "1,234,567.89 EUR" and fmt(-5) == "-0.05")
passed = sum(1 for _, ok, _ in cases if ok)
for name, ok, err in cases:
print(("PASS " if ok else "FAIL ") + name + ((" " + err) if err else ""))
print("%d/%d pasaron" % (passed, len(cases)))
return 0 if passed == len(cases) else 1
def _partitions(items):
if not items:
yield []
return
first, rest = items[0], items[1:]
for p in _partitions(rest):
for i in range(len(p)):
yield p[:i] + [[first] + p[i]] + p[i + 1:]
yield [[first]] + p
def _settles(led):
b = led.balances()
net = {p: b[p]["net"] for p in led.people}
for d, c, x in minimal(net, led.people)[0]:
net[d] += x
net[c] -= x
return not any(net.values())
def main(argv=None):
ap = argparse.ArgumentParser(description="Divide gastos compartidos y planifica las menos transferencias de liquidación.")
ap.add_argument("file", nargs="?", help="registro CSV ('-' = stdin)")
ap.add_argument("--base", default=None, help="moneda base (por defecto: línea '# base:' o EUR)")
ap.add_argument("--rates", default=None, help="USD=0.92,GBP=1.16 (1 unidad = tasa unidades de la base)")
ap.add_argument("--people", default=None, help="miembros del grupo separados por comas (estricto: errores tipográficos son errores)")
ap.add_argument("--detail", default=None, help="muestra el desglose línea por línea para una persona")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("da un registro CSV o --self-test")
try:
text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8-sig").read()
people = [p.strip() for p in a.people.split(",")] if a.people else None
led = Ledger(text, a.base, a.rates, people)
out = json.dumps(as_json(led), indent=2) + "\n" if a.json else render(led, a.detail)
except (OSError, LedgerError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
sys.stdout.write(out)
return 0
if __name__ == "__main__":
sys.exit(main())
Discusión
0 comentarios