splitkaro 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- splitkaro/__init__.py +24 -0
- splitkaro/__main__.py +5 -0
- splitkaro/cli.py +445 -0
- splitkaro/csvio.py +125 -0
- splitkaro/ledger.py +115 -0
- splitkaro/models.py +205 -0
- splitkaro/money.py +96 -0
- splitkaro/report.py +69 -0
- splitkaro/storage.py +128 -0
- splitkaro/upi.py +33 -0
- splitkaro-0.1.0.dist-info/METADATA +323 -0
- splitkaro-0.1.0.dist-info/RECORD +16 -0
- splitkaro-0.1.0.dist-info/WHEEL +5 -0
- splitkaro-0.1.0.dist-info/entry_points.txt +2 -0
- splitkaro-0.1.0.dist-info/licenses/LICENSE +21 -0
- splitkaro-0.1.0.dist-info/top_level.txt +1 -0
splitkaro/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""splitkaro: split shared expenses fairly and settle up with the fewest payments."""
|
|
2
|
+
|
|
3
|
+
from splitkaro.ledger import balances, settle, statement
|
|
4
|
+
from splitkaro.models import Expense, Group, Member, Split, SplitKaroError, SplitKind, Transfer
|
|
5
|
+
from splitkaro.money import allocate, format_money, to_money
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Expense",
|
|
11
|
+
"Group",
|
|
12
|
+
"SplitKaroError",
|
|
13
|
+
"Member",
|
|
14
|
+
"Split",
|
|
15
|
+
"SplitKind",
|
|
16
|
+
"Transfer",
|
|
17
|
+
"allocate",
|
|
18
|
+
"balances",
|
|
19
|
+
"format_money",
|
|
20
|
+
"settle",
|
|
21
|
+
"statement",
|
|
22
|
+
"to_money",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
splitkaro/__main__.py
ADDED
splitkaro/cli.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Command-line interface for splitkaro."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
|
|
12
|
+
from splitkaro import __version__
|
|
13
|
+
from splitkaro.csvio import export_csv, import_csv
|
|
14
|
+
from splitkaro.ledger import balances, settle, statement
|
|
15
|
+
from splitkaro.models import Expense, Group, Split, SplitKaroError, SplitKind, Transfer
|
|
16
|
+
from splitkaro.money import MoneyError, format_money, to_decimal, to_money
|
|
17
|
+
from splitkaro.report import build_report, filter_expenses
|
|
18
|
+
from splitkaro.storage import DEFAULT_FILE, load, save
|
|
19
|
+
from splitkaro.upi import is_valid_vpa, payment_link
|
|
20
|
+
|
|
21
|
+
EXIT_OK = 0
|
|
22
|
+
EXIT_ERROR = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="splitkaro",
|
|
28
|
+
description="Split shared expenses fairly and settle up with the fewest payments.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"-f",
|
|
33
|
+
"--file",
|
|
34
|
+
default=os.environ.get("SPLITKARO_FILE", DEFAULT_FILE),
|
|
35
|
+
help=f"group file (default: $SPLITKARO_FILE or {DEFAULT_FILE})",
|
|
36
|
+
)
|
|
37
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
38
|
+
sub.required = True
|
|
39
|
+
|
|
40
|
+
p = sub.add_parser("init", help="start a new group")
|
|
41
|
+
p.add_argument("name", help='group name, e.g. "Goa trip" or "Flat 302"')
|
|
42
|
+
p.add_argument("--currency", default="INR", help="currency the accounts are kept in (default: INR)")
|
|
43
|
+
p.add_argument("--force", action="store_true", help="overwrite an existing group file")
|
|
44
|
+
|
|
45
|
+
p = sub.add_parser("member", help="add, list or remove members")
|
|
46
|
+
msub = p.add_subparsers(dest="member_command", metavar="ACTION")
|
|
47
|
+
msub.required = True
|
|
48
|
+
m = msub.add_parser("add", help="add a member")
|
|
49
|
+
m.add_argument("names", nargs="+", help="one or more names")
|
|
50
|
+
m.add_argument("--upi", help="UPI ID for payment links (only with a single name)")
|
|
51
|
+
msub.add_parser("list", help="list members")
|
|
52
|
+
m = msub.add_parser("remove", help="remove a member with no expenses or payments")
|
|
53
|
+
m.add_argument("name")
|
|
54
|
+
m = msub.add_parser("upi", help="set a member's UPI ID")
|
|
55
|
+
m.add_argument("name")
|
|
56
|
+
m.add_argument("upi", help="e.g. asha@okbank")
|
|
57
|
+
|
|
58
|
+
p = sub.add_parser("add", help="record an expense")
|
|
59
|
+
p.add_argument("description", help='what it was for, e.g. "Dinner at Fisherman\'s Wharf"')
|
|
60
|
+
p.add_argument("amount", help="total amount, e.g. 1200 or 1,250.50")
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"--paid-by",
|
|
63
|
+
action="append",
|
|
64
|
+
required=True,
|
|
65
|
+
metavar="NAME[=AMOUNT]",
|
|
66
|
+
help="who paid; repeat with NAME=AMOUNT when several people paid",
|
|
67
|
+
)
|
|
68
|
+
how = p.add_mutually_exclusive_group()
|
|
69
|
+
how.add_argument("--among", metavar="A,B,C", help="split equally among these members (default: everyone)")
|
|
70
|
+
how.add_argument("--exact", metavar="A=300,B=900", help="split by exact amounts")
|
|
71
|
+
how.add_argument("--percent", metavar="A=40,B=60", help="split by percentages (must add up to 100)")
|
|
72
|
+
how.add_argument("--shares", metavar="A=2,B=1", help="split by shares, e.g. a couple counts as 2")
|
|
73
|
+
p.add_argument("--category", default="general", help="e.g. food, travel, rent (default: general)")
|
|
74
|
+
p.add_argument("--date", help="YYYY-MM-DD (default: today)")
|
|
75
|
+
p.add_argument("--currency", help="currency of this expense if different from the group's")
|
|
76
|
+
p.add_argument("--note", default="", help="free text")
|
|
77
|
+
|
|
78
|
+
p = sub.add_parser("list", help="list expenses")
|
|
79
|
+
_add_filters(p)
|
|
80
|
+
|
|
81
|
+
p = sub.add_parser("delete", help="delete an expense by id")
|
|
82
|
+
p.add_argument("id", type=int)
|
|
83
|
+
|
|
84
|
+
p = sub.add_parser("balances", help="show who is owed and who owes")
|
|
85
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
86
|
+
|
|
87
|
+
p = sub.add_parser("settle", help="suggest the fewest payments to settle up")
|
|
88
|
+
p.add_argument(
|
|
89
|
+
"--upi", action="store_true", help="include UPI payment links when the receiver has a UPI ID"
|
|
90
|
+
)
|
|
91
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser("pay", help="record a payment between members")
|
|
94
|
+
p.add_argument("sender", help="who paid")
|
|
95
|
+
p.add_argument("receiver", help="who received the money")
|
|
96
|
+
p.add_argument("amount")
|
|
97
|
+
p.add_argument("--date", help="YYYY-MM-DD (default: today)")
|
|
98
|
+
p.add_argument("--note", default="")
|
|
99
|
+
|
|
100
|
+
p = sub.add_parser("statement", help="every entry that changed one member's balance")
|
|
101
|
+
p.add_argument("name")
|
|
102
|
+
|
|
103
|
+
p = sub.add_parser("report", help="spending by category, month and member")
|
|
104
|
+
_add_filters(p)
|
|
105
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
106
|
+
|
|
107
|
+
p = sub.add_parser("rate", help="set the exchange rate for a foreign currency")
|
|
108
|
+
p.add_argument("currency", help="e.g. USD")
|
|
109
|
+
p.add_argument("value", help="value of 1 unit in the group currency, e.g. 83.20")
|
|
110
|
+
|
|
111
|
+
p = sub.add_parser("export", help="export expenses to CSV")
|
|
112
|
+
p.add_argument("path", help="file to write, or - for standard output")
|
|
113
|
+
|
|
114
|
+
p = sub.add_parser("import", help="import expenses from CSV (all rows or none)")
|
|
115
|
+
p.add_argument("path", help="file to read, or - for standard input")
|
|
116
|
+
|
|
117
|
+
return parser
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _add_filters(p: argparse.ArgumentParser) -> None:
|
|
121
|
+
p.add_argument("--month", help="only this month, YYYY-MM")
|
|
122
|
+
p.add_argument("--category", help="only this category")
|
|
123
|
+
p.add_argument("--member", help="only expenses this member paid for or shares in")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main(argv: list[str] | None = None) -> int:
|
|
127
|
+
# Some consoles (e.g. Windows code pages) cannot print "₹"; show "?" instead of crashing.
|
|
128
|
+
for stream in (sys.stdout, sys.stderr):
|
|
129
|
+
if hasattr(stream, "reconfigure"):
|
|
130
|
+
stream.reconfigure(errors="replace")
|
|
131
|
+
args = build_parser().parse_args(argv)
|
|
132
|
+
try:
|
|
133
|
+
if args.command == "init":
|
|
134
|
+
return _cmd_init(args)
|
|
135
|
+
group = load(args.file)
|
|
136
|
+
handler = COMMANDS[args.command]
|
|
137
|
+
changed = handler(group, args)
|
|
138
|
+
if changed:
|
|
139
|
+
save(group, args.file)
|
|
140
|
+
return EXIT_OK
|
|
141
|
+
except (SplitKaroError, MoneyError) as exc:
|
|
142
|
+
print(f"splitkaro: {exc}", file=sys.stderr)
|
|
143
|
+
return EXIT_ERROR
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Each handler returns True when the group changed and must be saved.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
150
|
+
if os.path.exists(args.file) and not args.force:
|
|
151
|
+
raise SplitKaroError(f"{args.file} already exists (use --force to overwrite)")
|
|
152
|
+
group = Group(name=args.name, currency=args.currency.upper())
|
|
153
|
+
save(group, args.file)
|
|
154
|
+
print(f"Created group {args.name!r} in {args.file} ({group.currency}).")
|
|
155
|
+
print("Next: splitkaro member add <name> <name> ...")
|
|
156
|
+
return EXIT_OK
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _cmd_member(group: Group, args: argparse.Namespace) -> bool:
|
|
160
|
+
if args.member_command == "add":
|
|
161
|
+
if args.upi and len(args.names) > 1:
|
|
162
|
+
raise SplitKaroError("--upi can only be used when adding one member")
|
|
163
|
+
if args.upi and not is_valid_vpa(args.upi):
|
|
164
|
+
raise SplitKaroError(f"{args.upi!r} does not look like a UPI ID (expected name@bank)")
|
|
165
|
+
for name in args.names:
|
|
166
|
+
group.add_member(name, args.upi)
|
|
167
|
+
print(f"Added {', '.join(args.names)}.")
|
|
168
|
+
return True
|
|
169
|
+
if args.member_command == "remove":
|
|
170
|
+
group.remove_member(args.name)
|
|
171
|
+
print(f"Removed {args.name}.")
|
|
172
|
+
return True
|
|
173
|
+
if args.member_command == "upi":
|
|
174
|
+
if not is_valid_vpa(args.upi):
|
|
175
|
+
raise SplitKaroError(f"{args.upi!r} does not look like a UPI ID (expected name@bank)")
|
|
176
|
+
group.member(args.name).upi = args.upi
|
|
177
|
+
print(f"Set UPI ID for {group.resolve(args.name)}.")
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
if not group.members:
|
|
181
|
+
print("No members yet. Add some with: splitkaro member add <name> ...")
|
|
182
|
+
for m in group.members:
|
|
183
|
+
print(f"{m.name}{' (' + m.upi + ')' if m.upi else ''}")
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _cmd_add(group: Group, args: argparse.Namespace) -> bool:
|
|
188
|
+
if not group.members:
|
|
189
|
+
raise SplitKaroError("add members first: splitkaro member add <name> ...")
|
|
190
|
+
amount = to_money(args.amount)
|
|
191
|
+
|
|
192
|
+
if args.exact:
|
|
193
|
+
split = Split.weighted(SplitKind.EXACT, _parse_pairs(args.exact, to_money))
|
|
194
|
+
elif args.percent:
|
|
195
|
+
split = Split.weighted(SplitKind.PERCENT, _parse_pairs(args.percent, to_decimal))
|
|
196
|
+
elif args.shares:
|
|
197
|
+
split = Split.weighted(SplitKind.SHARES, _parse_pairs(args.shares, to_decimal))
|
|
198
|
+
else:
|
|
199
|
+
among = [n.strip() for n in args.among.split(",")] if args.among else [m.name for m in group.members]
|
|
200
|
+
split = Split.equal(among)
|
|
201
|
+
|
|
202
|
+
expense = group.add_expense(
|
|
203
|
+
Expense(
|
|
204
|
+
id=0,
|
|
205
|
+
description=args.description,
|
|
206
|
+
amount=amount,
|
|
207
|
+
paid_by=_parse_payers(args.paid_by, amount),
|
|
208
|
+
split=split,
|
|
209
|
+
date=_parse_date(args.date),
|
|
210
|
+
currency=(args.currency or group.currency).upper(),
|
|
211
|
+
category=args.category,
|
|
212
|
+
note=args.note,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
print(f"Added #{expense.id}: {expense.description}, {format_money(expense.amount, expense.currency)}")
|
|
216
|
+
for name, portion in expense.split.portions(expense.amount).items():
|
|
217
|
+
print(f" {name:<16} {format_money(portion, expense.currency):>14}")
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _cmd_list(group: Group, args: argparse.Namespace) -> bool:
|
|
222
|
+
expenses = filter_expenses(group, args.month, args.category, args.member)
|
|
223
|
+
if not expenses:
|
|
224
|
+
print("No expenses.")
|
|
225
|
+
return False
|
|
226
|
+
for e in expenses:
|
|
227
|
+
payers = ", ".join(e.paid_by)
|
|
228
|
+
print(
|
|
229
|
+
f"#{e.id:<4} {e.date} {format_money(e.amount, e.currency):>14} "
|
|
230
|
+
f"{e.description} [{e.category}] paid by {payers}"
|
|
231
|
+
)
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _cmd_delete(group: Group, args: argparse.Namespace) -> bool:
|
|
236
|
+
expense = group.delete_expense(args.id)
|
|
237
|
+
print(f"Deleted #{expense.id}: {expense.description}")
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _cmd_balances(group: Group, args: argparse.Namespace) -> bool:
|
|
242
|
+
net = balances(group)
|
|
243
|
+
if args.json:
|
|
244
|
+
_print_json({"currency": group.currency, "balances": {k: str(v) for k, v in net.items()}})
|
|
245
|
+
return False
|
|
246
|
+
for name, amount in sorted(net.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
247
|
+
if amount > 0:
|
|
248
|
+
status = f"gets back {format_money(amount, group.currency)}"
|
|
249
|
+
elif amount < 0:
|
|
250
|
+
status = f"owes {format_money(-amount, group.currency)}"
|
|
251
|
+
else:
|
|
252
|
+
status = "settled up"
|
|
253
|
+
print(f"{name:<16} {status}")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _cmd_settle(group: Group, args: argparse.Namespace) -> bool:
|
|
258
|
+
plan = settle(balances(group))
|
|
259
|
+
links = {}
|
|
260
|
+
if args.upi:
|
|
261
|
+
if group.currency != "INR":
|
|
262
|
+
raise SplitKaroError("UPI links are only available for groups that use INR")
|
|
263
|
+
for t in plan:
|
|
264
|
+
vpa = group.member(t.receiver).upi
|
|
265
|
+
if vpa:
|
|
266
|
+
links[id(t)] = payment_link(vpa, t.receiver, t.amount, note=f"splitkaro: {group.name}")
|
|
267
|
+
|
|
268
|
+
if args.json:
|
|
269
|
+
_print_json(
|
|
270
|
+
{
|
|
271
|
+
"currency": group.currency,
|
|
272
|
+
"payments": [
|
|
273
|
+
{"from": t.sender, "to": t.receiver, "amount": str(t.amount), "upi": links.get(id(t))}
|
|
274
|
+
for t in plan
|
|
275
|
+
],
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
if not plan:
|
|
281
|
+
print("Everyone is settled up. 🎉")
|
|
282
|
+
return False
|
|
283
|
+
print(f"{len(plan)} payment(s) settle everything:")
|
|
284
|
+
for t in plan:
|
|
285
|
+
print(f" {t.sender} pays {t.receiver} {format_money(t.amount, group.currency)}")
|
|
286
|
+
if id(t) in links:
|
|
287
|
+
print(f" {links[id(t)]}")
|
|
288
|
+
print("\nAfter paying, record it with: splitkaro pay <from> <to> <amount>")
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _cmd_pay(group: Group, args: argparse.Namespace) -> bool:
|
|
293
|
+
t = group.add_transfer(
|
|
294
|
+
Transfer(args.sender, args.receiver, to_money(args.amount), _parse_date(args.date), args.note)
|
|
295
|
+
)
|
|
296
|
+
print(f"Recorded: {t.sender} paid {t.receiver} {format_money(t.amount, group.currency)}")
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _cmd_statement(group: Group, args: argparse.Namespace) -> bool:
|
|
301
|
+
lines = statement(group, args.name)
|
|
302
|
+
running = Decimal("0.00")
|
|
303
|
+
for line in lines:
|
|
304
|
+
running += line.change
|
|
305
|
+
print(
|
|
306
|
+
f"{line.date:<10} {format_money(line.change, group.currency):>14} "
|
|
307
|
+
f"{format_money(running, group.currency):>14} {line.description}"
|
|
308
|
+
)
|
|
309
|
+
name = group.resolve(args.name)
|
|
310
|
+
if running > 0:
|
|
311
|
+
print(f"\n{name} gets back {format_money(running, group.currency)}")
|
|
312
|
+
elif running < 0:
|
|
313
|
+
print(f"\n{name} owes {format_money(-running, group.currency)}")
|
|
314
|
+
else:
|
|
315
|
+
print(f"\n{name} is settled up")
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _cmd_report(group: Group, args: argparse.Namespace) -> bool:
|
|
320
|
+
report = build_report(group, filter_expenses(group, args.month, args.category, args.member))
|
|
321
|
+
if args.json:
|
|
322
|
+
_print_json(
|
|
323
|
+
{
|
|
324
|
+
"currency": group.currency,
|
|
325
|
+
"total": str(report.total),
|
|
326
|
+
"count": report.count,
|
|
327
|
+
"by_category": {k: str(v) for k, v in report.by_category.items()},
|
|
328
|
+
"by_month": {k: str(v) for k, v in report.by_month.items()},
|
|
329
|
+
"paid_by_member": {k: str(v) for k, v in report.paid_by_member.items()},
|
|
330
|
+
"share_by_member": {k: str(v) for k, v in report.share_by_member.items()},
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
cur = group.currency
|
|
336
|
+
print(f"{group.name}: {report.count} expense(s), {format_money(report.total, cur)} in total\n")
|
|
337
|
+
print("By category")
|
|
338
|
+
for name, amount in report.by_category.items():
|
|
339
|
+
print(f" {name:<16} {format_money(amount, cur):>14} {_percent(amount, report.total)}")
|
|
340
|
+
print("\nBy month")
|
|
341
|
+
for month, amount in report.by_month.items():
|
|
342
|
+
print(f" {month:<16} {format_money(amount, cur):>14}")
|
|
343
|
+
print(f"\n {'member':<16} {'paid':>14} {'share':>14}")
|
|
344
|
+
for name in report.paid_by_member:
|
|
345
|
+
print(
|
|
346
|
+
f" {name:<16} {format_money(report.paid_by_member[name], cur):>14} "
|
|
347
|
+
f"{format_money(report.share_by_member[name], cur):>14}"
|
|
348
|
+
)
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _cmd_rate(group: Group, args: argparse.Namespace) -> bool:
|
|
353
|
+
currency = args.currency.upper()
|
|
354
|
+
if currency == group.currency:
|
|
355
|
+
raise SplitKaroError(f"{currency} is already the group currency")
|
|
356
|
+
value = to_decimal(args.value)
|
|
357
|
+
if value <= 0:
|
|
358
|
+
raise SplitKaroError("an exchange rate must be greater than zero")
|
|
359
|
+
group.rates[currency] = value
|
|
360
|
+
print(f"1 {currency} = {value} {group.currency}")
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _cmd_export(group: Group, args: argparse.Namespace) -> bool:
|
|
365
|
+
if args.path == "-":
|
|
366
|
+
export_csv(group, sys.stdout)
|
|
367
|
+
return False
|
|
368
|
+
with open(args.path, "w", encoding="utf-8", newline="") as handle:
|
|
369
|
+
count = export_csv(group, handle)
|
|
370
|
+
print(f"Exported {count} expense(s) to {args.path}")
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _cmd_import(group: Group, args: argparse.Namespace) -> bool:
|
|
375
|
+
try:
|
|
376
|
+
if args.path == "-":
|
|
377
|
+
added = import_csv(group, sys.stdin)
|
|
378
|
+
else:
|
|
379
|
+
with open(args.path, encoding="utf-8-sig", newline="") as handle:
|
|
380
|
+
added = import_csv(group, handle)
|
|
381
|
+
except FileNotFoundError:
|
|
382
|
+
raise SplitKaroError(f"{args.path} not found") from None
|
|
383
|
+
print(f"Imported {len(added)} expense(s).")
|
|
384
|
+
return bool(added)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
COMMANDS = {
|
|
388
|
+
"member": _cmd_member,
|
|
389
|
+
"add": _cmd_add,
|
|
390
|
+
"list": _cmd_list,
|
|
391
|
+
"delete": _cmd_delete,
|
|
392
|
+
"balances": _cmd_balances,
|
|
393
|
+
"settle": _cmd_settle,
|
|
394
|
+
"pay": _cmd_pay,
|
|
395
|
+
"statement": _cmd_statement,
|
|
396
|
+
"report": _cmd_report,
|
|
397
|
+
"rate": _cmd_rate,
|
|
398
|
+
"export": _cmd_export,
|
|
399
|
+
"import": _cmd_import,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parse_pairs(text: str, parse) -> dict[str, Decimal]:
|
|
404
|
+
pairs = {}
|
|
405
|
+
for item in text.split(","):
|
|
406
|
+
name, sep, value = item.partition("=")
|
|
407
|
+
if not sep or not name.strip():
|
|
408
|
+
raise SplitKaroError(f"expected NAME=VALUE, got {item.strip()!r}")
|
|
409
|
+
pairs[name.strip()] = parse(value)
|
|
410
|
+
return pairs
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _parse_payers(values: list[str], amount: Decimal) -> dict[str, Decimal]:
|
|
414
|
+
if len(values) == 1 and "=" not in values[0]:
|
|
415
|
+
return {values[0].strip(): amount}
|
|
416
|
+
payers: dict[str, Decimal] = {}
|
|
417
|
+
for value in values:
|
|
418
|
+
if "=" not in value:
|
|
419
|
+
raise SplitKaroError("with several payers, give each as NAME=AMOUNT")
|
|
420
|
+
payers.update(_parse_pairs(value, to_money))
|
|
421
|
+
return payers
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _parse_date(value: str | None) -> dt.date:
|
|
425
|
+
if not value:
|
|
426
|
+
return dt.date.today()
|
|
427
|
+
try:
|
|
428
|
+
return dt.date.fromisoformat(value)
|
|
429
|
+
except ValueError:
|
|
430
|
+
raise SplitKaroError(f"invalid date {value!r}; use YYYY-MM-DD") from None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _percent(part: Decimal, total: Decimal) -> str:
|
|
434
|
+
if not total:
|
|
435
|
+
return ""
|
|
436
|
+
return f"{(part / total * 100):.0f}%"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _print_json(data: object) -> None:
|
|
440
|
+
json.dump(data, sys.stdout, indent=2, ensure_ascii=False)
|
|
441
|
+
sys.stdout.write("\n")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
if __name__ == "__main__":
|
|
445
|
+
sys.exit(main())
|
splitkaro/csvio.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Import and export expenses as CSV, for spreadsheets and other apps.
|
|
2
|
+
|
|
3
|
+
Columns (only ``date``, ``description``, ``amount`` and ``paid_by`` are required):
|
|
4
|
+
|
|
5
|
+
date,description,amount,paid_by,split,split_kind,category,currency,note
|
|
6
|
+
2026-11-02,Dinner,1200,Asha,,equal,food,INR,
|
|
7
|
+
2026-11-03,Cab,900,Asha:500;Ravi:400,Asha;Ravi,equal,travel,INR,
|
|
8
|
+
2026-11-03,Hotel,6000,Ravi,Asha:2;Ravi:1,shares,stay,INR,
|
|
9
|
+
|
|
10
|
+
``paid_by`` is one name (who paid everything) or ``name:amount`` pairs.
|
|
11
|
+
``split`` lists names for ``equal`` (empty means every member) or
|
|
12
|
+
``name:value`` pairs for ``exact``, ``percent`` and ``shares``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import csv
|
|
19
|
+
import datetime as dt
|
|
20
|
+
from typing import IO
|
|
21
|
+
|
|
22
|
+
from splitkaro.models import Expense, Group, Split, SplitKaroError, SplitKind
|
|
23
|
+
from splitkaro.money import MoneyError, to_decimal, to_money
|
|
24
|
+
|
|
25
|
+
COLUMNS = [
|
|
26
|
+
"id",
|
|
27
|
+
"date",
|
|
28
|
+
"description",
|
|
29
|
+
"amount",
|
|
30
|
+
"currency",
|
|
31
|
+
"category",
|
|
32
|
+
"paid_by",
|
|
33
|
+
"split_kind",
|
|
34
|
+
"split",
|
|
35
|
+
"note",
|
|
36
|
+
]
|
|
37
|
+
REQUIRED = {"date", "description", "amount", "paid_by"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def export_csv(group: Group, handle: IO[str]) -> int:
|
|
41
|
+
writer = csv.DictWriter(handle, fieldnames=COLUMNS, lineterminator="\n")
|
|
42
|
+
writer.writeheader()
|
|
43
|
+
for e in sorted(group.expenses, key=lambda x: (x.date, x.id)):
|
|
44
|
+
if e.split.kind is SplitKind.EQUAL:
|
|
45
|
+
split = ";".join(e.split.among)
|
|
46
|
+
else:
|
|
47
|
+
split = ";".join(f"{k}:{v}" for k, v in e.split.values.items())
|
|
48
|
+
writer.writerow(
|
|
49
|
+
{
|
|
50
|
+
"id": e.id,
|
|
51
|
+
"date": e.date.isoformat(),
|
|
52
|
+
"description": e.description,
|
|
53
|
+
"amount": str(e.amount),
|
|
54
|
+
"currency": e.currency,
|
|
55
|
+
"category": e.category,
|
|
56
|
+
"paid_by": ";".join(f"{k}:{v}" for k, v in e.paid_by.items()),
|
|
57
|
+
"split_kind": e.split.kind.value,
|
|
58
|
+
"split": split,
|
|
59
|
+
"note": e.note,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return len(group.expenses)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def import_csv(group: Group, handle: IO[str]) -> list[Expense]:
|
|
66
|
+
"""Add every row as an expense. Either all rows are added, or none are."""
|
|
67
|
+
reader = csv.DictReader(handle)
|
|
68
|
+
missing = REQUIRED - set(reader.fieldnames or [])
|
|
69
|
+
if missing:
|
|
70
|
+
raise SplitKaroError(f"CSV is missing column(s): {', '.join(sorted(missing))}")
|
|
71
|
+
|
|
72
|
+
trial = copy.deepcopy(group)
|
|
73
|
+
added = []
|
|
74
|
+
for row_number, row in enumerate(reader, start=2):
|
|
75
|
+
try:
|
|
76
|
+
added.append(trial.add_expense(_row_to_expense(trial, row)))
|
|
77
|
+
except (SplitKaroError, MoneyError, ValueError) as exc:
|
|
78
|
+
raise SplitKaroError(f"row {row_number}: {exc}") from None
|
|
79
|
+
|
|
80
|
+
# Every row is valid, so apply the result to the real group.
|
|
81
|
+
group.expenses, group.next_id = trial.expenses, trial.next_id
|
|
82
|
+
return added
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _row_to_expense(group: Group, row: dict[str, str]) -> Expense:
|
|
86
|
+
amount = to_money(row["amount"])
|
|
87
|
+
currency = (row.get("currency") or group.currency).strip().upper()
|
|
88
|
+
kind = SplitKind((row.get("split_kind") or "equal").strip().lower())
|
|
89
|
+
split_text = (row.get("split") or "").strip()
|
|
90
|
+
|
|
91
|
+
if kind is SplitKind.EQUAL:
|
|
92
|
+
among = [n.strip() for n in split_text.split(";") if n.strip()] or [m.name for m in group.members]
|
|
93
|
+
split = Split.equal(among)
|
|
94
|
+
else:
|
|
95
|
+
split = Split.weighted(kind, {k: to_decimal(v) for k, v in _pairs(split_text).items()})
|
|
96
|
+
|
|
97
|
+
paid_text = row["paid_by"].strip()
|
|
98
|
+
if ":" in paid_text:
|
|
99
|
+
paid_by = {k: to_money(v) for k, v in _pairs(paid_text).items()}
|
|
100
|
+
else:
|
|
101
|
+
paid_by = {paid_text: amount}
|
|
102
|
+
|
|
103
|
+
return Expense(
|
|
104
|
+
id=0,
|
|
105
|
+
description=row["description"].strip(),
|
|
106
|
+
amount=amount,
|
|
107
|
+
paid_by=paid_by,
|
|
108
|
+
split=split,
|
|
109
|
+
date=dt.date.fromisoformat(row["date"].strip()),
|
|
110
|
+
currency=currency,
|
|
111
|
+
category=(row.get("category") or "general").strip() or "general",
|
|
112
|
+
note=(row.get("note") or "").strip(),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _pairs(text: str) -> dict[str, str]:
|
|
117
|
+
pairs = {}
|
|
118
|
+
for item in text.split(";"):
|
|
119
|
+
if not item.strip():
|
|
120
|
+
continue
|
|
121
|
+
name, sep, value = item.partition(":")
|
|
122
|
+
if not sep:
|
|
123
|
+
raise SplitKaroError(f"expected name:value, got {item!r}")
|
|
124
|
+
pairs[name.strip()] = value.strip()
|
|
125
|
+
return pairs
|