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/ledger.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Who owes whom, and the fewest payments that settle everything."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
from splitkaro.models import Expense, Group, Transfer
|
|
9
|
+
from splitkaro.money import ZERO, allocate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ExpenseEffect:
|
|
14
|
+
"""An expense converted to the group currency: what each member paid and owes."""
|
|
15
|
+
|
|
16
|
+
paid: dict[str, Decimal]
|
|
17
|
+
owed: dict[str, Decimal]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def effect(group: Group, expense: Expense) -> ExpenseEffect:
|
|
21
|
+
total = group.to_group_currency(expense.amount, expense.currency)
|
|
22
|
+
payers = list(expense.paid_by)
|
|
23
|
+
# Converting each payer separately could drift by a paisa, so share the
|
|
24
|
+
# converted total among payers in proportion to what they paid instead.
|
|
25
|
+
paid_parts = allocate(total, [expense.paid_by[p] for p in payers])
|
|
26
|
+
return ExpenseEffect(
|
|
27
|
+
paid=dict(zip(payers, paid_parts)),
|
|
28
|
+
owed=expense.split.portions(total),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def balances(group: Group) -> dict[str, Decimal]:
|
|
33
|
+
"""Net position of every member in the group currency.
|
|
34
|
+
|
|
35
|
+
Positive: the group owes them money. Negative: they owe the group.
|
|
36
|
+
The balances always add up to exactly zero.
|
|
37
|
+
"""
|
|
38
|
+
net = {m.name: ZERO for m in group.members}
|
|
39
|
+
for expense in group.expenses:
|
|
40
|
+
e = effect(group, expense)
|
|
41
|
+
for name, amount in e.paid.items():
|
|
42
|
+
net[name] += amount
|
|
43
|
+
for name, amount in e.owed.items():
|
|
44
|
+
net[name] -= amount
|
|
45
|
+
for t in group.transfers:
|
|
46
|
+
net[t.sender] += t.amount
|
|
47
|
+
net[t.receiver] -= t.amount
|
|
48
|
+
return net
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def settle(net: dict[str, Decimal]) -> list[Transfer]:
|
|
52
|
+
"""Suggest payments that bring every balance to zero.
|
|
53
|
+
|
|
54
|
+
First, anyone who owes exactly what someone else is owed pays them
|
|
55
|
+
directly. The rest is settled greedily, always matching the largest
|
|
56
|
+
debtor with the largest creditor. This never needs more than n - 1
|
|
57
|
+
payments for n people with non-zero balances, and in everyday groups it
|
|
58
|
+
is usually the minimum. (Finding the true minimum is NP-hard.)
|
|
59
|
+
"""
|
|
60
|
+
debtors = {name: -amount for name, amount in net.items() if amount < 0}
|
|
61
|
+
creditors = {name: amount for name, amount in net.items() if amount > 0}
|
|
62
|
+
plan: list[Transfer] = []
|
|
63
|
+
|
|
64
|
+
for debtor in sorted(debtors):
|
|
65
|
+
for creditor in sorted(creditors):
|
|
66
|
+
if creditors[creditor] == debtors[debtor] and creditors[creditor] > 0:
|
|
67
|
+
plan.append(Transfer(debtor, creditor, debtors[debtor]))
|
|
68
|
+
creditors[creditor] = debtors[debtor] = ZERO
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
while True:
|
|
72
|
+
debtor = _largest(debtors)
|
|
73
|
+
creditor = _largest(creditors)
|
|
74
|
+
if debtor is None or creditor is None:
|
|
75
|
+
break
|
|
76
|
+
amount = min(debtors[debtor], creditors[creditor])
|
|
77
|
+
plan.append(Transfer(debtor, creditor, amount))
|
|
78
|
+
debtors[debtor] -= amount
|
|
79
|
+
creditors[creditor] -= amount
|
|
80
|
+
|
|
81
|
+
return plan
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _largest(amounts: dict[str, Decimal]) -> str | None:
|
|
85
|
+
# Sort by amount, then name, so the result is the same on every run.
|
|
86
|
+
candidates = [(-amount, name) for name, amount in amounts.items() if amount > 0]
|
|
87
|
+
return min(candidates)[1] if candidates else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class StatementLine:
|
|
92
|
+
date: str
|
|
93
|
+
description: str
|
|
94
|
+
change: Decimal # effect on the member's balance
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def statement(group: Group, name: str) -> list[StatementLine]:
|
|
98
|
+
"""Every expense and payment that changed one member's balance, in date order."""
|
|
99
|
+
name = group.resolve(name)
|
|
100
|
+
lines: list[StatementLine] = []
|
|
101
|
+
for expense in sorted(group.expenses, key=lambda x: (x.date, x.id)):
|
|
102
|
+
e = effect(group, expense)
|
|
103
|
+
change = e.paid.get(name, ZERO) - e.owed.get(name, ZERO)
|
|
104
|
+
if name in e.paid or name in e.owed:
|
|
105
|
+
lines.append(
|
|
106
|
+
StatementLine(expense.date.isoformat(), f"#{expense.id} {expense.description}", change)
|
|
107
|
+
)
|
|
108
|
+
for t in group.transfers:
|
|
109
|
+
when = t.date.isoformat() if t.date else ""
|
|
110
|
+
if t.sender == name:
|
|
111
|
+
lines.append(StatementLine(when, f"paid {t.receiver}", t.amount))
|
|
112
|
+
elif t.receiver == name:
|
|
113
|
+
lines.append(StatementLine(when, f"received from {t.sender}", -t.amount))
|
|
114
|
+
# Undated payments go last; the sort is stable, so same-day entries keep their order.
|
|
115
|
+
return sorted(lines, key=lambda line: line.date or "9999-12-31")
|
splitkaro/models.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The data that makes up a group's splitkaro: members, expenses and payments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import enum
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from splitkaro.money import ZERO, MoneyError, allocate, to_money
|
|
11
|
+
|
|
12
|
+
HUNDRED = Decimal(100)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SplitKaroError(ValueError):
|
|
16
|
+
"""A request that would leave the group's records inconsistent."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SplitKind(str, enum.Enum):
|
|
20
|
+
EQUAL = "equal" # everyone in `among` pays the same
|
|
21
|
+
EXACT = "exact" # values are amounts and must add up to the total
|
|
22
|
+
PERCENT = "percent" # values are percentages and must add up to 100
|
|
23
|
+
SHARES = "shares" # values are weights, e.g. 2 shares for a couple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Split:
|
|
28
|
+
kind: SplitKind
|
|
29
|
+
# Members taking part, in order. For EQUAL only this is used; for the other
|
|
30
|
+
# kinds it is the keys of `values`.
|
|
31
|
+
among: list[str]
|
|
32
|
+
values: dict[str, Decimal] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def equal(cls, among: list[str]) -> Split:
|
|
36
|
+
return cls(SplitKind.EQUAL, list(among))
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def weighted(cls, kind: SplitKind, values: dict[str, Decimal]) -> Split:
|
|
40
|
+
return cls(kind, list(values), dict(values))
|
|
41
|
+
|
|
42
|
+
def portions(self, total: Decimal) -> dict[str, Decimal]:
|
|
43
|
+
"""How much of ``total`` each member owes. Always adds up exactly."""
|
|
44
|
+
if not self.among:
|
|
45
|
+
raise SplitKaroError("an expense must be split among at least one member")
|
|
46
|
+
if len(set(self.among)) != len(self.among):
|
|
47
|
+
raise SplitKaroError("a member appears twice in the split")
|
|
48
|
+
|
|
49
|
+
if self.kind is SplitKind.EQUAL:
|
|
50
|
+
weights = [Decimal(1)] * len(self.among)
|
|
51
|
+
elif self.kind is SplitKind.EXACT:
|
|
52
|
+
amounts = {name: to_money(v) for name, v in self.values.items()}
|
|
53
|
+
if any(v < ZERO for v in amounts.values()):
|
|
54
|
+
raise SplitKaroError("exact amounts cannot be negative")
|
|
55
|
+
if sum(amounts.values()) != total:
|
|
56
|
+
raise SplitKaroError(
|
|
57
|
+
f"exact amounts add up to {sum(amounts.values())}, not the total {total}"
|
|
58
|
+
)
|
|
59
|
+
return amounts
|
|
60
|
+
elif self.kind is SplitKind.PERCENT:
|
|
61
|
+
if sum(self.values.values()) != HUNDRED:
|
|
62
|
+
raise SplitKaroError(f"percentages add up to {sum(self.values.values())}, not 100")
|
|
63
|
+
weights = [self.values[name] for name in self.among]
|
|
64
|
+
else:
|
|
65
|
+
weights = [self.values[name] for name in self.among]
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
parts = allocate(total, weights)
|
|
69
|
+
except MoneyError as exc:
|
|
70
|
+
raise SplitKaroError(str(exc)) from None
|
|
71
|
+
return dict(zip(self.among, parts))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Member:
|
|
76
|
+
name: str
|
|
77
|
+
upi: str | None = None # e.g. "asha@okbank", used for payment links
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Expense:
|
|
82
|
+
id: int
|
|
83
|
+
description: str
|
|
84
|
+
amount: Decimal
|
|
85
|
+
# Who paid, and how much each. Usually one person paid everything.
|
|
86
|
+
paid_by: dict[str, Decimal]
|
|
87
|
+
split: Split
|
|
88
|
+
date: dt.date
|
|
89
|
+
currency: str
|
|
90
|
+
category: str = "general"
|
|
91
|
+
note: str = ""
|
|
92
|
+
|
|
93
|
+
def validate(self) -> None:
|
|
94
|
+
if self.amount <= ZERO:
|
|
95
|
+
raise SplitKaroError("an expense amount must be greater than zero")
|
|
96
|
+
if not self.paid_by:
|
|
97
|
+
raise SplitKaroError("an expense needs at least one payer")
|
|
98
|
+
if any(v <= ZERO for v in self.paid_by.values()):
|
|
99
|
+
raise SplitKaroError("each payer must have paid more than zero")
|
|
100
|
+
paid = sum(self.paid_by.values())
|
|
101
|
+
if paid != self.amount:
|
|
102
|
+
raise SplitKaroError(f"payers paid {paid} in total, but the expense is {self.amount}")
|
|
103
|
+
self.split.portions(self.amount) # raises if the split is inconsistent
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Transfer:
|
|
108
|
+
"""Money handed from one member to another to settle up."""
|
|
109
|
+
|
|
110
|
+
sender: str
|
|
111
|
+
receiver: str
|
|
112
|
+
amount: Decimal
|
|
113
|
+
date: dt.date | None = None
|
|
114
|
+
note: str = ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Group:
|
|
119
|
+
name: str
|
|
120
|
+
currency: str = "INR"
|
|
121
|
+
members: list[Member] = field(default_factory=list)
|
|
122
|
+
expenses: list[Expense] = field(default_factory=list)
|
|
123
|
+
transfers: list[Transfer] = field(default_factory=list)
|
|
124
|
+
# Value of one unit of a foreign currency in the group currency,
|
|
125
|
+
# e.g. {"USD": Decimal("83.20")} for a group that keeps accounts in INR.
|
|
126
|
+
rates: dict[str, Decimal] = field(default_factory=dict)
|
|
127
|
+
next_id: int = 1
|
|
128
|
+
|
|
129
|
+
# -- members -------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def member(self, name: str) -> Member:
|
|
132
|
+
for m in self.members:
|
|
133
|
+
if m.name.casefold() == name.casefold():
|
|
134
|
+
return m
|
|
135
|
+
known = ", ".join(m.name for m in self.members) or "nobody yet"
|
|
136
|
+
raise SplitKaroError(f"no member called {name!r} (members: {known})")
|
|
137
|
+
|
|
138
|
+
def resolve(self, name: str) -> str:
|
|
139
|
+
"""Return the member's canonical spelling; names are case-insensitive."""
|
|
140
|
+
return self.member(name).name
|
|
141
|
+
|
|
142
|
+
def add_member(self, name: str, upi: str | None = None) -> Member:
|
|
143
|
+
name = name.strip()
|
|
144
|
+
if not name:
|
|
145
|
+
raise SplitKaroError("a member needs a name")
|
|
146
|
+
if any(m.name.casefold() == name.casefold() for m in self.members):
|
|
147
|
+
raise SplitKaroError(f"{name!r} is already a member")
|
|
148
|
+
member = Member(name, upi)
|
|
149
|
+
self.members.append(member)
|
|
150
|
+
return member
|
|
151
|
+
|
|
152
|
+
def remove_member(self, name: str) -> None:
|
|
153
|
+
name = self.resolve(name)
|
|
154
|
+
if self._mentions(name):
|
|
155
|
+
raise SplitKaroError(f"{name} appears in expenses or payments; settle up and delete those first")
|
|
156
|
+
self.members = [m for m in self.members if m.name != name]
|
|
157
|
+
|
|
158
|
+
def _mentions(self, name: str) -> bool:
|
|
159
|
+
for e in self.expenses:
|
|
160
|
+
if name in e.paid_by or name in e.split.among:
|
|
161
|
+
return True
|
|
162
|
+
return any(name in (t.sender, t.receiver) for t in self.transfers)
|
|
163
|
+
|
|
164
|
+
# -- expenses and payments ----------------------------------------
|
|
165
|
+
|
|
166
|
+
def add_expense(self, expense: Expense) -> Expense:
|
|
167
|
+
expense.amount = to_money(expense.amount)
|
|
168
|
+
expense.paid_by = {self.resolve(k): to_money(v) for k, v in expense.paid_by.items()}
|
|
169
|
+
expense.split.among = [self.resolve(n) for n in expense.split.among]
|
|
170
|
+
expense.split.values = {self.resolve(k): v for k, v in expense.split.values.items()}
|
|
171
|
+
if expense.currency != self.currency and expense.currency not in self.rates:
|
|
172
|
+
cur = expense.currency
|
|
173
|
+
raise SplitKaroError(f"no exchange rate for {cur}; set one with: splitkaro rate {cur} <value>")
|
|
174
|
+
expense.validate()
|
|
175
|
+
expense.id = self.next_id
|
|
176
|
+
self.next_id += 1
|
|
177
|
+
self.expenses.append(expense)
|
|
178
|
+
return expense
|
|
179
|
+
|
|
180
|
+
def expense(self, expense_id: int) -> Expense:
|
|
181
|
+
for e in self.expenses:
|
|
182
|
+
if e.id == expense_id:
|
|
183
|
+
return e
|
|
184
|
+
raise SplitKaroError(f"no expense with id {expense_id}")
|
|
185
|
+
|
|
186
|
+
def delete_expense(self, expense_id: int) -> Expense:
|
|
187
|
+
expense = self.expense(expense_id)
|
|
188
|
+
self.expenses.remove(expense)
|
|
189
|
+
return expense
|
|
190
|
+
|
|
191
|
+
def add_transfer(self, transfer: Transfer) -> Transfer:
|
|
192
|
+
transfer.sender = self.resolve(transfer.sender)
|
|
193
|
+
transfer.receiver = self.resolve(transfer.receiver)
|
|
194
|
+
transfer.amount = to_money(transfer.amount)
|
|
195
|
+
if transfer.sender == transfer.receiver:
|
|
196
|
+
raise SplitKaroError("a member cannot pay themselves")
|
|
197
|
+
if transfer.amount <= ZERO:
|
|
198
|
+
raise SplitKaroError("a payment must be greater than zero")
|
|
199
|
+
self.transfers.append(transfer)
|
|
200
|
+
return transfer
|
|
201
|
+
|
|
202
|
+
def to_group_currency(self, amount: Decimal, currency: str) -> Decimal:
|
|
203
|
+
if currency == self.currency:
|
|
204
|
+
return amount
|
|
205
|
+
return to_money(amount * self.rates[currency])
|
splitkaro/money.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Exact money arithmetic.
|
|
2
|
+
|
|
3
|
+
Amounts are ``Decimal`` values rounded to two places (rupees and paise, or the
|
|
4
|
+
equivalent minor unit). Floats are never used, so 0.1 + 0.2 is exactly 0.3.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
|
10
|
+
|
|
11
|
+
CENT = Decimal("0.01")
|
|
12
|
+
ZERO = Decimal("0.00")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MoneyError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_money(value: object) -> Decimal:
|
|
20
|
+
"""Parse ``value`` (str, int or Decimal) into an amount with two decimals."""
|
|
21
|
+
if isinstance(value, float):
|
|
22
|
+
# Floats cannot represent most amounts exactly; make callers be explicit.
|
|
23
|
+
value = repr(value)
|
|
24
|
+
try:
|
|
25
|
+
amount = Decimal(str(value).replace(",", "").strip())
|
|
26
|
+
except InvalidOperation:
|
|
27
|
+
raise MoneyError(f"not a valid amount: {value!r}") from None
|
|
28
|
+
if not amount.is_finite():
|
|
29
|
+
raise MoneyError(f"not a valid amount: {value!r}")
|
|
30
|
+
return amount.quantize(CENT, rounding=ROUND_HALF_UP)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_decimal(value: object) -> Decimal:
|
|
34
|
+
"""Parse a non-money number such as a share, a percentage or a rate."""
|
|
35
|
+
try:
|
|
36
|
+
number = Decimal(str(value).strip())
|
|
37
|
+
except InvalidOperation:
|
|
38
|
+
raise MoneyError(f"not a valid number: {value!r}") from None
|
|
39
|
+
if not number.is_finite():
|
|
40
|
+
raise MoneyError(f"not a valid number: {value!r}")
|
|
41
|
+
return number
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def allocate(total: Decimal, weights: list[Decimal]) -> list[Decimal]:
|
|
45
|
+
"""Split ``total`` in proportion to ``weights`` so the parts add up exactly.
|
|
46
|
+
|
|
47
|
+
Uses the largest-remainder method on whole paise: every part is rounded
|
|
48
|
+
down, then the leftover paise go to the parts that lost the most to
|
|
49
|
+
rounding (earlier parts win ties). Splitting 100.00 three ways gives
|
|
50
|
+
33.34, 33.33, 33.33.
|
|
51
|
+
"""
|
|
52
|
+
if not weights:
|
|
53
|
+
raise MoneyError("cannot split an amount among nobody")
|
|
54
|
+
if any(w < 0 for w in weights):
|
|
55
|
+
raise MoneyError("weights cannot be negative")
|
|
56
|
+
weight_sum = sum(weights)
|
|
57
|
+
if weight_sum == 0:
|
|
58
|
+
raise MoneyError("weights add up to zero")
|
|
59
|
+
|
|
60
|
+
sign = -1 if total < 0 else 1
|
|
61
|
+
paise = int((abs(total) / CENT).to_integral_value())
|
|
62
|
+
exact = [Decimal(paise) * w / weight_sum for w in weights]
|
|
63
|
+
parts = [int(x) for x in exact] # floor, since every value is >= 0
|
|
64
|
+
leftover = paise - sum(parts)
|
|
65
|
+
|
|
66
|
+
by_remainder = sorted(range(len(weights)), key=lambda i: (-(exact[i] - parts[i]), i))
|
|
67
|
+
for i in by_remainder[:leftover]:
|
|
68
|
+
parts[i] += 1
|
|
69
|
+
|
|
70
|
+
return [sign * Decimal(p) * CENT for p in parts]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_money(amount: Decimal, currency: str = "INR") -> str:
|
|
74
|
+
"""Format for people: ``₹1,23,456.50``, ``$12.00``, or ``12.00 CHF``.
|
|
75
|
+
|
|
76
|
+
Rupees use Indian digit grouping (lakhs and crores).
|
|
77
|
+
"""
|
|
78
|
+
symbols = {"INR": "₹", "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥"}
|
|
79
|
+
sign = "-" if amount < 0 else ""
|
|
80
|
+
body = _indian_grouping(abs(amount)) if currency == "INR" else f"{abs(amount):,.2f}"
|
|
81
|
+
if currency in symbols:
|
|
82
|
+
return f"{sign}{symbols[currency]}{body}"
|
|
83
|
+
return f"{sign}{body} {currency}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _indian_grouping(amount: Decimal) -> str:
|
|
87
|
+
whole, fraction = f"{amount:.2f}".split(".")
|
|
88
|
+
if len(whole) > 3:
|
|
89
|
+
head, tail = whole[:-3], whole[-3:]
|
|
90
|
+
groups = []
|
|
91
|
+
while len(head) > 2:
|
|
92
|
+
groups.insert(0, head[-2:])
|
|
93
|
+
head = head[:-2]
|
|
94
|
+
groups.insert(0, head)
|
|
95
|
+
whole = ",".join(groups) + "," + tail
|
|
96
|
+
return f"{whole}.{fraction}"
|
splitkaro/report.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Summaries: where the money went, and who carried how much of it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
from splitkaro.ledger import effect
|
|
10
|
+
from splitkaro.models import Expense, Group
|
|
11
|
+
from splitkaro.money import ZERO
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Report:
|
|
16
|
+
total: Decimal = ZERO
|
|
17
|
+
count: int = 0
|
|
18
|
+
by_category: dict[str, Decimal] = field(default_factory=dict)
|
|
19
|
+
by_month: dict[str, Decimal] = field(default_factory=dict)
|
|
20
|
+
paid_by_member: dict[str, Decimal] = field(default_factory=dict)
|
|
21
|
+
share_by_member: dict[str, Decimal] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def filter_expenses(
|
|
25
|
+
group: Group,
|
|
26
|
+
month: str | None = None,
|
|
27
|
+
category: str | None = None,
|
|
28
|
+
member: str | None = None,
|
|
29
|
+
) -> list[Expense]:
|
|
30
|
+
"""Expenses matching every filter given. ``month`` is ``YYYY-MM``."""
|
|
31
|
+
name = group.resolve(member) if member else None
|
|
32
|
+
result = []
|
|
33
|
+
for e in sorted(group.expenses, key=lambda x: (x.date, x.id)):
|
|
34
|
+
if month and e.date.strftime("%Y-%m") != month:
|
|
35
|
+
continue
|
|
36
|
+
if category and e.category.casefold() != category.casefold():
|
|
37
|
+
continue
|
|
38
|
+
if name and name not in e.paid_by and name not in e.split.among:
|
|
39
|
+
continue
|
|
40
|
+
result.append(e)
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_report(group: Group, expenses: list[Expense]) -> Report:
|
|
45
|
+
by_category: dict[str, Decimal] = defaultdict(lambda: ZERO)
|
|
46
|
+
by_month: dict[str, Decimal] = defaultdict(lambda: ZERO)
|
|
47
|
+
paid: dict[str, Decimal] = {m.name: ZERO for m in group.members}
|
|
48
|
+
share: dict[str, Decimal] = {m.name: ZERO for m in group.members}
|
|
49
|
+
total = ZERO
|
|
50
|
+
|
|
51
|
+
for expense in expenses:
|
|
52
|
+
e = effect(group, expense)
|
|
53
|
+
amount = sum(e.paid.values())
|
|
54
|
+
total += amount
|
|
55
|
+
by_category[expense.category] += amount
|
|
56
|
+
by_month[expense.date.strftime("%Y-%m")] += amount
|
|
57
|
+
for name, value in e.paid.items():
|
|
58
|
+
paid[name] += value
|
|
59
|
+
for name, value in e.owed.items():
|
|
60
|
+
share[name] += value
|
|
61
|
+
|
|
62
|
+
return Report(
|
|
63
|
+
total=total,
|
|
64
|
+
count=len(expenses),
|
|
65
|
+
by_category=dict(sorted(by_category.items(), key=lambda kv: (-kv[1], kv[0]))),
|
|
66
|
+
by_month=dict(sorted(by_month.items())),
|
|
67
|
+
paid_by_member=paid,
|
|
68
|
+
share_by_member=share,
|
|
69
|
+
)
|
splitkaro/storage.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Save a group as a readable JSON file (splitkaro.json by default).
|
|
2
|
+
|
|
3
|
+
Amounts are stored as strings ("1200.50") so no precision is ever lost, and
|
|
4
|
+
the file is written atomically so a crash can never leave it half-written.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from splitkaro.models import Expense, Group, Member, Split, SplitKaroError, SplitKind, Transfer
|
|
17
|
+
from splitkaro.money import to_decimal, to_money
|
|
18
|
+
|
|
19
|
+
DEFAULT_FILE = "splitkaro.json"
|
|
20
|
+
SCHEMA_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load(path: str) -> Group:
|
|
24
|
+
try:
|
|
25
|
+
with open(path, encoding="utf-8") as handle:
|
|
26
|
+
data = json.load(handle)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
raise SplitKaroError(f"{path} not found; create a group first with: splitkaro init <name>") from None
|
|
29
|
+
except json.JSONDecodeError as exc:
|
|
30
|
+
raise SplitKaroError(f"{path} is not valid JSON: {exc}") from None
|
|
31
|
+
return from_dict(data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save(group: Group, path: str) -> None:
|
|
35
|
+
text = json.dumps(to_dict(group), indent=2, ensure_ascii=False) + "\n"
|
|
36
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
37
|
+
fd, tmp = tempfile.mkstemp(prefix=".splitkaro-", suffix=".json", dir=directory)
|
|
38
|
+
try:
|
|
39
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
40
|
+
handle.write(text)
|
|
41
|
+
os.replace(tmp, path)
|
|
42
|
+
except BaseException:
|
|
43
|
+
if os.path.exists(tmp):
|
|
44
|
+
os.unlink(tmp)
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def to_dict(group: Group) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"version": SCHEMA_VERSION,
|
|
51
|
+
"name": group.name,
|
|
52
|
+
"currency": group.currency,
|
|
53
|
+
"next_id": group.next_id,
|
|
54
|
+
"rates": {k: str(v) for k, v in group.rates.items()},
|
|
55
|
+
"members": [{"name": m.name, "upi": m.upi} for m in group.members],
|
|
56
|
+
"expenses": [_expense_to_dict(e) for e in group.expenses],
|
|
57
|
+
"transfers": [
|
|
58
|
+
{
|
|
59
|
+
"from": t.sender,
|
|
60
|
+
"to": t.receiver,
|
|
61
|
+
"amount": str(t.amount),
|
|
62
|
+
"date": t.date.isoformat() if t.date else None,
|
|
63
|
+
"note": t.note,
|
|
64
|
+
}
|
|
65
|
+
for t in group.transfers
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def from_dict(data: dict[str, Any]) -> Group:
|
|
71
|
+
version = data.get("version")
|
|
72
|
+
if version != SCHEMA_VERSION:
|
|
73
|
+
raise SplitKaroError(f"unsupported file version {version!r}; expected {SCHEMA_VERSION}")
|
|
74
|
+
try:
|
|
75
|
+
return Group(
|
|
76
|
+
name=data["name"],
|
|
77
|
+
currency=data["currency"],
|
|
78
|
+
next_id=int(data["next_id"]),
|
|
79
|
+
rates={k: to_decimal(v) for k, v in data.get("rates", {}).items()},
|
|
80
|
+
members=[Member(m["name"], m.get("upi")) for m in data["members"]],
|
|
81
|
+
expenses=[_expense_from_dict(e) for e in data["expenses"]],
|
|
82
|
+
transfers=[
|
|
83
|
+
Transfer(
|
|
84
|
+
t["from"],
|
|
85
|
+
t["to"],
|
|
86
|
+
to_money(t["amount"]),
|
|
87
|
+
dt.date.fromisoformat(t["date"]) if t.get("date") else None,
|
|
88
|
+
t.get("note", ""),
|
|
89
|
+
)
|
|
90
|
+
for t in data["transfers"]
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
94
|
+
raise SplitKaroError(f"the file is damaged or incomplete ({exc})") from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _expense_to_dict(e: Expense) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"id": e.id,
|
|
100
|
+
"date": e.date.isoformat(),
|
|
101
|
+
"description": e.description,
|
|
102
|
+
"amount": str(e.amount),
|
|
103
|
+
"currency": e.currency,
|
|
104
|
+
"category": e.category,
|
|
105
|
+
"paid_by": {k: str(v) for k, v in e.paid_by.items()},
|
|
106
|
+
"split": {
|
|
107
|
+
"kind": e.split.kind.value,
|
|
108
|
+
"among": e.split.among,
|
|
109
|
+
"values": {k: str(v) for k, v in e.split.values.items()},
|
|
110
|
+
},
|
|
111
|
+
"note": e.note,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _expense_from_dict(d: dict[str, Any]) -> Expense:
|
|
116
|
+
split = d["split"]
|
|
117
|
+
values: dict[str, Decimal] = {k: to_decimal(v) for k, v in split.get("values", {}).items()}
|
|
118
|
+
return Expense(
|
|
119
|
+
id=int(d["id"]),
|
|
120
|
+
description=d["description"],
|
|
121
|
+
amount=to_money(d["amount"]),
|
|
122
|
+
paid_by={k: to_money(v) for k, v in d["paid_by"].items()},
|
|
123
|
+
split=Split(SplitKind(split["kind"]), list(split["among"]), values),
|
|
124
|
+
date=dt.date.fromisoformat(d["date"]),
|
|
125
|
+
currency=d["currency"],
|
|
126
|
+
category=d.get("category", "general"),
|
|
127
|
+
note=d.get("note", ""),
|
|
128
|
+
)
|
splitkaro/upi.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""UPI payment links, so a suggested settlement is one tap away.
|
|
2
|
+
|
|
3
|
+
A link like ``upi://pay?pa=asha@okbank&pn=Asha&am=450.00&cu=INR&tn=Goa+trip``
|
|
4
|
+
opens any UPI app (GPay, PhonePe, Paytm, BHIM...) with the payee and amount
|
|
5
|
+
filled in. The payer still reviews and confirms the payment in their app.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
from splitkaro.models import SplitKaroError
|
|
15
|
+
|
|
16
|
+
_VPA_RE = re.compile(r"^[A-Za-z0-9.\-_]{2,256}@[A-Za-z][A-Za-z0-9]{1,64}$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_valid_vpa(vpa: str) -> bool:
|
|
20
|
+
"""Check the shape of a UPI ID (virtual payment address), e.g. ``name@bank``."""
|
|
21
|
+
return bool(_VPA_RE.match(vpa))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def payment_link(vpa: str, name: str, amount: Decimal, note: str = "") -> str:
|
|
25
|
+
if not is_valid_vpa(vpa):
|
|
26
|
+
raise SplitKaroError(f"{vpa!r} does not look like a UPI ID (expected something like name@bank)")
|
|
27
|
+
if amount <= 0:
|
|
28
|
+
raise SplitKaroError("a UPI payment must be greater than zero")
|
|
29
|
+
params = {"pa": vpa, "pn": name, "am": f"{amount:.2f}", "cu": "INR"}
|
|
30
|
+
if note:
|
|
31
|
+
# Many UPI apps cut the note at about 50 characters.
|
|
32
|
+
params["tn"] = note[:50]
|
|
33
|
+
return "upi://pay?" + urlencode(params)
|