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/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)