owe 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.
owe/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ from flask import Flask
2
+ from flask_cors import CORS
3
+
4
+ from . import app as owe_app
5
+ from .config import load_env_config
6
+
7
+
8
+ def create_app(
9
+ *,
10
+ url_prefix: str | None = None,
11
+ api_only: bool = False,
12
+ ) -> Flask:
13
+ """Create and configure the Flask application instance."""
14
+ app = Flask(__name__, static_folder=None)
15
+ app.config.update(load_env_config())
16
+ owe_app.init(app)
17
+ bp = owe_app.api if api_only else owe_app.app
18
+ app.register_blueprint(bp, url_prefix=url_prefix)
19
+ CORS(app)
20
+ return app
owe/app.py ADDED
@@ -0,0 +1,204 @@
1
+ import logging
2
+ import sqlite3
3
+ from html import escape
4
+ from pathlib import Path
5
+ from typing import Any, cast
6
+
7
+ from flask import Blueprint, Flask, Response, current_app, request
8
+
9
+ from . import database, owe
10
+ from .config import AppConfigItems
11
+ from .record import AggregatedRecord
12
+
13
+ API_PREFIX = "/api"
14
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def app_config() -> AppConfigItems:
20
+ """Return typed app config values for the current request context."""
21
+ return cast("AppConfigItems", current_app.config)
22
+
23
+
24
+ def init(app: Flask) -> None:
25
+ """Initialize logging and database schema for the app."""
26
+ config = cast("AppConfigItems", app.config)
27
+ logging.basicConfig(
28
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
29
+ level=getattr(logging, config["LOG_LEVEL"], logging.INFO),
30
+ )
31
+ database.init(config["DATABASE"])
32
+
33
+
34
+ def get_requester() -> str:
35
+ """Return requester identity from configured header or remote address."""
36
+ request_email_header = app_config()["REQUEST_EMAIL_HEADER"]
37
+ if request_email_header:
38
+ email = request.headers.get(request_email_header)
39
+ if email:
40
+ return email
41
+ return request.remote_addr or "unknown"
42
+
43
+
44
+ app = Blueprint(
45
+ "owe",
46
+ __name__,
47
+ url_prefix="",
48
+ static_folder=STATIC_DIR,
49
+ static_url_path="",
50
+ )
51
+
52
+
53
+ @app.after_request
54
+ def add_csp(response: Response) -> Response:
55
+ """Attach a strict content security policy to outgoing responses."""
56
+ response.headers["Content-Security-Policy"] = (
57
+ "default-src 'self'; "
58
+ "script-src 'self' https://cdnjs.cloudflare.com; "
59
+ "style-src 'self' https://cdnjs.cloudflare.com; "
60
+ "img-src 'self' data:; "
61
+ )
62
+ return response
63
+
64
+
65
+ @app.route("/")
66
+ def index() -> Response:
67
+ """Serve the single-page application entry point."""
68
+ return app.send_static_file("index.html")
69
+
70
+
71
+ api = Blueprint("api", __name__, url_prefix=API_PREFIX)
72
+ app.register_blueprint(api)
73
+
74
+
75
+ @api.route("/config")
76
+ def get_config() -> dict[str, Any]:
77
+ """Return client-facing configuration values."""
78
+ return {"currency": app_config()["CURRENCY"]}
79
+
80
+
81
+ @api.route("/users")
82
+ def get_users() -> list[dict[str, Any]]:
83
+ """Return active users for UI selection."""
84
+ users = owe.get_active_users()
85
+ return [user.asdict() for user in users]
86
+
87
+
88
+ @api.route("/records")
89
+ def get_records() -> dict[str, dict[str, Any]]:
90
+ """Return all records keyed by record ID as strings."""
91
+ records = owe.get_records()
92
+ return {str(record.id): record.asdict() for record in records}
93
+
94
+
95
+ def validate_add_records_request( # noqa: PLR0911
96
+ req: dict[str, Any],
97
+ valid_emails: set[str],
98
+ ) -> tuple[bool, str]:
99
+ """Validate the add-record request body and referenced user emails."""
100
+ for key in ["type", "lender", "borrowers", "amount", "remarks"]:
101
+ if key not in req:
102
+ return False, f"Missing field: {key}"
103
+
104
+ req_type = req["type"]
105
+ if not isinstance(req_type, str) or req_type not in {"DEBT", "PAYMENT"}:
106
+ return False, 'type must be either "DEBT" or "PAYMENT"'
107
+
108
+ lender = req["lender"]
109
+ if not isinstance(lender, str):
110
+ return False, "lender must be a string"
111
+
112
+ borrowers = req["borrowers"]
113
+ if (
114
+ not isinstance(borrowers, list)
115
+ or not borrowers
116
+ or not all(isinstance(borrower, str) for borrower in borrowers)
117
+ ):
118
+ return False, "borrowers must be a non-empty list of strings"
119
+
120
+ amount = req["amount"]
121
+ if not isinstance(amount, (int, float)) or amount <= 0:
122
+ return False, "amount must be a positive number"
123
+
124
+ remarks = req["remarks"]
125
+ if not isinstance(remarks, str) and remarks is not None:
126
+ return False, "remarks must be a string or null"
127
+
128
+ if lender not in valid_emails:
129
+ return False, f"Unknown lender: {escape(lender)}"
130
+
131
+ if unknown_borrowers := set(borrowers) - valid_emails:
132
+ borrowers_str = escape(
133
+ # https://github.com/astral-sh/ty/issues/521
134
+ ", ".join(email for email in unknown_borrowers), # ty:ignore[no-matching-overload]
135
+ )
136
+ return False, f"Unknown borrower(s): {borrowers_str}"
137
+
138
+ return True, ""
139
+
140
+
141
+ @api.route("/records", methods=["POST"])
142
+ def add_records() -> tuple[dict[str, Any], int]:
143
+ """Create an aggregated record and persist its split entries."""
144
+ req = request.get_json()
145
+ if not req:
146
+ return {"success": False, "error": "Request body must be JSON"}, 400
147
+
148
+ users = owe.get_active_users()
149
+ valid_emails = {u.email for u in users}
150
+ valid, error = validate_add_records_request(req, valid_emails)
151
+ if not valid:
152
+ return {"success": False, "error": error}, 400
153
+
154
+ record = AggregatedRecord(
155
+ type=req["type"],
156
+ lender=req["lender"],
157
+ borrowers=req["borrowers"],
158
+ amount=req["amount"],
159
+ created_by=get_requester(),
160
+ remarks=req["remarks"],
161
+ )
162
+ try:
163
+ owe.add_records(record)
164
+ except sqlite3.Error:
165
+ logger.exception("Database error in add_records")
166
+ return {"success": False, "error": "Database error"}, 500
167
+
168
+ return {"success": True}, 200
169
+
170
+
171
+ @api.route("/records/status", methods=["PATCH"])
172
+ def set_records_active() -> tuple[dict[str, Any], int]:
173
+ """Update the active flag for a batch of records."""
174
+ req = request.get_json()
175
+ if not req:
176
+ return {"success": False, "error": "Request body must be JSON"}, 400
177
+
178
+ ids = req.get("ids", [])
179
+ active = req.get("active")
180
+ if not ids or not all(isinstance(i, int) for i in ids):
181
+ return {
182
+ "success": False,
183
+ "error": "ids must be a non-empty list of integers",
184
+ }, 400
185
+ if not isinstance(active, bool):
186
+ return {"success": False, "error": "active must be a boolean"}, 400
187
+
188
+ try:
189
+ owe.set_records_active(
190
+ ids,
191
+ active=active,
192
+ requester=get_requester(),
193
+ )
194
+ except sqlite3.Error:
195
+ logger.exception("Database error in set_records_active")
196
+ return {"success": False, "error": "Database error"}, 500
197
+
198
+ return {"success": True}, 200
199
+
200
+
201
+ @api.route("/summary")
202
+ def summary() -> list[owe.SummaryTransaction]:
203
+ """Return settlement transactions computed from net balances."""
204
+ return owe.get_summary()
owe/config.py ADDED
@@ -0,0 +1,24 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import TypedDict
4
+
5
+
6
+ class AppConfigItems(TypedDict):
7
+ LOG_LEVEL: str
8
+ DATABASE: Path
9
+ CURRENCY: str
10
+ REQUEST_EMAIL_HEADER: str | None
11
+ TELEGRAM_BOT_TOKEN: str | None
12
+ TELEGRAM_CHAT_ID: str | None
13
+
14
+
15
+ def load_env_config() -> AppConfigItems:
16
+ """Load application config values from environment variables."""
17
+ return {
18
+ "LOG_LEVEL": os.getenv("OWE_LOG_LEVEL", "INFO").upper(),
19
+ "DATABASE": Path(os.getenv("OWE_DATABASE", "owe.db")),
20
+ "CURRENCY": os.getenv("OWE_CURRENCY", "USD"),
21
+ "REQUEST_EMAIL_HEADER": os.getenv("OWE_REQUEST_EMAIL_HEADER"),
22
+ "TELEGRAM_BOT_TOKEN": os.getenv("OWE_TELEGRAM_BOT_TOKEN"),
23
+ "TELEGRAM_CHAT_ID": os.getenv("OWE_TELEGRAM_CHAT_ID"),
24
+ }
owe/database.py ADDED
@@ -0,0 +1,175 @@
1
+ import sqlite3
2
+ from pathlib import Path
3
+ from textwrap import dedent
4
+ from typing import Any
5
+
6
+ from .record import Record
7
+ from .user import User
8
+
9
+
10
+ def dict_factory(
11
+ cursor: sqlite3.Cursor,
12
+ row: tuple[Any, ...],
13
+ ) -> dict[str, Any]:
14
+ """Convert a SQLite row tuple into a dict keyed by column name."""
15
+ return dict(
16
+ zip(
17
+ [column[0] for column in cursor.description],
18
+ row,
19
+ strict=True,
20
+ )
21
+ )
22
+
23
+
24
+ def init(database: Path) -> None:
25
+ """Create database tables and views if they do not already exist."""
26
+ with sqlite3.connect(database) as con:
27
+ con.cursor().execute(
28
+ dedent("""
29
+ CREATE TABLE IF NOT EXISTS Users(
30
+ email TEXT PRIMARY KEY,
31
+ name TEXT UNIQUE NOT NULL,
32
+ active BOOLEAN DEFAULT TRUE
33
+ );
34
+ """)
35
+ ).execute(
36
+ dedent("""
37
+ CREATE TABLE IF NOT EXISTS Records(
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ type TEXT NOT NULL,
40
+ lender TEXT NOT NULL,
41
+ borrower TEXT NOT NULL,
42
+ amount INTEGER NOT NULL,
43
+ created_by TEXT NOT NULL,
44
+ created_at INTEGER NOT NULL,
45
+ remarks TEXT,
46
+ active BOOLEAN DEFAULT TRUE,
47
+ CHECK(type IN ('DEBT', 'PAYMENT')),
48
+ FOREIGN KEY(lender) REFERENCES Users(email),
49
+ FOREIGN KEY(borrower) REFERENCES Users(email),
50
+ CHECK(lender != borrower),
51
+ CHECK(amount > 0)
52
+ );
53
+ """)
54
+ ).execute(
55
+ dedent("""
56
+ CREATE VIEW IF NOT EXISTS UserBalances AS
57
+ WITH Deltas AS (
58
+ SELECT
59
+ lender AS user,
60
+ amount AS delta
61
+ FROM Records
62
+ WHERE active = TRUE
63
+
64
+ UNION ALL
65
+
66
+ SELECT
67
+ borrower AS user,
68
+ -amount AS delta
69
+ FROM Records
70
+ WHERE active = TRUE
71
+ )
72
+ SELECT
73
+ user,
74
+ SUM(delta) AS balance
75
+ FROM Deltas
76
+ GROUP BY user;
77
+ """)
78
+ )
79
+
80
+
81
+ def get_users(database: Path, *, active_only: bool = False) -> list[User]:
82
+ """Fetch users from the database ordered by display name."""
83
+ with sqlite3.connect(database) as con:
84
+ con.row_factory = dict_factory
85
+ cur = con.cursor()
86
+ query = "SELECT * FROM Users"
87
+ if active_only:
88
+ query += " WHERE active = TRUE"
89
+ query += " ORDER BY name;"
90
+ rows = cur.execute(query).fetchall()
91
+ return [User.from_db_row(row) for row in rows]
92
+
93
+
94
+ def add_user(database: Path, user: User) -> None:
95
+ """Insert a user row into the database."""
96
+ with sqlite3.connect(database) as con:
97
+ cur = con.cursor()
98
+ cur.execute(
99
+ "INSERT INTO Users(email, name, active) VALUES(?, ?, ?);",
100
+ user.to_insert_values(),
101
+ )
102
+
103
+
104
+ def set_user_active(database: Path, email: str, *, active: bool) -> int:
105
+ """Set a user's active flag and return the number of updated rows."""
106
+ with sqlite3.connect(database) as con:
107
+ cur = con.cursor()
108
+ cur.execute(
109
+ "UPDATE Users SET active = ? WHERE email = ?;",
110
+ (active, email),
111
+ )
112
+ return cur.rowcount
113
+
114
+
115
+ def get_records(database: Path, *, active_only: bool = False) -> list[Record]:
116
+ """Fetch records from the database ordered by ID."""
117
+ with sqlite3.connect(database) as con:
118
+ con.row_factory = dict_factory
119
+ cur = con.cursor()
120
+ query = "SELECT * FROM Records"
121
+ if active_only:
122
+ query += " WHERE active = TRUE"
123
+ query += " ORDER BY id;"
124
+ rows = cur.execute(query).fetchall()
125
+ return [Record.from_db_row(row) for row in rows]
126
+
127
+
128
+ def add_records(database: Path, records: list[Record]) -> None:
129
+ """Insert records and populate generated IDs on each record object."""
130
+ with sqlite3.connect(database) as con:
131
+ cur = con.cursor()
132
+ cur.execute("PRAGMA foreign_keys = ON;")
133
+ for record in records:
134
+ cur.execute(
135
+ dedent("""
136
+ INSERT INTO Records(
137
+ type,
138
+ lender,
139
+ borrower,
140
+ amount,
141
+ created_by,
142
+ created_at,
143
+ remarks,
144
+ active
145
+ )
146
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?);
147
+ """),
148
+ record.to_insert_values(),
149
+ )
150
+ record.id = cur.lastrowid
151
+
152
+
153
+ def set_records_active(
154
+ database: Path,
155
+ ids: list[int],
156
+ *,
157
+ active: bool,
158
+ ) -> int:
159
+ """Set the active flag for record IDs and return affected row count."""
160
+ with sqlite3.connect(database) as con:
161
+ cur = con.cursor()
162
+ cur.executemany(
163
+ "UPDATE Records SET active = ? WHERE id = ?;",
164
+ [(active, record_id) for record_id in ids],
165
+ )
166
+ return cur.rowcount
167
+
168
+
169
+ def get_net_balances(database: Path) -> dict[str, int]:
170
+ """Return per-user net balances computed from active records."""
171
+ with sqlite3.connect(database) as con:
172
+ cur = con.cursor()
173
+ cur.row_factory = dict_factory
174
+ rows = cur.execute("SELECT * FROM UserBalances;").fetchall()
175
+ return {row["user"]: row["balance"] for row in rows}
owe/owe.py ADDED
@@ -0,0 +1,126 @@
1
+ import logging
2
+ import threading
3
+ from typing import TypedDict, cast
4
+
5
+ from flask import current_app
6
+
7
+ from . import database
8
+ from .config import AppConfigItems
9
+ from .record import AggregatedRecord, Record
10
+ from .telegram import announce_record_status_change, announce_records
11
+ from .user import User
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ SummaryTransaction = TypedDict(
16
+ "SummaryTransaction",
17
+ {"from": str, "to": str, "amount": int},
18
+ )
19
+
20
+
21
+ def app_config() -> AppConfigItems:
22
+ """Return typed app config values for service-layer helpers."""
23
+ return cast("AppConfigItems", current_app.config)
24
+
25
+
26
+ def get_active_users() -> list[User]:
27
+ """Return all active users."""
28
+ return database.get_users(app_config()["DATABASE"], active_only=True)
29
+
30
+
31
+ def get_records() -> list[Record]:
32
+ """Return all records."""
33
+ return database.get_records(app_config()["DATABASE"])
34
+
35
+
36
+ def add_records(record: AggregatedRecord) -> None:
37
+ """Create new records and trigger notifications."""
38
+ config = app_config()
39
+ records = record.to_records()
40
+ database.add_records(config["DATABASE"], records)
41
+ logger.info(
42
+ "Added %d record(s) of type %s by %s",
43
+ len(records),
44
+ record.type,
45
+ record.created_by,
46
+ )
47
+
48
+ if config["TELEGRAM_BOT_TOKEN"] and config["TELEGRAM_CHAT_ID"]:
49
+ users = get_active_users()
50
+
51
+ threading.Thread(
52
+ target=announce_records,
53
+ args=(
54
+ records,
55
+ config["CURRENCY"],
56
+ users,
57
+ config["TELEGRAM_BOT_TOKEN"],
58
+ config["TELEGRAM_CHAT_ID"],
59
+ ),
60
+ daemon=False,
61
+ ).start()
62
+
63
+
64
+ def set_records_active(
65
+ ids: list[int],
66
+ *,
67
+ active: bool,
68
+ requester: str,
69
+ ) -> None:
70
+ """Activate or cancel records and trigger notifications."""
71
+ config = app_config()
72
+ database.set_records_active(config["DATABASE"], ids, active=active)
73
+ action = "activated" if active else "canceled"
74
+ logger.info("%d records %s by %s", len(ids), action, requester)
75
+
76
+ if config["TELEGRAM_BOT_TOKEN"] and config["TELEGRAM_CHAT_ID"]:
77
+ records = [record for record in get_records() if record.id in set(ids)]
78
+ users = database.get_users(config["DATABASE"])
79
+ threading.Thread(
80
+ target=announce_record_status_change,
81
+ args=(
82
+ records,
83
+ config["CURRENCY"],
84
+ users,
85
+ requester,
86
+ config["TELEGRAM_BOT_TOKEN"],
87
+ config["TELEGRAM_CHAT_ID"],
88
+ ),
89
+ kwargs={"active": active},
90
+ daemon=False,
91
+ ).start()
92
+
93
+
94
+ def get_summary() -> list[SummaryTransaction]:
95
+ """Return a minimized transfer plan from current balances."""
96
+ net_balances = database.get_net_balances(app_config()["DATABASE"])
97
+ creditors = [
98
+ (user, balance) for user, balance in net_balances.items() if balance > 0
99
+ ]
100
+ debtors = [
101
+ (user, -balance) for user, balance in net_balances.items() if balance < 0
102
+ ]
103
+ creditors.sort(key=lambda x: x[1], reverse=True)
104
+ debtors.sort(key=lambda x: x[1], reverse=True)
105
+
106
+ transactions: list[SummaryTransaction] = []
107
+ creditor_idx = 0
108
+ debtor_idx = 0
109
+ while creditor_idx < len(creditors) and debtor_idx < len(debtors):
110
+ creditor_user, credit_amount = creditors[creditor_idx]
111
+ debtor_user, debt_amount = debtors[debtor_idx]
112
+
113
+ transfer_amount = min(credit_amount, debt_amount)
114
+ if transfer_amount > 0:
115
+ transactions.append(
116
+ {"from": debtor_user, "to": creditor_user, "amount": transfer_amount}
117
+ )
118
+ creditors[creditor_idx] = (creditor_user, credit_amount - transfer_amount)
119
+ debtors[debtor_idx] = (debtor_user, debt_amount - transfer_amount)
120
+
121
+ if creditors[creditor_idx][1] == 0:
122
+ creditor_idx += 1
123
+ if debtors[debtor_idx][1] == 0:
124
+ debtor_idx += 1
125
+
126
+ return transactions
owe/record.py ADDED
@@ -0,0 +1,158 @@
1
+ import datetime as dt
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class Record:
8
+ """A record representing a single transaction between two users."""
9
+
10
+ type: str
11
+ lender: str
12
+ borrower: str
13
+ amount: int
14
+ created_by: str
15
+ created_at: dt.datetime
16
+ remarks: str | None = None
17
+ active: bool = True
18
+ id: int | None = None
19
+
20
+ def to_insert_values(
21
+ self,
22
+ ) -> tuple[str, str, str, int, str, int, str | None, bool]:
23
+ """Return values matching the records insert statement order."""
24
+ return (
25
+ self.type,
26
+ self.lender,
27
+ self.borrower,
28
+ self.amount,
29
+ self.created_by,
30
+ int(self.created_at.timestamp() * 1000),
31
+ self.remarks,
32
+ self.active,
33
+ )
34
+
35
+ @staticmethod
36
+ def from_db_row(row: dict[str, Any]) -> "Record":
37
+ """Create a ``Record`` from a database row mapping."""
38
+ created_at = dt.datetime.fromtimestamp(
39
+ row["created_at"] / 1000, tz=dt.timezone.utc
40
+ )
41
+ return Record(
42
+ id=row["id"],
43
+ type=row["type"],
44
+ lender=row["lender"],
45
+ borrower=row["borrower"],
46
+ amount=row["amount"],
47
+ created_by=row["created_by"],
48
+ created_at=created_at,
49
+ remarks=row["remarks"],
50
+ active=bool(row["active"]),
51
+ )
52
+
53
+ @staticmethod
54
+ def csv_header() -> tuple[str, str, str, str, str, str, str, str, str]:
55
+ """Return the CSV header used for record export."""
56
+ return (
57
+ "id",
58
+ "type",
59
+ "lender",
60
+ "borrower",
61
+ "amount",
62
+ "created_by",
63
+ "created_at",
64
+ "remarks",
65
+ "active",
66
+ )
67
+
68
+ @staticmethod
69
+ def from_csv_row(row: list[str]) -> "Record":
70
+ """Create a ``Record`` from a CSV row."""
71
+ (
72
+ record_id,
73
+ record_type,
74
+ lender,
75
+ borrower,
76
+ amount,
77
+ created_by,
78
+ created_at,
79
+ remarks,
80
+ active,
81
+ ) = row
82
+ return Record(
83
+ id=int(record_id),
84
+ type=record_type,
85
+ lender=lender,
86
+ borrower=borrower,
87
+ amount=int(float(amount) * 100),
88
+ created_by=created_by,
89
+ created_at=dt.datetime.fromisoformat(created_at),
90
+ remarks=remarks,
91
+ active=bool(int(active)),
92
+ )
93
+
94
+ def to_csv_row(self) -> tuple[int, str, str, str, str, str, str, str, int]:
95
+ """Return the record encoded as a CSV row."""
96
+ return (
97
+ self.id or 0,
98
+ self.type,
99
+ self.lender,
100
+ self.borrower,
101
+ f"{self.amount / 100:.2f}",
102
+ self.created_by,
103
+ self.created_at.isoformat(timespec="milliseconds"),
104
+ self.remarks or "",
105
+ int(self.active),
106
+ )
107
+
108
+ def asdict(self) -> dict[str, Any]:
109
+ """Return a JSON-serializable representation of the record."""
110
+ return {
111
+ "id": self.id,
112
+ "type": self.type,
113
+ "lender": self.lender,
114
+ "borrower": self.borrower,
115
+ "amount": self.amount,
116
+ "created_by": self.created_by,
117
+ "created_at": int(self.created_at.timestamp() * 1000),
118
+ "remarks": self.remarks,
119
+ "active": self.active,
120
+ }
121
+
122
+
123
+ def ceildiv(a: float, b: int) -> int:
124
+ """Divide ``a`` by ``b`` and round up."""
125
+ return int(-(a // -b))
126
+
127
+
128
+ @dataclass(slots=True)
129
+ class AggregatedRecord:
130
+ """
131
+ An aggregated record representing a single transaction that involves possibly
132
+ multiple users.
133
+ """
134
+
135
+ type: str
136
+ lender: str
137
+ borrowers: list[str]
138
+ amount: int
139
+ created_by: str
140
+ remarks: str | None
141
+
142
+ def to_records(self) -> list[Record]:
143
+ """Split an aggregated record into per-borrower ``Record`` entries."""
144
+ each_amount = ceildiv(self.amount, len(self.borrowers))
145
+ created_at = dt.datetime.now(tz=dt.timezone.utc)
146
+ return [
147
+ Record(
148
+ type=self.type,
149
+ lender=self.lender,
150
+ borrower=borrower,
151
+ amount=each_amount,
152
+ created_by=self.created_by,
153
+ created_at=created_at,
154
+ remarks=self.remarks,
155
+ )
156
+ for borrower in self.borrowers
157
+ if borrower != self.lender
158
+ ]