Add Splitwise clone backend

This commit is contained in:
2026-05-02 06:53:53 +02:00
parent 424d187080
commit da50a9aca8

164
app/resources/expenses.py Normal file
View File

@@ -0,0 +1,164 @@
from __future__ import annotations
from starlette.requests import Request
from starlette.responses import JSONResponse
from tortoise.transactions import atomic
from app.models import ExpenseItem, ExpenseParticipant, ExpenseReport, ExpenseShare, User
from app.schemas import (
ExpenseItemCreate,
ExpenseItemOut,
ExpenseReportCreate,
ExpenseReportListOut,
ExpenseReportOut,
ExpenseShareOut,
)
def _build_report_out(report: ExpenseReport) -> dict:
items_out: list[dict] = []
for item in report.items:
shares_out: list[dict] = []
for share in item.shares:
shares_out.append(
ExpenseShareOut(
user_sub=share.user.sub,
user_name=share.user.name,
percentage=share.percentage,
).model_dump()
)
items_out.append(
ExpenseItemOut(
id=item.id,
description=item.description,
amount=item.amount,
shares=shares_out,
).model_dump()
)
participant_subs: set[str] = set()
for item in report.items:
for share in item.shares:
participant_subs.add(share.user.sub)
participants_out = [
{"sub": sub, "name": None} for sub in sorted(participant_subs)
]
return ExpenseReportOut(
id=report.id,
title=report.title,
creator_sub=report.creator.sub,
creator_name=report.creator.name,
created_at=report.created_at.isoformat(),
items=items_out,
participants=participants_out,
).model_dump()
async def create_expense_report(request: Request) -> JSONResponse:
user: User = request.state.user
try:
body = await request.json()
except Exception:
return JSONResponse({"detail": "Invalid JSON"}, status_code=400)
try:
payload = ExpenseReportCreate.model_validate(body)
except Exception as e:
return JSONResponse({"detail": str(e)}, status_code=400)
all_subs: set[str] = set()
for item in payload.items:
all_subs.update(item.participants)
all_subs.add(user.sub)
existing_users = await User.filter(sub__in=list(all_subs)).all()
sub_to_user: dict[str, User] = {u.sub: u for u in existing_users}
missing = all_subs - set(sub_to_user.keys())
if missing:
return JSONResponse(
{"detail": f"Unknown user(s): {', '.join(sorted(missing))}"},
status_code=400,
)
@atomic()
async def _create() -> dict:
report = await ExpenseReport.create(title=payload.title, creator=user)
for sub in all_subs:
await ExpenseParticipant.create(
report=report, user=sub_to_user[sub]
)
for item_data in payload.items:
item = await ExpenseItem.create(
report=report,
description=item_data.description,
amount=item_data.amount,
)
if item_data.shares is not None:
for sub, pct in item_data.shares.items():
await ExpenseShare.create(
item=item,
user=sub_to_user[sub],
percentage=pct,
)
else:
equal_pct = 100.0 / len(item_data.participants)
for sub in item_data.participants:
await ExpenseShare.create(
item=item,
user=sub_to_user[sub],
percentage=equal_pct,
)
report = await (
ExpenseReport.filter(id=report.id)
.prefetch_related(
"items__shares__user",
"creator",
)
.first()
)
assert report is not None
return _build_report_out(report)
try:
result = await _create()
except Exception as e:
return JSONResponse({"detail": str(e)}, status_code=500)
return JSONResponse(result, status_code=201)
async def list_expense_reports(request: Request) -> JSONResponse:
user: User = request.state.user
participation_ids = await ExpenseParticipant.filter(user=user).values_list(
"report_id", flat=True
)
if not participation_ids:
return JSONResponse(
ExpenseReportListOut(reports=[], total_count=0).model_dump()
)
reports = await (
ExpenseReport.filter(id__in=list(participation_ids))
.order_by("-created_at")
.prefetch_related(
"items__shares__user",
"creator",
)
)
reports_out = [_build_report_out(r) for r in reports]
return JSONResponse(
ExpenseReportListOut(
reports=reports_out,
total_count=len(reports_out),
).model_dump()
)