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 +20 -0
- owe/app.py +204 -0
- owe/config.py +24 -0
- owe/database.py +175 -0
- owe/owe.py +126 -0
- owe/record.py +158 -0
- owe/scripts/owe_dump.py +62 -0
- owe/scripts/owe_users.py +136 -0
- owe/static/index.html +106 -0
- owe/static/main.js +375 -0
- owe/telegram.py +122 -0
- owe/user.py +38 -0
- owe-0.1.0.dist-info/METADATA +208 -0
- owe-0.1.0.dist-info/RECORD +17 -0
- owe-0.1.0.dist-info/WHEEL +4 -0
- owe-0.1.0.dist-info/entry_points.txt +4 -0
- owe-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|