Payment Status Legend¶
0 — Did not participate → owes $0
1 — Participated → pays equal share
2 — Participated (no drink) → pays equal share minus $12
Step 1 — Load & Preview the Raw Data¶
In [1]:
import pandas as pd
import warnings
warnings.filterwarnings('ignore')
df = pd.read_excel('SLCColoradoMontreal.xlsx')
df = df[['Date','Location','Total','Trip','Paid by','Notes','HG','JK','JW','DB']].copy()
df['Date'] = pd.to_datetime(df['Date']).dt.strftime('%Y-%m-%d')
df = df.reset_index(drop=True)
pd.set_option('display.max_colwidth', 55)
pd.set_option('display.width', 150)
print(f"✅ Loaded {len(df)} expenses")
print(f" People: HG, JK, JW, DB")
print(f" Total Spent: ${df['Total'].sum():,.2f}")
df
✅ Loaded 29 expenses People: HG, JK, JW, DB Total Spent: $5,153.91
Out[1]:
| Date | Location | Total | Trip | Paid by | Notes | HG | JK | JW | DB | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2025-09-09 | HARMONS CITY CREEK 135 EAST 100 SOUTH SALT LAKE CIT... | 51.35 | SLC | HG | Liquor: DB did not really drink | 1 | 1 | 1 | 0 |
| 1 | 2025-09-09 | STATE LIQUOR STORE 13 255 WEST NORTH TEMPLE SALT LA... | 109.63 | SLC | HG | Liquor: DB did not really drink | 1 | 1 | 1 | 0 |
| 2 | 2025-09-11 | Smith's 1725 W. Uinta Way, Park City, UT 84060 | 22.15 | SLC | HG | NaN | 1 | 1 | 1 | 1 |
| 3 | 2025-09-13 | Smith's, 1725 W. Uinta Way, Park City, UT 84060 | 37.55 | SLC | HG | NaN | 1 | 1 | 1 | 1 |
| 4 | 2025-09-10 | Smith's, 1725 W. Uinta Way, Park City UT 84060 | 51.53 | SLC | HG | NaN | 1 | 1 | 1 | 1 |
| 5 | 2025-09-14 | Chevron Touch N Go, 375 N Terminal Dr, Salt Lake UT | 67.88 | SLC | HG | Gas | 1 | 1 | 1 | 1 |
| 6 | 2025-09-09 | Red Iguana 1, 736 West North Temple St, Salt Lake C... | 74.18 | SLC | HG | Just HG and JK | 1 | 1 | 0 | 0 |
| 7 | 2025-09-10 | Taste of Thai, 1241 Center Dr Unit L160, Park City,... | 145.01 | SLC | JK | DB didn't order drink | 1 | 1 | 1 | 2 |
| 8 | 2025-09-10 | Costco, 1818 South 300 West, Salt Lake City, UT 84115 | 164.36 | SLC | DB | NaN | 1 | 1 | 1 | 1 |
| 9 | 2025-09-11 | Smith's, 1725 W. Uinta Way, Park City UT 84060 | 18.45 | SLC | DB | NaN | 1 | 1 | 1 | 1 |
| 10 | 2025-09-12 | Red Iguana 1, 736 West North Temple St, Salt Lake C... | 153.81 | SLC | DB | DB didn't order drink | 1 | 1 | 1 | 2 |
| 11 | 2025-09-12 | Bike Shuttle | 40.00 | SLC | DB | NaN | 1 | 1 | 1 | 1 |
| 12 | 2025-09-14 | National Car Rental, 3920 WEST TERMINAL DR, SALT LA... | 298.64 | SLC | JK | Rental car | 1 | 1 | 1 | 1 |
| 13 | 2026-01-17 | Montreal Airbnb | 799.65 | Montreal | DB | NaN | 1 | 1 | 1 | 1 |
| 14 | 2026-01-05 | Colorado Airbnb | 651.23 | CO | HG | NaN | 1 | 1 | 1 | 1 |
| 15 | 2026-01-19 | Canada Airbnb | 732.48 | Vermont | HG | NaN | 1 | 1 | 1 | 1 |
| 16 | 2026-01-25 | 7680 Pena Blvd, Denver. CO 80249 | 48.86 | CO | JK | Gas | 1 | 1 | 1 | 1 |
| 17 | 2026-01-25 | National Car Rental, 24530 E 78TH AVE, DENVER, CO 8... | 215.03 | CO | JK | Rental car | 1 | 1 | 1 | 1 |
| 18 | 2026-02-12 | DoubleTree Hilton | 289.10 | Vermont | JW | NaN | 1 | 1 | 1 | 1 |
| 19 | 2026-02-22 | 4E Mur | 103.43 | Montreal | JW | DB no drank | 1 | 1 | 1 | 0 |
| 20 | 2026-02-23 | Dehli Kebab | 134.92 | Montreal | JW | NaN | 1 | 1 | 1 | 1 |
| 21 | 2026-02-25 | Hannaford | 78.25 | Vermont | JW | NaN | 1 | 1 | 1 | 1 |
| 22 | 2026-02-25 | Esso | 59.05 | Montreal | JK | Gas | 1 | 1 | 1 | 1 |
| 23 | 2026-02-28 | Henry's Diner | 90.00 | Vermont | JW | NaN | 1 | 1 | 1 | 1 |
| 24 | 2026-02-28 | Ice Cream Tour | 26.00 | Vermont | HG | NaN | 1 | 1 | 1 | 1 |
| 25 | 2026-02-23 | IGA Beer | 27.14 | Montreal | HG | DB no drank | 1 | 1 | 1 | 0 |
| 26 | 2026-02-28 | 76 Paul St Garage | 6.00 | Vermont | JK | Parking in downtown Burlington | 1 | 1 | 1 | 1 |
| 27 | 2026-02-28 | ExxonMobil | 47.42 | Vermont | JK | Gas | 1 | 1 | 1 | 1 |
| 28 | 2026-03-01 | National Car Rental | 610.81 | Vermont | JK | Rental car | 1 | 1 | 1 | 1 |
Step 2 — Calculation Logic¶
For each expense:
- Identify participants — anyone with status
1or2 - Calculate base share =
Total ÷ number of participants - Apply per-person adjustments:
- Status
0→ owes$0 - Status
1→ owesbase_share - Status
2→ owesbase_share − $12
- Status
- Each non-payer creates a debt to the payer for their share
- The $12 drink discount is effectively absorbed by the payer
Step 3 — Per-Expense Breakdown¶
In [14]:
people = ['HG', 'JK', 'JW', 'DB']
def calc_shares(row):
total = float(row['Total'])
statuses = {p: int(row[p]) for p in people}
participants = [p for p in people if statuses[p] in (1, 2)]
n = len(participants)
if n == 0:
return {p: 0.0 for p in people}
base = total / n
no_drink = [p for p in participants if statuses[p] == 2]
drinkers = [p for p in participants if statuses[p] == 1]
extra = (len(no_drink) * 12.0) / len(drinkers) if drinkers else 0.0
shares = {p: 0.0 for p in people}
# Assign all but the last participant first
for p in participants[:-1]:
if statuses[p] == 1:
shares[p] = round(base + extra, 2)
elif statuses[p] == 2:
shares[p] = round(base - 12.0, 2)
# Last participant gets exact remainder so shares sum to total
last = participants[-1]
shares[last] = round(total - sum(shares[p] for p in participants[:-1]), 2)
return shares
# Build detailed breakdown table
rows = []
for _, row in df.iterrows():
shares = calc_shares(row)
payer = str(row['Paid by']).strip()
n_part = sum(1 for p in people if int(row[p]) in (1,2))
base = float(row['Total']) / n_part if n_part > 0 else 0
rows.append({
'Date': row['Date'],
'Location': row['Location'][:45] + ('…' if len(str(row['Location'])) > 45 else ''),
'Total': f"${row['Total']:,.2f}",
'Payer': payer,
'# People': n_part,
'Base Share': f"${base:.2f}",
'HG': f"${shares['HG']:.2f}" if int(row['HG']) > 0 else '—',
'JK': f"${shares['JK']:.2f}" if int(row['JK']) > 0 else '—',
'JW': f"${shares['JW']:.2f}" if int(row['JW']) > 0 else '—',
'DB': f"${shares['DB']:.2f}" if int(row['DB']) > 0 else '—',
})
detail_df = pd.DataFrame(rows)
print("Per-expense shares (— = not participating):")
detail_df
Per-expense shares (— = not participating):
Out[14]:
| Date | Location | Total | Payer | # People | Base Share | HG | JK | JW | DB | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2025-09-09 | HARMONS CITY CREEK 135 EAST 100 SOUTH SALT LA… | $51.35 | HG | 3 | $17.12 | $17.12 | $17.12 | $17.11 | — |
| 1 | 2025-09-09 | STATE LIQUOR STORE 13 255 WEST NORTH TEMPLE S… | $109.63 | HG | 3 | $36.54 | $36.54 | $36.54 | $36.55 | — |
| 2 | 2025-09-11 | Smith's 1725 W. Uinta Way, Park City, UT 8406… | $22.15 | HG | 4 | $5.54 | $5.54 | $5.54 | $5.54 | $5.53 |
| 3 | 2025-09-13 | Smith's, 1725 W. Uinta Way, Park City, UT 840… | $37.55 | HG | 4 | $9.39 | $9.39 | $9.39 | $9.39 | $9.38 |
| 4 | 2025-09-10 | Smith's, 1725 W. Uinta Way, Park City UT 8406… | $51.53 | HG | 4 | $12.88 | $12.88 | $12.88 | $12.88 | $12.89 |
| 5 | 2025-09-14 | Chevron Touch N Go, 375 N Terminal Dr, Salt L… | $67.88 | HG | 4 | $16.97 | $16.97 | $16.97 | $16.97 | $16.97 |
| 6 | 2025-09-09 | Red Iguana 1, 736 West North Temple St, Salt … | $74.18 | HG | 2 | $37.09 | $37.09 | $37.09 | — | — |
| 7 | 2025-09-10 | Taste of Thai, 1241 Center Dr Unit L160, Park… | $145.01 | JK | 4 | $36.25 | $40.25 | $40.25 | $40.25 | $24.26 |
| 8 | 2025-09-10 | Costco, 1818 South 300 West, Salt Lake City, … | $164.36 | DB | 4 | $41.09 | $41.09 | $41.09 | $41.09 | $41.09 |
| 9 | 2025-09-11 | Smith's, 1725 W. Uinta Way, Park City UT 8406… | $18.45 | DB | 4 | $4.61 | $4.61 | $4.61 | $4.61 | $4.62 |
| 10 | 2025-09-12 | Red Iguana 1, 736 West North Temple St, Salt … | $153.81 | DB | 4 | $38.45 | $42.45 | $42.45 | $42.45 | $26.46 |
| 11 | 2025-09-12 | Bike Shuttle | $40.00 | DB | 4 | $10.00 | $10.00 | $10.00 | $10.00 | $10.00 |
| 12 | 2025-09-14 | National Car Rental, 3920 WEST TERMINAL DR, S… | $298.64 | JK | 4 | $74.66 | $74.66 | $74.66 | $74.66 | $74.66 |
| 13 | 2026-01-17 | Montreal Airbnb | $799.65 | DB | 4 | $199.91 | $199.91 | $199.91 | $199.91 | $199.92 |
| 14 | 2026-01-05 | Colorado Airbnb | $651.23 | HG | 4 | $162.81 | $162.81 | $162.81 | $162.81 | $162.80 |
| 15 | 2026-01-19 | Canada Airbnb | $732.48 | HG | 4 | $183.12 | $183.12 | $183.12 | $183.12 | $183.12 |
| 16 | 2026-01-25 | 7680 Pena Blvd, Denver. CO 80249 | $48.86 | JK | 4 | $12.21 | $12.21 | $12.21 | $12.21 | $12.23 |
| 17 | 2026-01-25 | National Car Rental, 24530 E 78TH AVE, DENVER… | $215.03 | JK | 4 | $53.76 | $53.76 | $53.76 | $53.76 | $53.75 |
| 18 | 2026-02-12 | DoubleTree Hilton | $289.10 | JW | 4 | $72.28 | $72.28 | $72.28 | $72.28 | $72.26 |
| 19 | 2026-02-22 | 4E Mur | $103.43 | JW | 3 | $34.48 | $34.48 | $34.48 | $34.47 | — |
| 20 | 2026-02-23 | Dehli Kebab | $134.92 | JW | 4 | $33.73 | $33.73 | $33.73 | $33.73 | $33.73 |
| 21 | 2026-02-25 | Hannaford | $78.25 | JW | 4 | $19.56 | $19.56 | $19.56 | $19.56 | $19.57 |
| 22 | 2026-02-25 | Esso | $59.05 | JK | 4 | $14.76 | $14.76 | $14.76 | $14.76 | $14.77 |
| 23 | 2026-02-28 | Henry's Diner | $90.00 | JW | 4 | $22.50 | $22.50 | $22.50 | $22.50 | $22.50 |
| 24 | 2026-02-28 | Ice Cream Tour | $26.00 | HG | 4 | $6.50 | $6.50 | $6.50 | $6.50 | $6.50 |
| 25 | 2026-02-23 | IGA Beer | $27.14 | HG | 3 | $9.05 | $9.05 | $9.05 | $9.04 | — |
| 26 | 2026-02-28 | 76 Paul St Garage | $6.00 | JK | 4 | $1.50 | $1.50 | $1.50 | $1.50 | $1.50 |
| 27 | 2026-02-28 | ExxonMobil | $47.42 | JK | 4 | $11.86 | $11.86 | $11.86 | $11.86 | $11.84 |
| 28 | 2026-03-01 | National Car Rental | $610.81 | JK | 4 | $152.70 | $152.70 | $152.70 | $152.70 | $152.71 |
In [15]:
# Total owed and total paid per person
totals = {p: 0.0 for p in people}
paid = {p: 0.0 for p in people}
for _, row in df.iterrows():
shares = calc_shares(row)
for p in people:
totals[p] += shares[p]
payer = str(row['Paid by']).strip()
if payer in paid:
paid[payer] += float(row['Total'])
totals_df = pd.DataFrame([{
'Person': p,
'Total Paid': f"${paid[p]:,.2f}",
'Total Owed': f"${totals[p]:,.2f}",
'Net (Paid - Owed)': f"${paid[p] - totals[p]:,.2f}"
} for p in people])
print("\nSummary:")
totals_df
Summary:
Out[15]:
| Person | Total Paid | Total Owed | Net (Paid - Owed) | |
|---|---|---|---|---|
| 0 | HG | $1,851.12 | $1,339.32 | $511.80 |
| 1 | JK | $1,430.82 | $1,339.32 | $91.50 |
| 2 | JW | $695.70 | $1,302.21 | $-606.51 |
| 3 | DB | $1,176.27 | $1,173.06 | $3.21 |
Step 4 — Running Balances (Cumulative)¶
In [16]:
balances = {p: 0.0 for p in people}
balance_history = []
for _, row in df.iterrows():
shares = calc_shares(row)
payer = str(row['Paid by']).strip()
for p in people:
if p != payer and shares[p] > 0:
balances[p] -= shares[p] # p owes payer
balances[payer] += shares[p] # payer is owed
balance_history.append({
'Person': p,
'Net Balance': round(balances[p], 2),
'Status': '🟢 Is Owed' if balances[p] > 0 else ('🔴 Owes Others' if balances[p] < 0 else '⚪ Settled')
} for p in people)
bal_df = pd.DataFrame([{
'Person': p,
'Net Balance ($)': round(balances[p], 2),
'Status': '🟢 Is Owed $' + f"{balances[p]:.2f}" if balances[p] > 0.01
else ('🔴 Owes $' + f"{abs(balances[p]):.2f}" if balances[p] < -0.01 else '⚪ Settled')
} for p in people])
print("Net balances after all expenses:")
print(bal_df.to_string(index=False))
print()
print(f"Sanity check — balances sum to: ${sum(balances.values()):.2f} (should be $0.00)")
Net balances after all expenses:
Person Net Balance ($) Status
HG 511.80 🟢 Is Owed $511.80
JK 91.50 🟢 Is Owed $91.50
JW -606.51 🔴 Owes $606.51
DB 3.21 🟢 Is Owed $3.21
Sanity check — balances sum to: $-0.00 (should be $0.00)
Step 5 — Breakdown by Trip¶
In [17]:
trip_totals = {}
for _, row in df.iterrows():
trip = str(row['Trip']).strip()
shares = calc_shares(row)
payer = str(row['Paid by']).strip()
if trip not in trip_totals:
trip_totals[trip] = {'total': 0.0, 'by_person': {p: 0.0 for p in people}}
trip_totals[trip]['total'] += float(row['Total'])
for p in people:
if p != payer and shares[p] > 0:
trip_totals[trip]['by_person'][p] += shares[p]
print("Trip totals and per-person amounts owed within each trip:\n")
for trip, data in trip_totals.items():
print(f"📍 {trip} — Total Spent: ${data['total']:,.2f}")
for p, amt in data['by_person'].items():
if amt > 0:
print(f" {p}: owes ${amt:.2f}")
print()
Trip totals and per-person amounts owed within each trip: 📍 SLC — Total Spent: $1,234.54 HG: owes $213.06 JK: owes $233.68 JW: owes $311.50 DB: owes $143.69 📍 Montreal — Total Spent: $1,124.19 HG: owes $282.88 JK: owes $277.17 JW: owes $223.71 DB: owes $48.50 📍 CO — Total Spent: $915.12 HG: owes $65.97 JK: owes $162.81 JW: owes $228.78 DB: owes $228.78 📍 Vermont — Total Spent: $1,880.06 HG: owes $280.40 JK: owes $303.96 JW: owes $355.68 DB: owes $470.00
Step 6 — Minimum Transactions Settlement¶
Using a greedy algorithm to minimize the number of payments needed to settle all debts.
In [18]:
# Minimize transactions
bal = {p: round(balances[p], 2) for p in people}
debtors = sorted([(p, -b) for p, b in bal.items() if b < -0.01], key=lambda x: -x[1])
creditors= sorted([(p, b) for p, b in bal.items() if b > 0.01], key=lambda x: -x[1])
d_list = [[p, amt] for p, amt in debtors]
c_list = [[p, amt] for p, amt in creditors]
settlements = []
i = j = 0
while i < len(d_list) and j < len(c_list):
debtor, debt = d_list[i]
creditor, cred = c_list[j]
pay = round(min(debt, cred), 2)
settlements.append({'FROM (pays)': debtor, 'TO (receives)': creditor, 'Amount': f"${pay:.2f}"})
d_list[i][1] -= pay
c_list[j][1] -= pay
if d_list[i][1] < 0.01: i += 1
if c_list[j][1] < 0.01: j += 1
s_df = pd.DataFrame(settlements)
print("🏦 FINAL SETTLEMENT TRANSACTIONS:")
print("="*42)
print(s_df.to_string(index=False))
print()
for s in settlements:
print(f" ➡️ {s['FROM (pays)']} pays {s['TO (receives)']}: {s['Amount']}")
🏦 FINAL SETTLEMENT TRANSACTIONS:
==========================================
FROM (pays) TO (receives) Amount
JW HG $511.80
JW JK $91.50
JW DB $3.21
➡️ JW pays HG: $511.80
➡️ JW pays JK: $91.50
➡️ JW pays DB: $3.21
Step 7 — Visual Summary¶
In [19]:
import matplotlib
# matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
fig.patch.set_facecolor('#0f172a')
# ─ Left: Net Balance Bar Chart ─
ax1 = axes[0]
ax1.set_facecolor('#1e293b')
colors = ['#22c55e' if balances[p] > 0 else '#ef4444' for p in people]
vals = [balances[p] for p in people]
bars = ax1.bar(people, vals, color=colors, width=0.5, edgecolor='white', linewidth=0.5)
ax1.axhline(0, color='white', linewidth=0.8, linestyle='--', alpha=0.4)
ax1.set_title('Net Balance Per Person', color='white', fontsize=13, pad=12, fontweight='bold')
ax1.set_ylabel('Amount ($)', color='#94a3b8')
ax1.tick_params(colors='white')
ax1.spines[:].set_visible(False)
for bar, val in zip(bars, vals):
label = f'${val:+,.2f}'
ax1.text(bar.get_x() + bar.get_width()/2, val + (8 if val >= 0 else -18),
label, ha='center', va='bottom', color='white', fontsize=10, fontweight='bold')
# ─ Right: Spend by Trip Pie ─
ax2 = axes[1]
ax2.set_facecolor('#1e293b')
trip_spend = df.groupby('Trip')['Total'].sum()
pie_colors = ['#6366f1','#f59e0b','#10b981','#ec4899','#3b82f6']
wedges, texts, autotexts = ax2.pie(
trip_spend.values, labels=trip_spend.index,
colors=pie_colors[:len(trip_spend)], autopct='%1.1f%%',
startangle=140, textprops={'color': 'white'}, pctdistance=0.75
)
for at in autotexts: at.set_fontsize(9)
ax2.set_title('Total Spend by Trip', color='white', fontsize=13, pad=12, fontweight='bold')
plt.tight_layout(pad=2)
# plt.show()
# print("Chart saved.")
In [ ]:
In [ ]: