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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from splitkaro.cli import main
4
+
5
+ sys.exit(main())
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