Initial scaffold: Flask/SQLite backend, React/Vite PWA frontend

Backend: receipt upload -> LLM vision extraction (OpenAI-compatible,
provider-agnostic), item review/edit, per-group splitting against
Cospend projects/members, highlight+upload via WebDAV, public share
link, bill creation via Cospend's OCS API (verified against real
source, not just doc summaries).

Frontend: capture -> review -> group -> summary flow as an installable
PWA.

install.sh / run.sh (venv + npm, tmux session) instead of Docker, per
the ~/Projects/gain pattern.
This commit is contained in:
2026-08-30 15:02:18 +02:00
commit d97aac0e17
42 changed files with 8274 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
"""Cospend's authenticated (NC-login) API - same app-password auth as
nc_client. This is an OCS API (same family as the Share API), confirmed
against the real routes/controller source in julien-nc/cospend-nc:
appinfo/routes.php ('ocs' section):
GET /api/{v}/projects -> api#getLocalProjects
GET /api/{v}/projects/{projectId}/members -> api#getMembers
POST /api/{v}/projects/{projectId}/bills -> api#createBill
lib/Controller/ApiController.php, createBill() signature:
string $projectId, ?string $date, ?string $what, ?int $payer,
?string $payedFor, ?float $amount, ... ?string $comment, ...
-> params are camelCase (payedFor, not payed_for), payer/payedFor are
numeric member ids (payedFor is a comma-separated string of ids).
Being an OCS controller, every call needs the OCS-APIRequest header and the
full path is under /ocs/v2.php/apps/cospend/...
"""
from __future__ import annotations
from typing import Any
import requests
from .config import Config
_HEADERS = {"OCS-APIRequest": "true"}
def _auth() -> tuple[str, str]:
return (Config.NC_USERNAME, Config.NC_APP_PASSWORD)
def _base(project_id: str | None = None) -> str:
root = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/cospend/api/v1"
if project_id is None:
return root
return f"{root}/projects/{project_id}"
def get_projects() -> list[dict[str, Any]]:
"""Lists Cospend projects visible to the authenticated user."""
resp = requests.get(
f"{_base()}/projects",
auth=_auth(),
headers=_HEADERS,
params={"format": "json"},
timeout=15,
)
resp.raise_for_status()
return resp.json()["ocs"]["data"]
def get_members(project_id: str) -> list[dict[str, Any]]:
resp = requests.get(
f"{_base(project_id)}/members",
auth=_auth(),
headers=_HEADERS,
params={"format": "json"},
timeout=15,
)
resp.raise_for_status()
return resp.json()["ocs"]["data"]
def create_bill(
*,
project_id: str,
what: str,
amount: float,
payer_id: int,
ower_ids: list[int],
date: str,
comment: str = "",
) -> int:
"""Creates a bill split evenly among `ower_ids`, paid by `payer_id`.
`amount` is the bill total; Cospend divides it across owers itself.
Returns the new bill's id - createBill's controller method returns just
the int id (`return new DataResponse($newBillId)`), not a bill object.
"""
resp = requests.post(
f"{_base(project_id)}/bills",
auth=_auth(),
headers=_HEADERS,
params={"format": "json"},
json={
"what": what,
"amount": amount,
"payer": payer_id,
"payedFor": ",".join(str(i) for i in ower_ids),
"comment": comment,
"date": date, # YYYY-MM-DD, the receipt's issue date, not today
},
timeout=15,
)
resp.raise_for_status()
return resp.json()["ocs"]["data"]