from __future__ import annotations import json import re import uuid from datetime import date as date_cls from datetime import datetime from flask import Blueprint, g, jsonify, request 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 @bp.post("/receipts") def create_receipt(): """multipart/form-data with an `image` file. Extracts items via LLM.""" file = request.files.get("image") if file is None: return jsonify(error="missing 'image' file"), 400 image_bytes = file.read() receipt_id = str(uuid.uuid4()) image_path = f"{Config.UPLOAD_DIR}/{receipt_id}.jpg" with open(image_path, "wb") as f: f.write(image_bytes) try: extracted = llm_client.extract_receipt(image_bytes, mime_type=file.mimetype or "image/jpeg") except Exception as exc: # noqa: BLE001 - surfaced to the client as-is return jsonify(error=f"extraction failed: {exc}"), 502 items = extracted["items"] store_name = extracted["store_name"] # Fall back to today if the receipt's date wasn't readable - the user # can still override it in the review step. receipt_date = extracted["date"] or date_cls.today().isoformat() with get_conn() as conn: conn.execute( """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 @bp.get("/receipts/") def get_receipt(receipt_id: str): receipt = _load_receipt(receipt_id, g.nc_user_id) if receipt is None: return jsonify(error="not found"), 404 return jsonify(receipt) @bp.patch("/receipts//items") def update_items(receipt_id: str): """Body: {"items": [...]} — full replacement, for the review/edit step.""" body = request.get_json(force=True) or {} items = body.get("items") if not isinstance(items, list): return jsonify(error="'items' must be a list"), 400 with get_conn() as conn: cur = conn.execute( "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 return jsonify(id=receipt_id, items=items) @bp.patch("/receipts//meta") def update_meta(receipt_id: str): """Body: {"store_name"?: str, "date"?: "YYYY-MM-DD"} - user override for what the receipt reader guessed (or didn't find).""" body = request.get_json(force=True) or {} fields, values = [], [] if "store_name" in body: fields.append("store_name = ?") values.append(body["store_name"]) if "date" in body: if not re.match(r"^\d{4}-\d{2}-\d{2}$", str(body["date"] or "")): return jsonify(error="date must be YYYY-MM-DD"), 400 fields.append("receipt_date = ?") values.append(body["date"]) if not fields: return jsonify(error="nothing to update"), 400 values.extend([receipt_id, g.nc_user_id]) with get_conn() as conn: 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, g.nc_user_id) return jsonify(receipt) 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 = ? AND owner_nc_user_id = ?", (receipt_id, owner_nc_user_id), ).fetchone() if row is None: return None return { "id": row["id"], "items": json.loads(row["items_json"]), "store_name": row["store_name"], "date": row["receipt_date"], "status": row["status"], "created_at": row["created_at"], } # -------------------------------------------------------------- cospend @bp.get("/cospend/projects") def cospend_projects(): try: 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) @bp.get("/cospend/projects//members") def cospend_members(project_id: str): try: 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) # ------------------------------------------------------------------ groups @bp.post("/receipts//groups") def create_group(receipt_id: str): """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 {} project_id = body.get("cospend_project_id") payer_member_id = body.get("payer_member_id") member_ids = body.get("member_ids") or [] item_ids = body.get("item_ids") or [] if not project_id or not payer_member_id or not member_ids or not item_ids: return ( jsonify( error="cospend_project_id, payer_member_id, member_ids and item_ids are required" ), 400, ) group_id = str(uuid.uuid4()) with get_conn() as conn: conn.execute( """INSERT INTO groups (id, receipt_id, cospend_project_id, payer_member_id, member_ids_json, item_ids_json) VALUES (?, ?, ?, ?, ?, ?)""", ( group_id, receipt_id, project_id, payer_member_id, json.dumps(member_ids), json.dumps(item_ids), ), ) conn.execute("UPDATE receipts SET status = 'grouped' WHERE id = ?", (receipt_id,)) return jsonify(id=group_id), 201 @bp.get("/receipts//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,) ).fetchall() return jsonify([_group_dict(r) for r in rows]) def _group_dict(row) -> dict: return { "id": row["id"], "receipt_id": row["receipt_id"], "cospend_project_id": row["cospend_project_id"], "payer_member_id": row["payer_member_id"], "member_ids": json.loads(row["member_ids_json"]), "item_ids": json.loads(row["item_ids_json"]), "status": row["status"], "share_url": row["share_url"], "cospend_bill_id": row["cospend_bill_id"], "error": row["error"], } @bp.post("/receipts//groups//submit") def submit_group(receipt_id: str, group_id: str): """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 with get_conn() as conn: row = conn.execute( "SELECT * FROM groups WHERE id = ? AND receipt_id = ?", (group_id, receipt_id) ).fetchone() if row is None: return jsonify(error="group not found"), 404 group = _group_dict(row) items_by_id = {item["id"]: item for item in receipt["items"]} selected = [items_by_id[i] for i in group["item_ids"] if i in items_by_id] if not selected: return jsonify(error="no matching items on this receipt"), 400 total = round(sum(item["price"] for item in selected), 2) with open(_receipt_image_path(receipt_id, g.nc_user_id), "rb") as f: original_bytes = f.read() try: # 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_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 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") 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, payer_id=int(group["payer_member_id"]), ower_ids=[int(m) for m in group["member_ids"]], date=bill_date, comment=item_lines, ) except Exception as exc: # noqa: BLE001 with get_conn() as conn: conn.execute( "UPDATE groups SET status = 'failed', error = ? WHERE id = ?", (str(exc), group_id), ) return jsonify(error=f"submit failed: {exc}"), 502 with get_conn() as conn: conn.execute( """UPDATE groups SET status = 'submitted', share_url = ?, cospend_bill_id = ? WHERE id = ?""", (share_url, str(bill_id), group_id), ) return jsonify(share_url=share_url, bill_id=bill_id, amount=total) 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 = ? AND owner_nc_user_id = ?", (receipt_id, owner_nc_user_id), ).fetchone() return row["image_path"]