Per-user auth via Nextcloud Login Flow v2, Tailwind UI rewrite

- auth.py/auth_routes.py: Login Flow v2 (verified against real NC
  source/docs) - no OAuth2 client registration needed, backend polls
  server-side so no CORS issues. Sessions are hashed-token cookies;
  NC app passwords encrypted at rest (Fernet). Every /api/* route
  guarded by a blueprint-wide before_request, not per-route decorators,
  so future routes are protected by default.
- receipts/groups scoped per owner_nc_user_id; cross-user access 404s.
- nc_client/cospend_client take (username, app_password) per call
  instead of one shared global credential - each user's uploads/shares/
  bills now happen as themselves.
- Frontend: LoginGate component drives the login flow (open NC login
  in a new tab, poll our backend, done).
- Merged the old separate review step into the split screen, redesigned
  with Tailwind (was unstyled/broken), default-excluded-per-item
  splitting with one-click "include everyone" fixed, DD.MM.YYYY date
  field, receipt-icon branding.
- Dropped LLM bounding-box highlighting - unreliable on real receipts,
  plain photo upload instead.
- Only mention who a bill is split with in its title when there's more
  than one bill off the same receipt to disambiguate.
This commit is contained in:
2026-08-30 16:02:29 +02:00
parent d97aac0e17
commit 1b38396a2c
31 changed files with 1653 additions and 746 deletions
+18 -5
View File
@@ -1,12 +1,13 @@
# Copy to .env and fill in. Never commit .env.
# --- Nextcloud ---
# Server address only - each user logs in themselves via Nextcloud Login
# Flow v2 (see /auth/login/start); there's no shared NC_USERNAME/APP_PASSWORD
# any more.
NC_BASE_URL=https://cloud.dominik-roth.eu
NC_USERNAME=dodox
# Settings -> Security -> "Devices & Sessions" -> create app password
NC_APP_PASSWORD=
# Folder (relative to the user's files root) receipt images get uploaded to.
# Created automatically on first upload if missing (each path segment).
# Folder (relative to each user's own files root) receipt images get
# uploaded to. Created automatically on first upload if missing (each path
# segment).
NC_UPLOAD_FOLDER=Documents/Cospend/Assets
# --- Cospend ---
@@ -29,3 +30,15 @@ LLM_MODEL=
# --- Flask ---
FLASK_SECRET_KEY=dev-change-me
DATABASE_PATH=instance/wgbill.sqlite3
# --- Auth ---
# Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Encrypts stored Nextcloud app passwords at rest. Required - the app
# refuses to store/read a token without it. Keep this secret; losing it
# means every logged-in user has to log in again.
TOKEN_ENCRYPTION_KEY=
# Idle session lifetime in days (sliding - refreshed on each use).
SESSION_TTL_DAYS=30
# Cookies need Secure (HTTPS-only) for any real deployment. Only set to
# false for plain-http localhost dev.
SESSION_COOKIE_SECURE=true
+11 -2
View File
@@ -15,13 +15,22 @@ def create_app() -> Flask:
os.makedirs(Config.UPLOAD_DIR, exist_ok=True)
# Frontend runs on a different origin (vite dev server) during
# development; lock this down to that origin in production.
CORS(app, resources={r"/api/*": {"origins": Config.CORS_ORIGIN}})
# development; lock this down to that origin in production. Auth now
# relies on a session cookie, so credentials must be allowed - and with
# supports_credentials, the origin allowlist can't be "*", it has to be
# this one explicit origin (flask-cors enforces that).
CORS(
app,
resources={r"/api/*": {"origins": Config.CORS_ORIGIN}, r"/auth/*": {"origins": Config.CORS_ORIGIN}},
supports_credentials=True,
)
init_db(Config.DATABASE_PATH)
from .auth_routes import bp as auth_bp
from .routes import bp as api_bp
app.register_blueprint(api_bp, url_prefix="/api")
app.register_blueprint(auth_bp, url_prefix="/auth")
return app
+213
View File
@@ -0,0 +1,213 @@
"""Login via Nextcloud's Login Flow v2 (the same mechanism NC's own desktop/
mobile clients use to obtain an app password without ever seeing a
third-party app's code touch the user's real password).
Flow, doc-verified against docs.nextcloud.com/server/stable/developer_manual
/client_apis/LoginFlow/index.html:
1. POST {NC_BASE_URL}/index.php/login/v2 (no auth) ->
{poll: {token, endpoint}, login: <url the user opens>}
2. User opens `login`, authenticates directly with Nextcloud, approves.
3. POST {poll.endpoint} with body token=<poll.token> -> 404 while pending,
200 once (single-use) with {server, loginName, appPassword} once
granted. Token valid 20 minutes.
Because our backend does the polling (not browser JS), none of the CORS
issues that rule out a pure-frontend NC login apply here - the browser only
ever does a full-page navigation to NC's own login page and back.
"""
from __future__ import annotations
import hashlib
import secrets
import time
from datetime import datetime, timedelta
from functools import wraps
import requests
from cryptography.fernet import Fernet, InvalidToken
from flask import g, jsonify, request
from .config import Config
from .db import get_conn
# In-memory store for login flows in progress: flow_id -> {poll_token,
# poll_endpoint, created_at}. Short-lived (20 min NC-side) and low-stakes if
# lost on a backend restart (the user just retries the login button) - not
# worth a DB table.
_pending_flows: dict[str, dict] = {}
_FLOW_TTL_SECONDS = 20 * 60
def _fernet() -> Fernet:
if not Config.TOKEN_ENCRYPTION_KEY:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set (see .env.example) - refusing to "
"store or read an NC app password without it"
)
return Fernet(Config.TOKEN_ENCRYPTION_KEY.encode())
def _hash_session_id(session_id: str) -> str:
return hashlib.sha256(session_id.encode()).hexdigest()
# ------------------------------------------------------------ login flow
def start_login_flow() -> dict:
"""Kicks off a Login Flow v2 against NC. Returns {flow_id, login_url}."""
resp = requests.post(f"{Config.NC_BASE_URL}/index.php/login/v2", timeout=15)
resp.raise_for_status()
data = resp.json()
flow_id = secrets.token_urlsafe(24)
_pending_flows[flow_id] = {
"poll_token": data["poll"]["token"],
"poll_endpoint": data["poll"]["endpoint"],
"created_at": time.time(),
}
_gc_expired_flows()
return {"flow_id": flow_id, "login_url": data["login"]}
def poll_login_flow(flow_id: str) -> dict:
"""Returns {"status": "pending"} | {"status": "expired"} |
{"status": "done", "nc_user_id": str, "session_id": str}.
On success, provisions/updates the user row and creates a new session -
the caller still has to actually set the cookie on the response.
"""
flow = _pending_flows.get(flow_id)
if flow is None:
return {"status": "expired"}
if time.time() - flow["created_at"] > _FLOW_TTL_SECONDS:
del _pending_flows[flow_id]
return {"status": "expired"}
resp = requests.post(
flow["poll_endpoint"], data={"token": flow["poll_token"]}, timeout=15
)
if resp.status_code == 404:
return {"status": "pending"}
resp.raise_for_status()
# Single-use - NC only returns this once, so this flow is done either way.
del _pending_flows[flow_id]
granted = resp.json()
nc_user_id = granted["loginName"]
app_password = granted["appPassword"]
encrypted = _fernet().encrypt(app_password.encode())
with get_conn() as conn:
conn.execute(
"""INSERT INTO users (nc_user_id, nc_app_password_encrypted)
VALUES (?, ?)
ON CONFLICT(nc_user_id) DO UPDATE SET
nc_app_password_encrypted = excluded.nc_app_password_encrypted,
last_login_at = datetime('now')""",
(nc_user_id, encrypted),
)
session_id = create_session(nc_user_id)
return {"status": "done", "nc_user_id": nc_user_id, "session_id": session_id}
def _gc_expired_flows() -> None:
now = time.time()
expired = [fid for fid, f in _pending_flows.items() if now - f["created_at"] > _FLOW_TTL_SECONDS]
for fid in expired:
del _pending_flows[fid]
# --------------------------------------------------------------- sessions
def create_session(nc_user_id: str) -> str:
"""Returns the raw session id to set as the cookie value."""
session_id = secrets.token_urlsafe(32)
expires_at = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat()
with get_conn() as conn:
conn.execute(
"INSERT INTO sessions (session_id_hash, nc_user_id, expires_at) VALUES (?, ?, ?)",
(_hash_session_id(session_id), nc_user_id, expires_at),
)
return session_id
def resolve_session(session_id: str) -> str | None:
"""Returns the nc_user_id for a valid, non-expired session, refreshing
its expiry (sliding session), or None if invalid/expired."""
session_hash = _hash_session_id(session_id)
with get_conn() as conn:
row = conn.execute(
"SELECT nc_user_id, expires_at FROM sessions WHERE session_id_hash = ?",
(session_hash,),
).fetchone()
if row is None:
return None
if datetime.fromisoformat(row["expires_at"]) < datetime.utcnow():
conn.execute("DELETE FROM sessions WHERE session_id_hash = ?", (session_hash,))
return None
new_expiry = (datetime.utcnow() + timedelta(days=Config.SESSION_TTL_DAYS)).isoformat()
conn.execute(
"UPDATE sessions SET expires_at = ? WHERE session_id_hash = ?",
(new_expiry, session_hash),
)
return row["nc_user_id"]
def destroy_session(session_id: str) -> None:
with get_conn() as conn:
conn.execute(
"DELETE FROM sessions WHERE session_id_hash = ?", (_hash_session_id(session_id),)
)
def get_app_password(nc_user_id: str) -> str:
with get_conn() as conn:
row = conn.execute(
"SELECT nc_app_password_encrypted FROM users WHERE nc_user_id = ?", (nc_user_id,)
).fetchone()
if row is None:
raise RuntimeError(f"no stored credentials for {nc_user_id!r}")
try:
return _fernet().decrypt(bytes(row["nc_app_password_encrypted"])).decode()
except InvalidToken as exc:
raise RuntimeError(
"stored app password could not be decrypted - TOKEN_ENCRYPTION_KEY "
"changed or is wrong"
) from exc
# ------------------------------------------------------------- decorator
def enforce_login():
"""Populates g.nc_user_id/g.nc_app_password from the session cookie, or
returns a 401 response if there isn't a valid one. Used both as a
blueprint-wide `before_request` (so any route added to that blueprint is
protected by default, not only ones someone remembered to decorate) and
directly by the `login_required` decorator for routes registered
elsewhere.
"""
session_id = request.cookies.get(Config.SESSION_COOKIE_NAME)
nc_user_id = resolve_session(session_id) if session_id else None
if nc_user_id is None:
return jsonify(error="not logged in"), 401
g.nc_user_id = nc_user_id
g.nc_app_password = get_app_password(nc_user_id)
return None
def login_required(view):
"""Per-route variant of enforce_login(), for routes on a blueprint that
isn't (or shouldn't be) guarded wholesale."""
@wraps(view)
def wrapped(*args, **kwargs):
rejection = enforce_login()
if rejection is not None:
return rejection
return view(*args, **kwargs)
return wrapped
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from flask import Blueprint, jsonify, request
from . import auth
from .config import Config
bp = Blueprint("auth", __name__)
@bp.post("/login/start")
def login_start():
try:
result = auth.start_login_flow()
except Exception as exc: # noqa: BLE001
return jsonify(error=f"could not start login: {exc}"), 502
return jsonify(result)
@bp.get("/login/poll")
def login_poll():
flow_id = request.args.get("flow_id", "")
if not flow_id:
return jsonify(error="flow_id is required"), 400
try:
result = auth.poll_login_flow(flow_id)
except Exception as exc: # noqa: BLE001
return jsonify(error=f"login poll failed: {exc}"), 502
if result["status"] != "done":
return jsonify(status=result["status"])
resp = jsonify(status="done", nc_user_id=result["nc_user_id"])
resp.set_cookie(
Config.SESSION_COOKIE_NAME,
result["session_id"],
max_age=Config.SESSION_TTL_DAYS * 24 * 3600,
httponly=True,
secure=Config.SESSION_COOKIE_SECURE,
samesite="Lax",
path="/",
)
return resp
@bp.post("/logout")
def logout():
session_id = request.cookies.get(Config.SESSION_COOKIE_NAME)
if session_id:
auth.destroy_session(session_id)
resp = jsonify(status="ok")
resp.delete_cookie(Config.SESSION_COOKIE_NAME, path="/")
return resp
+16 -3
View File
@@ -17,10 +17,10 @@ class Config:
CORS_ORIGIN = os.environ.get("CORS_ORIGIN", "http://localhost:5173")
# Server address only - who's calling is now per logged-in user (see
# auth.py), not a single shared NC_USERNAME/NC_APP_PASSWORD.
NC_BASE_URL = os.environ.get("NC_BASE_URL", "").rstrip("/")
NC_USERNAME = os.environ.get("NC_USERNAME", "")
NC_APP_PASSWORD = os.environ.get("NC_APP_PASSWORD", "")
NC_UPLOAD_FOLDER = os.environ.get("NC_UPLOAD_FOLDER", "wgBill").strip("/")
NC_UPLOAD_FOLDER = os.environ.get("NC_UPLOAD_FOLDER", "Documents/Cospend/Assets").strip("/")
# Optional - pre-selects a project in the UI; the app lists all of the
# user's Cospend projects via the API either way, so this isn't required.
@@ -29,3 +29,16 @@ class Config:
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "").rstrip("/")
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
LLM_MODEL = os.environ.get("LLM_MODEL", "")
# Fernet key (Fernet.generate_key()) used to encrypt stored NC app
# passwords at rest. Required in production; a request-time error is
# raised if missing so this can't be silently skipped.
TOKEN_ENCRYPTION_KEY = os.environ.get("TOKEN_ENCRYPTION_KEY", "")
SESSION_COOKIE_NAME = "wgbill_session"
# Sessions are sliding - refreshed on use, so an active user never gets
# logged out; an idle one expires after this many days.
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
# Cookies need Secure (HTTPS-only) in any real deployment; only disable
# for plain-http local dev.
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() == "true"
+14 -12
View File
@@ -1,6 +1,7 @@
"""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:
"""Cospend's authenticated (NC-login) API - same per-user app-password auth
as nc_client (see auth.py). 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
@@ -28,10 +29,6 @@ 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:
@@ -39,11 +36,11 @@ def _base(project_id: str | None = None) -> str:
return f"{root}/projects/{project_id}"
def get_projects() -> list[dict[str, Any]]:
def get_projects(username: str, app_password: str) -> list[dict[str, Any]]:
"""Lists Cospend projects visible to the authenticated user."""
resp = requests.get(
f"{_base()}/projects",
auth=_auth(),
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
timeout=15,
@@ -52,10 +49,10 @@ def get_projects() -> list[dict[str, Any]]:
return resp.json()["ocs"]["data"]
def get_members(project_id: str) -> list[dict[str, Any]]:
def get_members(username: str, app_password: str, project_id: str) -> list[dict[str, Any]]:
resp = requests.get(
f"{_base(project_id)}/members",
auth=_auth(),
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
timeout=15,
@@ -65,6 +62,8 @@ def get_members(project_id: str) -> list[dict[str, Any]]:
def create_bill(
username: str,
app_password: str,
*,
project_id: str,
what: str,
@@ -82,7 +81,7 @@ def create_bill(
"""
resp = requests.post(
f"{_base(project_id)}/bills",
auth=_auth(),
auth=(username, app_password),
headers=_HEADERS,
params={"format": "json"},
json={
@@ -92,6 +91,9 @@ def create_bill(
"payedFor": ",".join(str(i) for i in ower_ids),
"comment": comment,
"date": date, # YYYY-MM-DD, the receipt's issue date, not today
# required by LocalProjectService::createBill (400s without it);
# 'n' = FREQUENCY_NO, i.e. this bill doesn't repeat.
"repeat": "n",
},
timeout=15,
)
-32
View File
@@ -1,32 +0,0 @@
"""Draws highlight boxes over selected items on the original receipt image."""
from __future__ import annotations
import io
from PIL import Image, ImageDraw
_BOX_COLOR = (255, 210, 0) # translucent yellow highlighter look
_BOX_WIDTH = 4
def highlight_items(image_bytes: bytes, bboxes: list[list[float]]) -> bytes:
"""`bboxes` are normalized [x, y, w, h] (0-1). Returns JPEG bytes."""
base = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
w, h = base.size
for bbox in bboxes:
if not bbox or len(bbox) != 4:
continue
x, y, bw, bh = bbox
left, top = x * w, y * h
right, bottom = (x + bw) * w, (y + bh) * h
draw.rectangle([left, top, right, bottom], fill=(*_BOX_COLOR, 70))
draw.rectangle([left, top, right, bottom], outline=(*_BOX_COLOR, 255), width=_BOX_WIDTH)
combined = Image.alpha_composite(base, overlay).convert("RGB")
out = io.BytesIO()
combined.save(out, format="JPEG", quality=90)
return out.getvalue()
+2 -7
View File
@@ -1,4 +1,4 @@
"""Receipt -> [{id, label, price, bbox}] via any OpenAI-compatible chat
"""Receipt -> [{id, label, price}] via any OpenAI-compatible chat
completions endpoint (works unmodified with OpenAI; point LLM_BASE_URL at
Gemini's OpenAI-compat layer to use that instead, no code change needed).
"""
@@ -23,16 +23,12 @@ Respond with ONLY a JSON object, no prose, no markdown fences, shaped like:
"store_name": "<store/shop name as printed, or null if unreadable>",
"date": "<receipt date as YYYY-MM-DD, or null if unreadable>",
"items": [
{"label": "<item name as printed>", "price": <number>, \
"bbox": [x, y, w, h]}
{"label": "<item name as printed>", "price": <number>}
]
}
- price is the item's price in the receipt's currency, as a plain number \
(no currency symbol).
- bbox is the item's approximate bounding box on the image, normalized to \
0-1 (x, y = top-left corner; w, h = width/height as a fraction of the \
image). Best effort is fine.
- Skip subtotal/tax/total lines, only real purchased items.
- If a line item's price is unclear, make your best guess rather than \
omitting it.
@@ -79,7 +75,6 @@ def extract_receipt(image_bytes: bytes, mime_type: str = "image/jpeg") -> dict:
"id": str(uuid.uuid4()),
"label": str(entry.get("label", "")).strip(),
"price": float(entry.get("price", 0) or 0),
"bbox": entry.get("bbox") or None,
}
)
+23 -18
View File
@@ -1,7 +1,9 @@
"""Thin wrapper around the bits of Nextcloud's WebDAV and OCS Share API we need.
Auth is HTTP Basic with an app password (Settings -> Security -> Devices &
Sessions -> create app password), never the account's real password.
Auth is HTTP Basic with an app password obtained per-user via Login Flow v2
(see auth.py) - callers pass (username, app_password) explicitly rather than
this module reading a single global credential, since every user acts as
themselves now.
"""
from __future__ import annotations
@@ -11,15 +13,11 @@ import requests
from .config import Config
def _auth() -> tuple[str, str]:
return (Config.NC_USERNAME, Config.NC_APP_PASSWORD)
def _webdav_root(username: str) -> str:
return f"{Config.NC_BASE_URL}/remote.php/dav/files/{username}"
def _webdav_root() -> str:
return f"{Config.NC_BASE_URL}/remote.php/dav/files/{Config.NC_USERNAME}"
def ensure_upload_folder() -> None:
def ensure_upload_folder(username: str, app_password: str) -> None:
"""MKCOL each segment of the configured upload folder path if missing.
NC_UPLOAD_FOLDER may be nested (e.g. "Documents/Cospend/Assets"); MKCOL
@@ -32,35 +30,42 @@ def ensure_upload_folder() -> None:
partial = ""
for segment in segments:
partial = f"{partial}/{segment}" if partial else segment
url = f"{_webdav_root()}/{partial}"
resp = requests.request("MKCOL", url, auth=_auth(), timeout=15)
url = f"{_webdav_root(username)}/{partial}"
resp = requests.request("MKCOL", url, auth=(username, app_password), timeout=15)
# 201 = created, 405 = already exists. Anything else is a real problem.
if resp.status_code not in (201, 405):
resp.raise_for_status()
def upload_file(filename: str, content: bytes, content_type: str = "image/jpeg") -> str:
"""Uploads `content` to <upload folder>/<filename> via WebDAV PUT.
def upload_file(
username: str, app_password: str, filename: str, content: bytes, content_type: str = "image/jpeg"
) -> str:
"""Uploads `content` to <upload folder>/<filename> via WebDAV PUT, into
the given user's own NC files.
Returns the server-relative path (e.g. "wgBill/receipt-123.jpg"), which is
what the Share API expects as `path`.
"""
ensure_upload_folder()
ensure_upload_folder(username, app_password)
rel_path = f"{Config.NC_UPLOAD_FOLDER}/{filename}" if Config.NC_UPLOAD_FOLDER else filename
url = f"{_webdav_root()}/{rel_path}"
url = f"{_webdav_root(username)}/{rel_path}"
resp = requests.put(
url, data=content, auth=_auth(), headers={"Content-Type": content_type}, timeout=30
url,
data=content,
auth=(username, app_password),
headers={"Content-Type": content_type},
timeout=30,
)
resp.raise_for_status()
return rel_path
def create_public_share(rel_path: str) -> str:
def create_public_share(username: str, app_password: str, rel_path: str) -> str:
"""Creates a public read-only link share for `rel_path`. Returns the share URL."""
url = f"{Config.NC_BASE_URL}/ocs/v2.php/apps/files_sharing/api/v1/shares"
resp = requests.post(
url,
auth=_auth(),
auth=(username, app_password),
headers={"OCS-APIRequest": "true"},
data={
"path": f"/{rel_path}",
+88 -36
View File
@@ -6,13 +6,24 @@ import uuid
from datetime import date as date_cls
from datetime import datetime
from flask import Blueprint, jsonify, request
from flask import Blueprint, g, jsonify, request
from . import cospend_client, highlight, llm_client, nc_client
from . import cospend_client, llm_client, nc_client
from .auth import enforce_login
from .config import Config
from .db import get_conn
bp = Blueprint("api", __name__)
# Every route on this blueprint requires a valid session by default - a
# route added here later is protected automatically, not only if someone
# remembers to decorate it. (auth_routes.py's /auth/* blueprint is
# deliberately separate and unguarded - it's the login mechanism itself.)
bp.before_request(enforce_login)
@bp.get("/me")
def me():
return jsonify(nc_user_id=g.nc_user_id)
# ---------------------------------------------------------------- receipts
@@ -43,9 +54,9 @@ def create_receipt():
with get_conn() as conn:
conn.execute(
"""INSERT INTO receipts (id, image_path, items_json, store_name, receipt_date)
VALUES (?, ?, ?, ?, ?)""",
(receipt_id, image_path, json.dumps(items), store_name, receipt_date),
"""INSERT INTO receipts (id, owner_nc_user_id, image_path, items_json, store_name, receipt_date)
VALUES (?, ?, ?, ?, ?, ?)""",
(receipt_id, g.nc_user_id, image_path, json.dumps(items), store_name, receipt_date),
)
return jsonify(id=receipt_id, items=items, store_name=store_name, date=receipt_date), 201
@@ -53,7 +64,7 @@ def create_receipt():
@bp.get("/receipts/<receipt_id>")
def get_receipt(receipt_id: str):
receipt = _load_receipt(receipt_id)
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="not found"), 404
return jsonify(receipt)
@@ -69,8 +80,8 @@ def update_items(receipt_id: str):
with get_conn() as conn:
cur = conn.execute(
"UPDATE receipts SET items_json = ? WHERE id = ?",
(json.dumps(items), receipt_id),
"UPDATE receipts SET items_json = ? WHERE id = ? AND owner_nc_user_id = ?",
(json.dumps(items), receipt_id, g.nc_user_id),
)
if cur.rowcount == 0:
return jsonify(error="not found"), 404
@@ -95,19 +106,24 @@ def update_meta(receipt_id: str):
if not fields:
return jsonify(error="nothing to update"), 400
values.append(receipt_id)
values.extend([receipt_id, g.nc_user_id])
with get_conn() as conn:
cur = conn.execute(f"UPDATE receipts SET {', '.join(fields)} WHERE id = ?", values)
cur = conn.execute(
f"UPDATE receipts SET {', '.join(fields)} WHERE id = ? AND owner_nc_user_id = ?", values
)
if cur.rowcount == 0:
return jsonify(error="not found"), 404
receipt = _load_receipt(receipt_id)
receipt = _load_receipt(receipt_id, g.nc_user_id)
return jsonify(receipt)
def _load_receipt(receipt_id: str) -> dict | None:
def _load_receipt(receipt_id: str, owner_nc_user_id: str) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM receipts WHERE id = ?", (receipt_id,)).fetchone()
row = conn.execute(
"SELECT * FROM receipts WHERE id = ? AND owner_nc_user_id = ?",
(receipt_id, owner_nc_user_id),
).fetchone()
if row is None:
return None
return {
@@ -125,7 +141,7 @@ def _load_receipt(receipt_id: str) -> dict | None:
@bp.get("/cospend/projects")
def cospend_projects():
try:
projects = cospend_client.get_projects()
projects = cospend_client.get_projects(g.nc_user_id, g.nc_app_password)
except Exception as exc: # noqa: BLE001
return jsonify(error=f"cospend request failed: {exc}"), 502
return jsonify(projects=projects, default_project_id=Config.COSPEND_DEFAULT_PROJECT_ID or None)
@@ -134,7 +150,7 @@ def cospend_projects():
@bp.get("/cospend/projects/<project_id>/members")
def cospend_members(project_id: str):
try:
members = cospend_client.get_members(project_id)
members = cospend_client.get_members(g.nc_user_id, g.nc_app_password, project_id)
except Exception as exc: # noqa: BLE001
return jsonify(error=f"cospend request failed: {exc}"), 502
return jsonify(members)
@@ -144,13 +160,16 @@ def cospend_members(project_id: str):
@bp.post("/receipts/<receipt_id>/groups")
def create_group(receipt_id: str):
"""Body: {name, cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}"""
receipt = _load_receipt(receipt_id)
"""Body: {cospend_project_id, payer_member_id, member_ids: [...], item_ids: [...]}
No user-facing "name" - the group is fully described by the project +
who's in the split, which is already what payer/member_ids capture.
"""
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="not found"), 404
body = request.get_json(force=True) or {}
name = body.get("name") or "WG"
project_id = body.get("cospend_project_id")
payer_member_id = body.get("payer_member_id")
member_ids = body.get("member_ids") or []
@@ -168,13 +187,12 @@ def create_group(receipt_id: str):
with get_conn() as conn:
conn.execute(
"""INSERT INTO groups
(id, receipt_id, name, cospend_project_id, payer_member_id,
(id, receipt_id, cospend_project_id, payer_member_id,
member_ids_json, item_ids_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?)""",
(
group_id,
receipt_id,
name,
project_id,
payer_member_id,
json.dumps(member_ids),
@@ -188,6 +206,10 @@ def create_group(receipt_id: str):
@bp.get("/receipts/<receipt_id>/groups")
def list_groups(receipt_id: str):
# Ownership check on the parent receipt is enough - a group can't exist
# without a receipt row, and receipts are already scoped per-owner.
if _load_receipt(receipt_id, g.nc_user_id) is None:
return jsonify(error="not found"), 404
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM groups WHERE receipt_id = ? ORDER BY created_at", (receipt_id,)
@@ -199,7 +221,6 @@ def _group_dict(row) -> dict:
return {
"id": row["id"],
"receipt_id": row["receipt_id"],
"name": row["name"],
"cospend_project_id": row["cospend_project_id"],
"payer_member_id": row["payer_member_id"],
"member_ids": json.loads(row["member_ids_json"]),
@@ -213,8 +234,8 @@ def _group_dict(row) -> dict:
@bp.post("/receipts/<receipt_id>/groups/<group_id>/submit")
def submit_group(receipt_id: str, group_id: str):
"""Highlights selected items, uploads to NC, shares, creates the Cospend bill."""
receipt = _load_receipt(receipt_id)
"""Uploads the receipt photo to NC, shares it, creates the Cospend bill."""
receipt = _load_receipt(receipt_id, g.nc_user_id)
if receipt is None:
return jsonify(error="receipt not found"), 404
@@ -232,33 +253,61 @@ def submit_group(receipt_id: str, group_id: str):
return jsonify(error="no matching items on this receipt"), 400
total = round(sum(item["price"] for item in selected), 2)
bboxes = [item["bbox"] for item in selected if item.get("bbox")]
with open(_receipt_image_path(receipt_id), "rb") as f:
with open(_receipt_image_path(receipt_id, g.nc_user_id), "rb") as f:
original_bytes = f.read()
try:
highlighted = highlight.highlight_items(original_bytes, bboxes)
# Members list is fetched here (not stored at group-creation time) so
# the label always reflects real names - also gives us the payer's
# name for a slightly more readable title.
members = cospend_client.get_members(
g.nc_user_id, g.nc_app_password, group["cospend_project_id"]
)
names_by_id = {str(m["id"]): m["name"] for m in members}
# member ids come back from Cospend as JSON numbers, not strings, and
# may have round-tripped through JS (which doesn't distinguish) - str()
# everything here so a missed match can't leave a raw int in the list
# `join` chokes on.
ower_names = [names_by_id.get(str(m), str(m)) for m in group["member_ids"]]
members_label = " + ".join(ower_names) if ower_names else "split"
# No highlighting - LLM-provided bounding boxes were unreliable
# enough on real receipts to not be worth it. Just upload the
# original photo, into the submitting user's own NC files.
# Timestamp for human sorting/browsing in NC, group id suffix so two
# submits in the same second (or a retry) never collide/overwrite.
safe_name = re.sub(r"[^A-Za-z0-9_-]+", "-", group["name"]).strip("-") or "group"
filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_name}_{group_id[:8]}.jpg"
rel_path = nc_client.upload_file(filename, highlighted)
share_url = nc_client.create_public_share(rel_path)
safe_label = re.sub(r"[^A-Za-z0-9_-]+", "-", members_label).strip("-") or "group"
filename = f"{datetime.now():%Y-%m-%d_%H%M%S}_{safe_label}_{group_id[:8]}.jpg"
rel_path = nc_client.upload_file(
g.nc_user_id, g.nc_app_password, filename, original_bytes
)
share_url = nc_client.create_public_share(g.nc_user_id, g.nc_app_password, rel_path)
item_lines = "\n".join(f"- {i['label']}: {i['price']}" for i in selected)
# Descriptive title: store name when we have one, else just the
# group name - either way followed by the share link, since
# Descriptive title: store name when we have one, else fall back to
# who's splitting it - either way followed by the share link, since
# Cospend's UI only recognizes a bill as having an attachment when
# the link is in `what` (the title); a link in `comment` is just
# plain text and isn't picked up.
# Only mention who it's split with when there's more than one bill
# off this receipt to tell apart - with a single bill it's just
# noise (the split is already visible in the bill itself).
with get_conn() as conn:
(bill_count,) = conn.execute(
"SELECT COUNT(*) FROM groups WHERE receipt_id = ?", (receipt_id,)
).fetchone()
store_name = receipt.get("store_name")
label = f"{store_name} ({group['name']})" if store_name else group["name"]
if bill_count > 1:
label = f"{store_name} ({members_label})" if store_name else members_label
else:
label = store_name or "receipt"
bill_date = receipt.get("date") or date_cls.today().isoformat()
bill_id = cospend_client.create_bill(
g.nc_user_id,
g.nc_app_password,
project_id=group["cospend_project_id"],
what=f"{label} {share_url}",
amount=total,
@@ -285,7 +334,10 @@ def submit_group(receipt_id: str, group_id: str):
return jsonify(share_url=share_url, bill_id=bill_id, amount=total)
def _receipt_image_path(receipt_id: str) -> str:
def _receipt_image_path(receipt_id: str, owner_nc_user_id: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT image_path FROM receipts WHERE id = ?", (receipt_id,)).fetchone()
row = conn.execute(
"SELECT image_path FROM receipts WHERE id = ? AND owner_nc_user_id = ?",
(receipt_id, owner_nc_user_id),
).fetchone()
return row["image_path"]
+20 -1
View File
@@ -1,5 +1,25 @@
CREATE TABLE IF NOT EXISTS users (
nc_user_id TEXT PRIMARY KEY,
-- Fernet-encrypted NC app password obtained via Login Flow v2. Never
-- returned by any API response.
nc_app_password_encrypted BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
-- SHA-256 hex digest of the actual session cookie value - never the raw
-- token, so a DB read alone can't yield a usable session (same
-- principle as password hashing).
session_id_hash TEXT PRIMARY KEY,
nc_user_id TEXT NOT NULL REFERENCES users(nc_user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS receipts (
id TEXT PRIMARY KEY,
owner_nc_user_id TEXT NOT NULL REFERENCES users(nc_user_id) ON DELETE CASCADE,
image_path TEXT NOT NULL,
image_width INTEGER,
image_height INTEGER,
@@ -14,7 +34,6 @@ CREATE TABLE IF NOT EXISTS receipts (
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
receipt_id TEXT NOT NULL REFERENCES receipts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
cospend_project_id TEXT NOT NULL,
payer_member_id TEXT NOT NULL,
member_ids_json TEXT NOT NULL, -- json list of cospend member ids (owers)
+1
View File
@@ -3,3 +3,4 @@ Flask-Cors==4.0.1
python-dotenv==1.0.1
requests==2.32.3
Pillow==10.4.0
cryptography==43.0.1
+590
View File
@@ -10,14 +10,17 @@
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"vite-plugin-pwa": "^1.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.2.2"
}
@@ -2587,6 +2590,539 @@
"win32"
]
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.24.1",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.3"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-x64": "4.3.3",
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.3",
"@tailwindcss/oxide": "4.3.3",
"tailwindcss": "4.3.3"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
@@ -3184,6 +3720,20 @@
"integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==",
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.24.5",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
"integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4219,6 +4769,16 @@
"node": ">=10"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4866,6 +5426,15 @@
"react": "^19.2.8"
}
},
"node_modules/react-icons": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5474,6 +6043,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+3
View File
@@ -12,14 +12,17 @@
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-icons": "^5.7.0",
"vite-plugin-pwa": "^1.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.2.2"
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

-199
View File
@@ -1,199 +0,0 @@
:root {
color-scheme: light dark;
--bg: #fafafa;
--fg: #1a1a1a;
--accent: #2b6cb0;
--border: #ddd;
--danger: #c0392b;
--selected: #e3f0ff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #17181a;
--fg: #eee;
--border: #3a3a3a;
--selected: #1c3350;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family:
system-ui,
-apple-system,
'Segoe UI',
sans-serif;
}
.app {
max-width: 480px;
margin: 0 auto;
padding: 1rem 1rem 3rem;
min-height: 100vh;
}
.step h1,
.step h2 {
margin-bottom: 0.25rem;
}
.hint {
color: #888;
font-size: 0.9rem;
margin-top: 0;
}
button {
display: block;
width: 100%;
padding: 0.9rem;
margin: 0.75rem 0;
border: none;
border-radius: 10px;
background: var(--accent);
color: white;
font-size: 1rem;
font-weight: 600;
}
button:disabled {
opacity: 0.5;
}
button.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
button.icon-btn {
width: auto;
padding: 0.4rem 0.6rem;
margin: 0;
background: transparent;
color: var(--danger);
font-weight: 400;
}
input,
select {
padding: 0.6rem;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 1rem;
}
.meta-row {
display: flex;
gap: 0.75rem;
margin: 0.75rem 0;
}
.meta-row label {
flex: 1;
display: flex;
flex-direction: column;
font-size: 0.85rem;
gap: 0.25rem;
}
.item-list {
list-style: none;
padding: 0;
margin: 0.5rem 0;
}
.item-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.item-row input[type='text'],
.item-row input:not([type]) {
flex: 1;
}
.item-row input[type='number'] {
width: 5.5rem;
}
.item-list.selectable li {
display: flex;
justify-content: space-between;
padding: 0.7rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.4rem;
cursor: pointer;
}
.item-list.selectable li.selected {
background: var(--selected);
border-color: var(--accent);
}
.member-list {
list-style: none;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.member-list label {
display: flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.35rem 0.75rem;
}
.payer-select {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.group-name {
width: 100%;
margin: 0.5rem 0;
}
.total {
font-weight: 600;
text-align: right;
}
.error {
color: var(--danger);
}
.submitted-groups {
background: var(--selected);
border-radius: 8px;
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
}
.summary-list {
list-style: none;
padding: 0;
}
.summary-list li {
padding: 0.6rem 0;
border-bottom: 1px solid var(--border);
}
+32 -31
View File
@@ -1,52 +1,53 @@
import { useState } from 'react'
import './App.css'
import { CaptureStep } from './components/CaptureStep'
import { ReviewStep } from './components/ReviewStep'
import { GroupStep } from './components/GroupStep'
import { LoginGate } from './components/LoginGate'
import { SummaryStep } from './components/SummaryStep'
import type { Group, Item } from './types'
type Stage =
| { name: 'capture' }
| { name: 'review'; receiptId: string; items: Item[]; storeName: string | null; date: string }
| { name: 'group'; receiptId: string; items: Item[] }
| { name: 'group'; receiptId: string; items: Item[]; storeName: string | null; date: string }
| { name: 'summary'; groups: Group[] }
export default function App() {
const [stage, setStage] = useState<Stage>({ name: 'capture' })
return (
<div className="app">
{stage.name === 'capture' && (
<CaptureStep
onExtracted={(receiptId, items, storeName, date) =>
setStage({ name: 'review', receiptId, items, storeName, date: date || today() })
}
/>
)}
<LoginGate>
{({ ncUserId, logout }) => (
<div className="mx-auto min-h-screen max-w-2xl px-4 pb-16 pt-8 sm:px-6">
<div className="mb-4 flex items-center justify-end gap-2 text-xs text-neutral-400">
<span>{ncUserId}</span>
<button onClick={logout} className="underline hover:text-neutral-600 dark:hover:text-neutral-200">
log out
</button>
</div>
{stage.name === 'review' && (
<ReviewStep
receiptId={stage.receiptId}
initialItems={stage.items}
initialStoreName={stage.storeName}
initialDate={stage.date}
onConfirmed={(items) => setStage({ name: 'group', receiptId: stage.receiptId, items })}
/>
)}
{stage.name === 'capture' && (
<CaptureStep
onExtracted={(receiptId, items, storeName, date) =>
setStage({ name: 'group', receiptId, items, storeName, date: date || today() })
}
/>
)}
{stage.name === 'group' && (
<GroupStep
receiptId={stage.receiptId}
items={stage.items}
onAllDone={(groups) => setStage({ name: 'summary', groups })}
/>
)}
{stage.name === 'group' && (
<GroupStep
receiptId={stage.receiptId}
initialItems={stage.items}
initialStoreName={stage.storeName}
initialDate={stage.date}
onAllDone={(groups) => setStage({ name: 'summary', groups })}
/>
)}
{stage.name === 'summary' && (
<SummaryStep groups={stage.groups} onRestart={() => setStage({ name: 'capture' })} />
{stage.name === 'summary' && (
<SummaryStep groups={stage.groups} onRestart={() => setStage({ name: 'capture' })} />
)}
</div>
)}
</div>
</LoginGate>
)
}
+43 -12
View File
@@ -2,11 +2,19 @@ import type { CospendProject, Group, Item, Member, Receipt, SubmitResult } from
const BASE = import.meta.env.VITE_API_BASE_URL ?? ''
export class UnauthorizedError extends Error {}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}/api${path}`, {
const res = await fetch(`${BASE}${path}`, {
...init,
// Auth is a cookie now (see auth.py) - needed even same-origin-via-proxy
// in dev, and required for a cross-origin prod deployment.
credentials: 'include',
headers: init?.body instanceof FormData ? init.headers : { 'Content-Type': 'application/json', ...init?.headers },
})
if (res.status === 401) {
throw new UnauthorizedError('not logged in')
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `${res.status} ${res.statusText}`)
@@ -15,52 +23,75 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
getMe(): Promise<{ nc_user_id: string }> {
return req('/api/me')
},
authStart(): Promise<{ flow_id: string; login_url: string }> {
return req('/auth/login/start', { method: 'POST' })
},
authPoll(
flowId: string,
): Promise<{ status: 'pending' | 'expired' | 'done'; nc_user_id?: string }> {
return req(`/auth/login/poll?flow_id=${encodeURIComponent(flowId)}`)
},
logout(): Promise<{ status: string }> {
return req('/auth/logout', { method: 'POST' })
},
uploadReceipt(
file: File | Blob,
): Promise<{ id: string; items: Item[]; store_name: string | null; date: string }> {
const form = new FormData()
form.append('image', file, 'receipt.jpg')
return req('/receipts', { method: 'POST', body: form })
return req('/api/receipts', { method: 'POST', body: form })
},
getReceipt(id: string): Promise<Receipt> {
return req(`/receipts/${id}`)
return req(`/api/receipts/${id}`)
},
updateItems(id: string, items: Item[]): Promise<Receipt> {
return req(`/receipts/${id}/items`, { method: 'PATCH', body: JSON.stringify({ items }) })
return req(`/api/receipts/${id}/items`, { method: 'PATCH', body: JSON.stringify({ items }) })
},
updateMeta(id: string, meta: { store_name?: string; date?: string }): Promise<Receipt> {
return req(`/receipts/${id}/meta`, { method: 'PATCH', body: JSON.stringify(meta) })
return req(`/api/receipts/${id}/meta`, { method: 'PATCH', body: JSON.stringify(meta) })
},
getProjects(): Promise<{ projects: CospendProject[]; default_project_id: string | null }> {
return req('/cospend/projects')
return req('/api/cospend/projects')
},
getMembers(projectId: string): Promise<Member[]> {
return req(`/cospend/projects/${projectId}/members`)
async getMembers(projectId: string): Promise<Member[]> {
// Cospend returns member ids as JSON numbers; normalize to strings here
// so every consumer downstream can rely on the Member/Group types'
// `string` id fields actually being strings, not silently numbers.
const raw = await req<Array<{ id: number | string; name: string }>>(
`/api/cospend/projects/${projectId}/members`,
)
return raw.map((m) => ({ id: String(m.id), name: m.name }))
},
createGroup(
receiptId: string,
group: {
name: string
cospend_project_id: string
payer_member_id: string
member_ids: string[]
item_ids: string[]
},
): Promise<{ id: string }> {
return req(`/receipts/${receiptId}/groups`, { method: 'POST', body: JSON.stringify(group) })
return req(`/api/receipts/${receiptId}/groups`, { method: 'POST', body: JSON.stringify(group) })
},
submitGroup(receiptId: string, groupId: string): Promise<SubmitResult> {
return req(`/receipts/${receiptId}/groups/${groupId}/submit`, { method: 'POST' })
return req(`/api/receipts/${receiptId}/groups/${groupId}/submit`, { method: 'POST' })
},
listGroups(receiptId: string): Promise<Group[]> {
return req(`/receipts/${receiptId}/groups`)
return req(`/api/receipts/${receiptId}/groups`)
},
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

+39 -5
View File
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react'
import { CiReceipt } from 'react-icons/ci'
import { api } from '../api'
import type { Item } from '../types'
@@ -10,6 +11,7 @@ export function CaptureStep({ onExtracted }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dragging, setDragging] = useState(false)
async function handleFile(file: File | undefined) {
if (!file) return
@@ -25,10 +27,33 @@ export function CaptureStep({ onExtracted }: Props) {
}
}
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
const file = [...e.dataTransfer.files].find((f) => f.type.startsWith('image/'))
handleFile(file)
}
return (
<div className="step capture-step">
<h1>wgBill</h1>
<p>Take a photo of the receipt to get started.</p>
<div
className={`flex min-h-[70vh] flex-col items-center justify-center rounded-2xl border-2 border-dashed p-10 text-center transition-colors ${
dragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/30'
: 'border-transparent'
}`}
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
<CiReceipt className="h-16 w-16 text-blue-600 dark:text-blue-400" />
<h1 className="mt-2 text-3xl font-bold tracking-tight">wgBill</h1>
<p className="mt-2 text-neutral-500 dark:text-neutral-400">
Take a photo of the receipt to get started.
</p>
<input
ref={inputRef}
type="file"
@@ -37,10 +62,19 @@ export function CaptureStep({ onExtracted }: Props) {
hidden
onChange={(e) => handleFile(e.target.files?.[0])}
/>
<button disabled={loading} onClick={() => inputRef.current?.click()}>
<button
disabled={loading}
onClick={() => inputRef.current?.click()}
className="mt-6 w-full max-w-xs rounded-xl bg-blue-600 px-6 py-3 font-semibold text-white shadow-sm transition hover:bg-blue-500 disabled:opacity-50 sm:w-auto"
>
{loading ? 'Reading receipt…' : 'Take photo'}
</button>
{error && <p className="error">{error}</p>}
<p className="mt-4 text-sm text-neutral-400 dark:text-neutral-500">
or drop an image anywhere on this page
</p>
{error && <p className="mt-4 text-sm text-red-500">{error}</p>}
</div>
)
}
+340 -148
View File
@@ -1,31 +1,65 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { CiReceipt } from 'react-icons/ci'
import { api } from '../api'
import type { CospendProject, Group, Item, Member } from '../types'
// <input type="date">'s displayed format follows the browser/OS locale, not
// anything we control from the page - so for a fixed DD.MM.YYYY display we
// use a plain text field instead, converting to/from the ISO date the
// backend expects.
function isoToEu(iso: string): string {
const [y, m, d] = iso.split('-')
return y && m && d ? `${d}.${m}.${y}` : ''
}
function euToIso(eu: string): string | null {
const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(eu.trim())
if (!match) return null
const [, d, m, y] = match
return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`
}
interface Props {
receiptId: string
items: Item[]
initialItems: Item[]
initialStoreName: string | null
initialDate: string
onAllDone: (groups: Group[]) => void
}
export function GroupStep({ receiptId, items, onAllDone }: Props) {
/** One computed bill: a distinct set of owers, and the items assigned to it. */
interface Partition {
memberIds: string[]
items: Item[]
total: number
}
export function GroupStep({
receiptId,
initialItems,
initialStoreName,
initialDate,
onAllDone,
}: Props) {
// --- receipt-level fields (was the separate "review" step) ---
const [items, setItems] = useState<Item[]>(initialItems)
const [storeName, setStoreName] = useState(initialStoreName ?? '')
const [date, setDate] = useState(initialDate)
const [dateText, setDateText] = useState(isoToEu(initialDate))
// --- cospend data ---
const [projects, setProjects] = useState<CospendProject[]>([])
const [projectId, setProjectId] = useState<string | null>(null)
const [members, setMembers] = useState<Member[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [remainingIds, setRemainingIds] = useState<Set<string>>(new Set(items.map((i) => i.id)))
const [groups, setGroups] = useState<Group[]>([])
// current draft
const [name, setName] = useState('WG')
const [payerId, setPayerId] = useState<string | null>(null)
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(new Set())
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set())
// itemId -> set of member ids splitting that item. Missing/empty = excluded.
const [itemMembers, setItemMembers] = useState<Record<string, Set<string>>>({})
const [customizing, setCustomizing] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// load Cospend projects once, pick the configured default (or the only one)
useEffect(() => {
api
.getProjects()
@@ -37,78 +71,124 @@ export function GroupStep({ receiptId, items, onAllDone }: Props) {
.catch((e) => setLoadError(e instanceof Error ? e.message : String(e)))
}, [])
// load members whenever the selected project changes
useEffect(() => {
if (!projectId) return
api
.getMembers(projectId)
.then((m) => {
Promise.all([api.getMembers(projectId), api.getMe()])
.then(([m, { nc_user_id }]) => {
setMembers(m)
if (m.length > 0) {
setPayerId(m[0].id)
setSelectedMemberIds(new Set(m.map((mm) => mm.id)))
setPayerId(fuzzyMatchMember(m, nc_user_id)?.id ?? m[0].id)
}
})
.catch((e) => setLoadError(e instanceof Error ? e.message : String(e)))
}, [projectId])
function toggleItem(id: string) {
setSelectedItemIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
const allMemberIds = useMemo(() => members.map((m) => m.id), [members])
function updateItem(id: string, patch: Partial<Item>) {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)))
}
function removeItem(id: string) {
setItems((prev) => prev.filter((it) => it.id !== id))
setItemMembers((prev) => {
const next = { ...prev }
delete next[id]
return next
})
}
function toggleMember(id: string) {
setSelectedMemberIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
function addItem() {
const id = crypto.randomUUID()
setItems((prev) => [...prev, { id, label: '', price: 0 }])
}
function isIncluded(itemId: string) {
return (itemMembers[itemId]?.size ?? 0) > 0
}
/** The primary, one-click action: off -> everyone, on -> off. */
function toggleIncluded(itemId: string) {
setItemMembers((prev) => {
const next = { ...prev }
if (isIncluded(itemId)) {
delete next[itemId]
} else {
next[itemId] = new Set(allMemberIds)
}
return next
})
}
const remainingItems = items.filter((i) => remainingIds.has(i.id))
const groupTotal = items
.filter((i) => selectedItemIds.has(i.id))
.reduce((sum, i) => sum + i.price, 0)
function toggleItemMember(itemId: string, memberId: string) {
setItemMembers((prev) => {
const current = new Set(prev[itemId] ?? [])
current.has(memberId) ? current.delete(memberId) : current.add(memberId)
const next = { ...prev, [itemId]: current }
if (current.size === 0) delete next[itemId]
return next
})
}
async function submitCurrentGroup() {
if (!projectId) {
setError('pick a Cospend project')
// Group included items by identical ower-set - each distinct set becomes
// its own bill, since a Cospend bill can only have one split.
const partitions: Partition[] = useMemo(() => {
const byKey = new Map<string, Partition>()
for (const item of items) {
const memberSet = itemMembers[item.id]
if (!memberSet || memberSet.size === 0) continue
const memberIds = [...memberSet].sort()
const key = memberIds.join(',')
const existing = byKey.get(key)
if (existing) {
existing.items.push(item)
existing.total += item.price
} else {
byKey.set(key, { memberIds, items: [item], total: item.price })
}
}
return [...byKey.values()]
}, [items, itemMembers])
const excludedCount = items.length - partitions.reduce((n, p) => n + p.items.length, 0)
function memberName(id: string) {
return members.find((m) => m.id === id)?.name ?? id
}
async function submitAll() {
if (!projectId || !payerId) {
setError('pick a Cospend project and who paid')
return
}
if (!payerId || selectedMemberIds.size === 0 || selectedItemIds.size === 0) {
setError('pick a payer, at least one member, and at least one item')
if (partitions.length === 0) {
setError('include at least one item')
return
}
setSubmitting(true)
setError(null)
try {
const { id: groupId } = await api.createGroup(receiptId, {
name,
cospend_project_id: projectId,
payer_member_id: payerId,
member_ids: [...selectedMemberIds],
item_ids: [...selectedItemIds],
})
const result = await api.submitGroup(receiptId, groupId)
const allGroups = await api.listGroups(receiptId)
setGroups(allGroups)
await api.updateItems(receiptId, items)
await api.updateMeta(receiptId, { store_name: storeName, date })
setRemainingIds((prev) => {
const next = new Set(prev)
selectedItemIds.forEach((id) => next.delete(id))
return next
})
const wasLastGroup = remainingItems.length === selectedItemIds.size
setSelectedItemIds(new Set())
setName('WG')
if (wasLastGroup) {
onAllDone(allGroups)
// Create every group first, then submit - so by the time any of them
// submits, the backend can see the receipt's real total bill count
// (and only mention who's splitting it in the title when there's more
// than one bill to tell apart).
const groupIds: string[] = []
for (const partition of partitions) {
const { id: groupId } = await api.createGroup(receiptId, {
cospend_project_id: projectId,
payer_member_id: payerId,
member_ids: partition.memberIds,
item_ids: partition.items.map((i) => i.id),
})
groupIds.push(groupId)
}
void result
for (const groupId of groupIds) {
await api.submitGroup(receiptId, groupId)
}
onAllDone(await api.listGroups(receiptId))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
@@ -117,108 +197,220 @@ export function GroupStep({ receiptId, items, onAllDone }: Props) {
}
if (loadError) {
return (
<div className="step">
<p className="error">Couldn't load Cospend data: {loadError}</p>
</div>
)
return <p className="mt-8 text-center text-red-500">Couldn't load Cospend data: {loadError}</p>
}
return (
<div className="step group-step">
<h2>Who's splitting this?</h2>
<p className="hint">
Select the items for one splitting arrangement, pick who's in on it, submit. Repeat for
any items that need a different group (e.g. only your snack).
</p>
<div className="space-y-6">
<div className="flex items-center gap-2">
<CiReceipt className="h-8 w-8 shrink-0 text-blue-600 dark:text-blue-400" />
<h2 className="text-2xl font-bold">Split the receipt</h2>
</div>
<label className="payer-select">
Cospend project
<select value={projectId ?? ''} onChange={(e) => setProjectId(e.target.value)}>
<option value="" disabled>
select a project…
</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
{groups.length > 0 && (
<div className="submitted-groups">
{groups.map((g) => (
<p key={g.id}>
✓ {g.name} bill created —{' '}
<a href={g.share_url ?? '#'} target="_blank" rel="noreferrer">
receipt
</a>
</p>
))}
</div>
)}
<input
className="group-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="group name (e.g. WG, just me + Alex)"
/>
<h3>Items ({remainingItems.length} left)</h3>
<ul className="item-list selectable">
{remainingItems.map((item) => (
<li
key={item.id}
className={selectedItemIds.has(item.id) ? 'selected' : ''}
onClick={() => toggleItem(item.id)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Field label="Store">
<input
value={storeName}
placeholder="(unreadable)"
onChange={(e) => setStoreName(e.target.value)}
className="input"
/>
</Field>
<Field label="Date">
<input
type="text"
inputMode="numeric"
placeholder="TT.MM.JJJJ"
value={dateText}
onChange={(e) => {
setDateText(e.target.value)
const iso = euToIso(e.target.value)
if (iso) setDate(iso)
}}
className="input"
/>
</Field>
<Field label="Cospend project">
<select
value={projectId ?? ''}
onChange={(e) => setProjectId(e.target.value)}
className="input"
>
<span>{item.label || '(unnamed)'}</span>
<span>{item.price.toFixed(2)}</span>
</li>
))}
</ul>
<h3>Split with</h3>
<ul className="member-list">
{members.map((m) => (
<li key={m.id}>
<label>
<input
type="checkbox"
checked={selectedMemberIds.has(m.id)}
onChange={() => toggleMember(m.id)}
/>
{m.name}
</label>
</li>
))}
</ul>
<label className="payer-select">
Paid by
<select value={payerId ?? ''} onChange={(e) => setPayerId(e.target.value)}>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
<option value="" disabled>
select…
</option>
))}
</select>
</label>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
<Field label="Paid by">
<select
value={payerId ?? ''}
onChange={(e) => setPayerId(e.target.value)}
className="input"
>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
</div>
<p className="total">Group total: {groupTotal.toFixed(2)}</p>
{error && <p className="error">{error}</p>}
<ul className="space-y-2">
{items.map((item) => {
const included = isIncluded(item.id)
const selectedMembers = itemMembers[item.id] ?? new Set<string>()
return (
<li
key={item.id}
className={`rounded-xl border p-3 transition-colors ${
included
? 'border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30'
: 'border-neutral-200 dark:border-neutral-800'
}`}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => toggleIncluded(item.id)}
aria-pressed={included}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border text-xs font-bold transition-colors ${
included
? 'border-blue-600 bg-blue-600 text-white'
: 'border-neutral-300 text-transparent dark:border-neutral-600'
}`}
>
</button>
<button disabled={submitting || selectedItemIds.size === 0} onClick={submitCurrentGroup}>
{submitting ? 'Creating bill' : 'Create bill for this group'}
<input
value={item.label}
placeholder="item"
onChange={(e) => updateItem(item.id, { label: e.target.value })}
className="min-w-0 flex-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-sm focus:border-neutral-300 focus:bg-white focus:outline-none dark:focus:border-neutral-700 dark:focus:bg-neutral-900"
/>
<input
type="number"
step="0.01"
value={item.price}
onChange={(e) => updateItem(item.id, { price: Number(e.target.value) })}
className="w-20 shrink-0 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-right text-sm tabular-nums focus:border-neutral-300 focus:bg-white focus:outline-none dark:focus:border-neutral-700 dark:focus:bg-neutral-900"
/>
<button
type="button"
onClick={() => setCustomizing(customizing === item.id ? null : item.id)}
className="shrink-0 rounded-md px-2 py-1 text-xs text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
>
{included ? memberInitials(selectedMembers, members) : 'excluded'} ▾
</button>
<button
type="button"
onClick={() => removeItem(item.id)}
aria-label="remove item"
className="shrink-0 rounded-md px-1.5 py-1 text-neutral-400 hover:text-red-500"
>
</button>
</div>
{customizing === item.id && (
<div className="mt-2 flex flex-wrap gap-2 border-t border-neutral-200 pt-2 dark:border-neutral-800">
{members.map((m) => (
<label
key={m.id}
className="flex items-center gap-1.5 rounded-full border border-neutral-300 px-2.5 py-1 text-xs dark:border-neutral-700"
>
<input
type="checkbox"
checked={selectedMembers.has(m.id)}
onChange={() => toggleItemMember(item.id, m.id)}
/>
{m.name}
</label>
))}
</div>
)}
</li>
)
})}
</ul>
<button
onClick={addItem}
className="w-full rounded-lg border border-dashed border-neutral-300 py-2 text-sm text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-neutral-700 dark:hover:border-neutral-500"
>
+ add missed item
</button>
{remainingItems.length === 0 && groups.length > 0 && (
<button className="secondary" onClick={() => onAllDone(groups)}>
All items assigned finish
</button>
)}
<div className="rounded-xl bg-neutral-100 p-4 dark:bg-neutral-900">
<h3 className="text-sm font-semibold">
{partitions.length} bill{partitions.length === 1 ? '' : 's'} to create
{excludedCount > 0 && (
<span className="font-normal text-neutral-500"> · {excludedCount} excluded</span>
)}
</h3>
<ul className="mt-2 space-y-1 text-sm text-neutral-600 dark:text-neutral-300">
{partitions.map((p) => (
<li key={p.memberIds.join(',')} className="flex justify-between">
<span>
{p.memberIds.map(memberName).join(' + ')}{' '}
<span className="text-neutral-400">({p.items.length} item(s))</span>
</span>
<span className="tabular-nums">{p.total.toFixed(2)}</span>
</li>
))}
</ul>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
disabled={submitting || partitions.length === 0}
onClick={submitAll}
className="w-full rounded-xl bg-blue-600 py-3 font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
>
{submitting ? 'Creating bills' : `Create ${partitions.length || ''} bill(s)`}
</button>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1 text-xs font-medium text-neutral-500 dark:text-neutral-400">
{label}
{children}
</label>
)
}
/** Best-effort match of the logged-in NC user against a Cospend member
* name, so we can preselect them as the default payer without hardcoding a
* project-specific member id anywhere. Case-insensitive exact match first,
* then substring containment either direction; null if nothing looks close
* enough (caller falls back to the first member). */
function fuzzyMatchMember(members: Member[], ncUsername: string): Member | null {
const needle = ncUsername.trim().toLowerCase()
if (!needle) return null
const exact = members.find((m) => m.name.trim().toLowerCase() === needle)
if (exact) return exact
const partial = members.find((m) => {
const name = m.name.trim().toLowerCase()
return name.includes(needle) || needle.includes(name)
})
return partial ?? null
}
function memberInitials(ids: Set<string>, members: Member[]): string {
if (ids.size === members.length && members.length > 0) return 'everyone'
return [...ids].map((id) => members.find((m) => m.id === id)?.name.slice(0, 3) ?? '?').join(',')
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useRef, useState } from 'react'
import { CiReceipt } from 'react-icons/ci'
import { api, UnauthorizedError } from '../api'
interface Props {
/** Rendered once a session is confirmed. */
children: (ctx: { ncUserId: string; logout: () => void }) => React.ReactNode
}
type Status =
| { name: 'checking' }
| { name: 'loggedOut' }
| { name: 'starting' }
| { name: 'polling'; flowId: string }
| { name: 'error'; message: string }
| { name: 'loggedIn'; ncUserId: string }
const POLL_INTERVAL_MS = 1500
export function LoginGate({ children }: Props) {
const [status, setStatus] = useState<Status>({ name: 'checking' })
const openedWindow = useRef<Window | null>(null)
useEffect(() => {
api
.getMe()
.then(({ nc_user_id }) => setStatus({ name: 'loggedIn', ncUserId: nc_user_id }))
.catch((e) => {
if (e instanceof UnauthorizedError) setStatus({ name: 'loggedOut' })
else setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) })
})
}, [])
useEffect(() => {
if (status.name !== 'polling') return
const flowId = status.flowId
const interval = setInterval(async () => {
try {
const result = await api.authPoll(flowId)
if (result.status === 'done' && result.nc_user_id) {
openedWindow.current?.close()
setStatus({ name: 'loggedIn', ncUserId: result.nc_user_id })
} else if (result.status === 'expired') {
setStatus({ name: 'error', message: 'Login timed out - try again.' })
}
} catch (e) {
setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) })
}
}, POLL_INTERVAL_MS)
return () => clearInterval(interval)
}, [status])
async function login() {
setStatus({ name: 'starting' })
try {
const { flow_id, login_url } = await api.authStart()
openedWindow.current = window.open(login_url, '_blank')
setStatus({ name: 'polling', flowId: flow_id })
} catch (e) {
setStatus({ name: 'error', message: e instanceof Error ? e.message : String(e) })
}
}
function logout() {
api.logout().finally(() => setStatus({ name: 'loggedOut' }))
}
if (status.name === 'loggedIn') {
return <>{children({ ncUserId: status.ncUserId, logout })}</>
}
return (
<div className="flex min-h-[70vh] flex-col items-center justify-center px-4 text-center">
<CiReceipt className="h-16 w-16 text-blue-600 dark:text-blue-400" />
<h1 className="mt-2 text-3xl font-bold tracking-tight">wgBill</h1>
{status.name === 'checking' && (
<p className="mt-4 text-neutral-500 dark:text-neutral-400">Checking login</p>
)}
{(status.name === 'loggedOut' || status.name === 'starting') && (
<>
<p className="mt-2 text-neutral-500 dark:text-neutral-400">
Log in with your Nextcloud account to get started.
</p>
<button
disabled={status.name === 'starting'}
onClick={login}
className="mt-6 w-full max-w-xs rounded-xl bg-blue-600 px-6 py-3 font-semibold text-white shadow-sm transition hover:bg-blue-500 disabled:opacity-50 sm:w-auto"
>
{status.name === 'starting' ? 'Starting…' : 'Log in with Nextcloud'}
</button>
</>
)}
{status.name === 'polling' && (
<p className="mt-4 text-neutral-500 dark:text-neutral-400">
Complete the login in the tab that just opened, then come back here.
</p>
)}
{status.name === 'error' && (
<>
<p className="mt-4 text-red-500">{status.message}</p>
<button
onClick={login}
className="mt-6 w-full max-w-xs rounded-xl bg-blue-600 px-6 py-3 font-semibold text-white shadow-sm transition hover:bg-blue-500 sm:w-auto"
>
Try again
</button>
</>
)}
</div>
)
}
-107
View File
@@ -1,107 +0,0 @@
import { useState } from 'react'
import { api } from '../api'
import type { Item } from '../types'
interface Props {
receiptId: string
initialItems: Item[]
initialStoreName: string | null
initialDate: string
onConfirmed: (items: Item[], storeName: string, date: string) => void
}
export function ReviewStep({
receiptId,
initialItems,
initialStoreName,
initialDate,
onConfirmed,
}: Props) {
const [items, setItems] = useState<Item[]>(initialItems)
const [storeName, setStoreName] = useState(initialStoreName ?? '')
const [date, setDate] = useState(initialDate)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
function updateItem(id: string, patch: Partial<Item>) {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)))
}
function removeItem(id: string) {
setItems((prev) => prev.filter((it) => it.id !== id))
}
function addItem() {
setItems((prev) => [
...prev,
{ id: crypto.randomUUID(), label: '', price: 0, bbox: null },
])
}
async function confirm() {
setSaving(true)
setError(null)
try {
await api.updateItems(receiptId, items)
await api.updateMeta(receiptId, { store_name: storeName, date })
onConfirmed(items, storeName, date)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const total = items.reduce((sum, it) => sum + (Number(it.price) || 0), 0)
return (
<div className="step review-step">
<h2>Check the extracted items</h2>
<p className="hint">Fix anything the receipt reader got wrong before splitting.</p>
<div className="meta-row">
<label>
Store
<input
value={storeName}
placeholder="(unreadable - add it)"
onChange={(e) => setStoreName(e.target.value)}
/>
</label>
<label>
Date
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</label>
</div>
<ul className="item-list">
{items.map((item) => (
<li key={item.id} className="item-row">
<input
value={item.label}
placeholder="item"
onChange={(e) => updateItem(item.id, { label: e.target.value })}
/>
<input
type="number"
step="0.01"
value={item.price}
onChange={(e) => updateItem(item.id, { price: Number(e.target.value) })}
/>
<button className="icon-btn" onClick={() => removeItem(item.id)} aria-label="remove item">
</button>
</li>
))}
</ul>
<button className="secondary" onClick={addItem}>
+ add missed item
</button>
<p className="total">Total: {total.toFixed(2)}</p>
{error && <p className="error">{error}</p>}
<button disabled={saving || items.length === 0} onClick={confirm}>
{saving ? 'Saving…' : 'Looks good, continue'}
</button>
</div>
)
}
+21 -10
View File
@@ -7,26 +7,37 @@ interface Props {
export function SummaryStep({ groups, onRestart }: Props) {
return (
<div className="step summary-step">
<h2>Done 🎉</h2>
<ul className="summary-list">
<div>
<h2 className="text-2xl font-bold">Done 🎉</h2>
<ul className="mt-4 space-y-2">
{groups.map((g) => (
<li key={g.id}>
<strong>{g.name}</strong>
<li
key={g.id}
className="rounded-lg border border-neutral-200 px-4 py-3 text-sm dark:border-neutral-800"
>
{g.status === 'submitted' ? (
<>
{' — bill created. '}
<a href={g.share_url ?? '#'} target="_blank" rel="noreferrer">
view receipt
<a
href={g.share_url ?? '#'}
target="_blank"
rel="noreferrer"
className="font-medium text-blue-600 underline dark:text-blue-400"
>
View receipt
</a>
</>
) : (
<span className="error"> failed: {g.error}</span>
<span className="text-red-500">failed: {g.error}</span>
)}
</li>
))}
</ul>
<button onClick={onRestart}>Scan another receipt</button>
<button
onClick={onRestart}
className="mt-6 w-full rounded-xl bg-blue-600 py-3 font-semibold text-white hover:bg-blue-500"
>
Scan another receipt
</button>
</div>
)
}
+6 -102
View File
@@ -1,111 +1,15 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
@import 'tailwindcss';
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
html {
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
@apply m-0 min-h-screen bg-neutral-50 font-sans text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
@layer components {
.input {
@apply rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
-13
View File
@@ -1,10 +1,7 @@
export type BBox = [number, number, number, number] // x, y, w, h, normalized 0-1
export interface Item {
id: string
label: string
price: number
bbox: BBox | null
}
export interface Receipt {
@@ -29,7 +26,6 @@ export interface CospendProject {
export interface Group {
id: string
receipt_id: string
name: string
cospend_project_id: string
payer_member_id: string
member_ids: string[]
@@ -45,12 +41,3 @@ export interface SubmitResult {
amount: number
bill_id: number
}
/** A group draft in progress client-side, before it's persisted via POST /groups. */
export interface GroupDraft {
localId: string
name: string
payerMemberId: string | null
memberIds: string[]
itemIds: string[]
}
+3
View File
@@ -1,3 +1,4 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
@@ -6,6 +7,7 @@ import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
@@ -25,6 +27,7 @@ export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:5000',
'/auth': 'http://localhost:5000',
},
},
})