patx/projectpay
afterdark labs payments
Commit d1084f4 · patx · 2026-06-28T15:29:16-04:00
Comments
No comments yet.
Diff
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..475ac48
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+# Environment and secrets
+.env
+.env.*
+!.env.example
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+.Python
+.venv/
+venv/
+env/
+ENV/
+pip-wheel-metadata/
+*.egg-info/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+htmlcov/
+
+# Local databases and runtime files
+*.db
+*.sqlite
+*.sqlite3
+*.log
+
+# OS and editor files
+.DS_Store
+Thumbs.db
+.idea/
+.vscode/
+*.swp
+*.swo
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..21cfc62
--- /dev/null
+++ b/Procfile
@@ -0,0 +1 @@
+web: uvicorn app:app --host 0.0.0.0 --port $PORT
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..40dcaf9
--- /dev/null
+++ b/app.py
@@ -0,0 +1,853 @@
+import asyncio
+import hmac
+import json
+import os
+import secrets
+from datetime import datetime, timezone
+from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
+from typing import Any, Dict, List, Optional, Tuple
+
+import stripe
+from bson import ObjectId
+from bson.errors import InvalidId
+from markupsafe import Markup, escape
+from pymongo import MongoClient, ReturnDocument
+from pymongo.errors import DuplicateKeyError
+
+from micropie import App
+
+
+PAGE_SIZE = 20
+ADMIN_COOKIE = "invoice_admin"
+
+
+def load_dotenv(path: str = ".env") -> None:
+ if not os.path.exists(path):
+ return
+ with open(path, "r", encoding="utf-8") as env_file:
+ for raw_line in env_file:
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if key:
+ os.environ.setdefault(key, value)
+
+
+def now_utc() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def parse_money_to_cents(value: str) -> Optional[int]:
+ if value is None:
+ return None
+ value = value.strip().replace(",", "").replace("$", "")
+ if not value:
+ return None
+ try:
+ amount = Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+ except (InvalidOperation, ValueError):
+ return None
+ if amount < 0:
+ return None
+ return int(amount * 100)
+
+
+def cents_to_money(cents: int, currency: str = "USD") -> str:
+ amount = Decimal(int(cents or 0)) / Decimal(100)
+ if currency.upper() == "USD":
+ return f"${amount:,.2f}"
+ return f"{currency.upper()} {amount:,.2f}"
+
+
+def format_star_emphasis(value: Any) -> Markup:
+ text = "" if value is None else str(value)
+ rendered = Markup("")
+ position = 0
+
+ while True:
+ start = text.find("**", position)
+ if start == -1:
+ rendered += escape(text[position:])
+ break
+
+ end = text.find("**", start + 2)
+ if end == -1:
+ rendered += escape(text[position:])
+ break
+
+ rendered += escape(text[position:start])
+ rendered += Markup('<strong class="star-emphasis">')
+ rendered += escape(text[start + 2 : end])
+ rendered += Markup("</strong>")
+ position = end + 2
+
+ return rendered
+
+
+def object_id(value: str) -> Optional[ObjectId]:
+ try:
+ return ObjectId(value)
+ except (InvalidId, TypeError):
+ return None
+
+
+def as_plain_dict(value: Any) -> Dict[str, Any]:
+ if isinstance(value, dict):
+ return value
+ if hasattr(value, "to_dict_recursive"):
+ return value.to_dict_recursive()
+ if hasattr(value, "model_dump"):
+ return value.model_dump()
+ if hasattr(value, "to_dict"):
+ return value.to_dict()
+ return {}
+
+
+class InvoiceApp(App):
+ def __init__(self) -> None:
+ super().__init__()
+ self.mongo_uri = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
+ self.mongo_db_name = os.getenv("MONGODB_DB", "invoice_maker")
+ timeout_ms = int(os.getenv("MONGODB_SERVER_SELECTION_TIMEOUT_MS", "5000"))
+ self.client = MongoClient(
+ self.mongo_uri,
+ serverSelectionTimeoutMS=timeout_ms,
+ connect=False,
+ )
+ self._indexes_ready = False
+
+ self.admin_password = os.getenv("ADMIN_PASSWORD", "admin")
+ self.admin_cookie_secret = (
+ os.getenv("ADMIN_COOKIE_SECRET")
+ or os.getenv("APP_SECRET")
+ or self.admin_password
+ or "invoice-maker-dev"
+ )
+ self.app_base_url = os.getenv("APP_BASE_URL", "").rstrip("/")
+ self.currency = os.getenv("STRIPE_CURRENCY", "usd").upper()
+ self.stripe_secret_key = os.getenv("STRIPE_SECRET_KEY", "")
+ self.stripe_webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET", "")
+ self.stripe_product_id = os.getenv("STRIPE_PRODUCT_ID", "")
+ self.stripe_minimum_amount_cents = int(
+ os.getenv("STRIPE_MINIMUM_AMOUNT_CENTS", "100") or "100"
+ )
+ if self.stripe_secret_key:
+ stripe.api_key = self.stripe_secret_key
+
+ if self.env is not None:
+ self.env.filters["money"] = cents_to_money
+ self.env.filters["star_emphasis"] = format_star_emphasis
+ self.env.globals["money"] = cents_to_money
+
+ @property
+ def db(self):
+ self._ensure_indexes()
+ return self.client[self.mongo_db_name]
+
+ def _ensure_indexes(self) -> None:
+ if self._indexes_ready:
+ return
+ db = self.client[self.mongo_db_name]
+ db.projects.create_index("project_number", unique=True)
+ db.projects.create_index("share_token", unique=True)
+ db.projects.create_index("customer_name")
+ db.projects.create_index("created_at")
+ db.payments.create_index("project_id")
+ db.payments.create_index("webhook_id", unique=True, sparse=True)
+ db.payments.create_index("stripe_event_id", unique=True, sparse=True)
+ db.payments.create_index("stripe_payment_intent_id", unique=True, sparse=True)
+ db.payments.create_index("stripe_checkout_session_id", unique=True, sparse=True)
+ db.checkout_sessions.create_index("session_id", unique=True, sparse=True)
+ db.checkout_sessions.create_index("project_id")
+ db.webhook_events.create_index("webhook_id", unique=True, sparse=True)
+ self._indexes_ready = True
+
+ async def __call__(self, scope, receive, send) -> None:
+ if (
+ scope.get("type") == "http"
+ and scope.get("method") == "POST"
+ and scope.get("path") == "/webhook/stripe"
+ ):
+ await self._webhook_asgi(scope, receive, send)
+ return
+ await super().__call__(scope, receive, send)
+
+ async def _read_raw_body(self, receive) -> bytes:
+ chunks: List[bytes] = []
+ while True:
+ message = await receive()
+ if message["type"] == "http.disconnect":
+ break
+ if body := message.get("body", b""):
+ chunks.append(body)
+ if not message.get("more_body"):
+ break
+ return b"".join(chunks)
+
+ async def _webhook_asgi(self, scope, receive, send) -> None:
+ raw_body = await self._read_raw_body(receive)
+ headers = {
+ key.decode("utf-8", "replace").lower(): value.decode("utf-8", "replace")
+ for key, value in scope.get("headers", [])
+ }
+ status, body = await asyncio.to_thread(
+ self._handle_stripe_webhook, raw_body, headers
+ )
+ await self._send_response(
+ send,
+ status,
+ json.dumps(body),
+ [("Content-Type", "application/json")],
+ )
+
+ def _handle_stripe_webhook(
+ self, raw_body: bytes, headers: Dict[str, str]
+ ) -> Tuple[int, Dict[str, Any]]:
+ if not self.stripe_webhook_secret:
+ return 500, {"error": "Stripe webhook verification is not configured"}
+
+ try:
+ event = stripe.Webhook.construct_event(
+ raw_body,
+ headers.get("stripe-signature", ""),
+ self.stripe_webhook_secret,
+ )
+ except ValueError:
+ return 400, {"error": "Invalid JSON payload"}
+ except stripe.error.SignatureVerificationError:
+ return 401, {"error": "Invalid webhook signature"}
+
+ payload = as_plain_dict(event)
+ webhook_id = str(payload.get("id") or "")
+ if not webhook_id:
+ return 400, {"error": "Webhook event id is missing"}
+ event_type = payload.get("type", "")
+ created_at = now_utc()
+
+ try:
+ result = self.db.webhook_events.update_one(
+ {"webhook_id": webhook_id},
+ {
+ "$setOnInsert": {
+ "webhook_id": webhook_id,
+ "provider": "stripe",
+ "event_type": event_type,
+ "payload": payload,
+ "processed": False,
+ "created_at": created_at,
+ }
+ },
+ upsert=True,
+ )
+ except DuplicateKeyError:
+ return 200, {"received": True, "duplicate": True}
+
+ if result.upserted_id is None:
+ return 200, {"received": True, "duplicate": True}
+
+ try:
+ outcome = self._process_stripe_webhook(payload, webhook_id)
+ self.db.webhook_events.update_one(
+ {"webhook_id": webhook_id},
+ {
+ "$set": {
+ "processed": True,
+ "processed_at": now_utc(),
+ "outcome": outcome,
+ }
+ },
+ )
+ return 200, {"received": True, **outcome}
+ except Exception as exc:
+ self.db.webhook_events.update_one(
+ {"webhook_id": webhook_id},
+ {
+ "$set": {
+ "processed": False,
+ "processed_at": now_utc(),
+ "error": str(exc),
+ }
+ },
+ )
+ return 500, {"error": "Webhook processing failed"}
+
+ def _process_stripe_webhook(
+ self, payload: Dict[str, Any], webhook_id: str
+ ) -> Dict[str, Any]:
+ event_type = payload.get("type", "")
+ data = payload.get("data") or {}
+ session = as_plain_dict(data.get("object"))
+
+ if event_type != "checkout.session.completed":
+ return {"ignored": True, "reason": "not a completed checkout session"}
+
+ if str(session.get("payment_status") or "").lower() != "paid":
+ return {"ignored": True, "reason": "checkout session is not paid"}
+
+ project = self._project_from_stripe_session(session)
+ if project is None:
+ return {"ignored": True, "reason": "project metadata not found"}
+
+ try:
+ amount_cents = int(session.get("amount_total") or 0)
+ except (TypeError, ValueError):
+ amount_cents = 0
+ if amount_cents <= 0:
+ return {"ignored": True, "reason": "payment amount not found"}
+
+ checkout_session_id = str(session.get("id") or "")
+ payment_intent_id = str(session.get("payment_intent") or "")
+ currency = str(session.get("currency") or self.currency).upper()
+
+ payment_doc = {
+ "project_id": project["_id"],
+ "provider": "stripe",
+ "webhook_id": webhook_id,
+ "stripe_event_id": webhook_id,
+ "amount_cents": amount_cents,
+ "currency": currency,
+ "status": "succeeded",
+ "event_type": payload.get("type", ""),
+ "raw_event": payload,
+ "created_at": now_utc(),
+ }
+ if checkout_session_id:
+ payment_doc["stripe_checkout_session_id"] = checkout_session_id
+ if payment_intent_id:
+ payment_doc["stripe_payment_intent_id"] = payment_intent_id
+
+ query: Dict[str, Any] = {"stripe_event_id": webhook_id}
+ if payment_intent_id:
+ query = {"stripe_payment_intent_id": payment_intent_id}
+ elif checkout_session_id:
+ query = {"stripe_checkout_session_id": checkout_session_id}
+
+ try:
+ insert_result = self.db.payments.update_one(
+ query,
+ {"$setOnInsert": payment_doc},
+ upsert=True,
+ )
+ except DuplicateKeyError:
+ return {"received": True, "duplicate": True}
+
+ if insert_result.upserted_id is None:
+ return {"received": True, "duplicate": True}
+
+ if checkout_session_id:
+ self.db.checkout_sessions.update_one(
+ {"session_id": checkout_session_id},
+ {
+ "$set": {
+ "status": "paid",
+ "amount_cents": amount_cents,
+ "stripe_payment_intent_id": payment_intent_id,
+ "paid_at": now_utc(),
+ }
+ },
+ )
+
+ self._refresh_project_status(project["_id"])
+ return {"received": True, "payment_recorded": True}
+
+ def _project_from_stripe_session(
+ self, session: Dict[str, Any]
+ ) -> Optional[Dict[str, Any]]:
+ metadata = as_plain_dict(session.get("metadata"))
+ project_id = (
+ metadata.get("project_id")
+ or metadata.get("projectId")
+ or session.get("client_reference_id")
+ )
+ share_token = metadata.get("share_token") or metadata.get("shareToken")
+
+ if project_id:
+ oid = object_id(str(project_id))
+ if oid is not None:
+ project = self.db.projects.find_one({"_id": oid})
+ if project:
+ return project
+
+ if share_token:
+ project = self.db.projects.find_one({"share_token": str(share_token)})
+ if project:
+ return project
+
+ checkout_session_id = session.get("id")
+ if checkout_session_id:
+ session = self.db.checkout_sessions.find_one(
+ {"session_id": str(checkout_session_id)}
+ )
+ if session:
+ return self.db.projects.find_one({"_id": session["project_id"]})
+ return None
+
+ def _stripe_metadata(self, project: Dict[str, Any]) -> Dict[str, str]:
+ return {
+ "project_id": str(project["_id"]),
+ "project_number": str(project["project_number"]),
+ "share_token": str(project["share_token"]),
+ }
+
+ def _create_checkout_session(self, project: Dict[str, Any]) -> Dict[str, Any]:
+ if not self.stripe_secret_key:
+ raise RuntimeError("STRIPE_SECRET_KEY is not configured")
+ if not self.stripe_product_id:
+ raise RuntimeError("STRIPE_PRODUCT_ID is not configured")
+
+ stripe.api_key = self.stripe_secret_key
+ summary = self._summarize_project(project)
+ balance_cents = int(summary["balance_cents"])
+ if balance_cents <= 0:
+ raise RuntimeError("Project is already paid in full")
+
+ public_url = self._public_project_url(project)
+ metadata = self._stripe_metadata(project)
+ minimum_amount_cents = max(1, min(self.stripe_minimum_amount_cents, balance_cents))
+
+ price = stripe.Price.create(
+ currency=self.currency.lower(),
+ product=self.stripe_product_id,
+ custom_unit_amount={
+ "enabled": True,
+ "minimum": minimum_amount_cents,
+ "maximum": balance_cents,
+ "preset": balance_cents,
+ },
+ metadata=metadata,
+ )
+ price_dict = as_plain_dict(price)
+ price_id = price_dict.get("id") or getattr(price, "id", None)
+ if not price_id:
+ raise RuntimeError("Stripe price did not include an id")
+
+ payload: Dict[str, Any] = {
+ "mode": "payment",
+ "line_items": [{"price": price_id, "quantity": 1}],
+ "success_url": f"{public_url}?checkout=return",
+ "cancel_url": f"{public_url}?checkout=cancel",
+ "client_reference_id": str(project["_id"]),
+ "metadata": metadata,
+ "payment_intent_data": {"metadata": metadata},
+ }
+ if project.get("customer_email"):
+ payload["customer_email"] = project["customer_email"]
+
+ response = stripe.checkout.Session.create(**payload)
+ response_dict = as_plain_dict(response)
+ checkout_url = response_dict.get("url") or getattr(response, "url", None)
+ session_id = response_dict.get("id") or getattr(response, "id", None)
+ if not checkout_url:
+ raise RuntimeError("Stripe checkout session did not include a url")
+ return {
+ "checkout_url": checkout_url,
+ "session_id": session_id,
+ "stripe_price_id": price_id,
+ "balance_cents": balance_cents,
+ }
+
+ def _signed_admin_cookie(self) -> str:
+ signature = hmac.new(
+ self.admin_cookie_secret.encode("utf-8"),
+ b"admin",
+ "sha256",
+ ).hexdigest()
+ return f"admin.{signature}"
+
+ def _cookie_header(self, max_age: int) -> str:
+ value = self._signed_admin_cookie() if max_age > 0 else ""
+ return (
+ f"{ADMIN_COOKIE}={value}; Path=/; Max-Age={max_age}; "
+ "SameSite=Lax; HttpOnly"
+ )
+
+ def _is_admin(self) -> bool:
+ cookie_header = self.request.headers.get("cookie", "")
+ cookies: Dict[str, str] = {}
+ for part in cookie_header.split(";"):
+ if "=" in part:
+ key, value = part.strip().split("=", 1)
+ cookies[key] = value
+ actual = cookies.get(ADMIN_COOKIE, "")
+ return hmac.compare_digest(actual, self._signed_admin_cookie())
+
+ def _require_admin(self):
+ if not self._is_admin():
+ return self._redirect("/login")
+ return None
+
+ def _base_url(self) -> str:
+ if self.app_base_url:
+ return self.app_base_url
+ headers = self.request.headers
+ proto = headers.get("x-forwarded-proto") or "http"
+ host = headers.get("x-forwarded-host") or headers.get("host") or "127.0.0.1:8000"
+ return f"{proto}://{host}".rstrip("/")
+
+ def _public_project_url(self, project: Dict[str, Any]) -> str:
+ return f"{self._base_url()}/p/{project['share_token']}"
+
+ def _next_project_number(self) -> str:
+ counter = self.db.counters.find_one_and_update(
+ {"_id": "project_number"},
+ {"$inc": {"value": 1}},
+ upsert=True,
+ return_document=ReturnDocument.AFTER,
+ )
+ return f"PRJ-{now_utc().year}-{int(counter['value']):04d}"
+
+ def _project_total(self, project: Dict[str, Any]) -> int:
+ return sum(int(item.get("amount_cents", 0)) for item in project.get("line_items", []))
+
+ def _project_paid(self, project_id: ObjectId) -> int:
+ pipeline = [
+ {"$match": {"project_id": project_id, "status": "succeeded"}},
+ {"$group": {"_id": "$project_id", "total": {"$sum": "$amount_cents"}}},
+ ]
+ rows = list(self.db.payments.aggregate(pipeline))
+ return int(rows[0]["total"]) if rows else 0
+
+ def _summarize_project(self, project: Dict[str, Any]) -> Dict[str, Any]:
+ total = self._project_total(project)
+ paid = self._project_paid(project["_id"])
+ balance = max(total - paid, 0)
+ status = "paid" if total > 0 and balance == 0 else project.get("status", "open")
+ return {
+ **project,
+ "id": str(project["_id"]),
+ "scope_terms": project.get("scope_terms", ""),
+ "total_cents": total,
+ "paid_cents": paid,
+ "balance_cents": balance,
+ "status": status,
+ "share_url": self._public_project_url(project),
+ }
+
+ def _find_projects(self, q: str, page: int) -> Tuple[List[Dict[str, Any]], bool]:
+ query: Dict[str, Any] = {}
+ q = (q or "").strip()
+ if q:
+ clauses: List[Dict[str, Any]] = [
+ {"project_number": {"$regex": q, "$options": "i"}},
+ {"customer_name": {"$regex": q, "$options": "i"}},
+ ]
+ oid = object_id(q)
+ if oid is not None:
+ clauses.append({"_id": oid})
+ query = {"$or": clauses}
+
+ skip = max(page - 1, 0) * PAGE_SIZE
+ docs = list(
+ self.db.projects.find(query)
+ .sort("created_at", -1)
+ .skip(skip)
+ .limit(PAGE_SIZE + 1)
+ )
+ has_more = len(docs) > PAGE_SIZE
+ return [self._summarize_project(doc) for doc in docs[:PAGE_SIZE]], has_more
+
+ def _parse_line_items(self) -> Tuple[List[Dict[str, Any]], List[str]]:
+ names = self.request.body_params.get("item_name", [])
+ prices = self.request.body_params.get("item_price", [])
+ max_len = max(len(names), len(prices))
+ items: List[Dict[str, Any]] = []
+ errors: List[str] = []
+
+ for index in range(max_len):
+ name = names[index].strip() if index < len(names) else ""
+ price = prices[index].strip() if index < len(prices) else ""
+ if not name and not price:
+ continue
+ amount_cents = parse_money_to_cents(price)
+ if not name:
+ errors.append("Each line item needs a name.")
+ if amount_cents is None or amount_cents <= 0:
+ errors.append("Each line item needs a price greater than 0.")
+ if name or price:
+ items.append(
+ {
+ "name": name,
+ "amount_cents": amount_cents or 0,
+ "price_input": price,
+ }
+ )
+
+ if not items:
+ errors.append("Add at least one line item.")
+ return items, errors
+
+ def _project_from_form(self, existing: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ items, errors = self._parse_line_items()
+ customer_name = (self.request.form("customer_name", "") or "").strip()
+ customer_email = (self.request.form("customer_email", "") or "").strip()
+ scope_terms = (self.request.form("scope_terms", "") or "").strip()
+ if not customer_name:
+ errors.append("Customer name is required.")
+
+ if not items:
+ items = [{"name": "", "amount_cents": 0, "price_input": ""}]
+ clean_items = [
+ {"name": item["name"], "amount_cents": item["amount_cents"]}
+ for item in items
+ if item.get("name") and item.get("amount_cents", 0) > 0
+ ]
+ project_items = items if errors else clean_items
+
+ project = {
+ "customer_name": customer_name,
+ "customer_email": customer_email,
+ "scope_terms": scope_terms,
+ "line_items": project_items,
+ "status": existing.get("status", "open") if existing else "open",
+ }
+ if existing:
+ project.update(
+ {
+ "_id": existing["_id"],
+ "id": str(existing["_id"]),
+ "project_number": existing["project_number"],
+ "share_token": existing["share_token"],
+ }
+ )
+ project["errors"] = sorted(set(errors))
+ return project
+
+ def _refresh_project_status(self, project_id: ObjectId) -> None:
+ project = self.db.projects.find_one({"_id": project_id})
+ if not project:
+ return
+ total = self._project_total(project)
+ paid = self._project_paid(project_id)
+ status = "paid" if total > 0 and paid >= total else "open"
+ self.db.projects.update_one(
+ {"_id": project_id},
+ {"$set": {"status": status, "updated_at": now_utc()}},
+ )
+
+ async def _render(self, template: str, **kwargs: Any) -> str:
+ kwargs.setdefault("admin", self._is_admin())
+ kwargs.setdefault("currency", self.currency)
+ return await self._render_template(template, **kwargs)
+
+ async def index(self) -> Any:
+ if redirect := self._require_admin():
+ return redirect
+ page = int(self.request.query("page", "1") or "1")
+ q = self.request.query("q", "") or ""
+ projects, has_more = self._find_projects(q, page)
+ return await self._render(
+ "index.html",
+ projects=projects,
+ q=q,
+ page=page,
+ has_more=has_more,
+ )
+
+ async def projects_page(self) -> Any:
+ if redirect := self._require_admin():
+ return redirect
+ page = int(self.request.query("page", "1") or "1")
+ q = self.request.query("q", "") or ""
+ projects, has_more = self._find_projects(q, page)
+ return await self._render(
+ "_project_rows.html",
+ projects=projects,
+ page=page,
+ has_more=has_more,
+ )
+
+ async def login(self) -> Any:
+ if self.request.method == "POST":
+ password = self.request.form("password", "") or ""
+ if hmac.compare_digest(password, self.admin_password):
+ return self._redirect(
+ "/",
+ [("Set-Cookie", self._cookie_header(30 * 24 * 60 * 60))],
+ )
+ return await self._render("login.html", error="Invalid password.")
+
+ if self._is_admin():
+ return self._redirect("/")
+ return await self._render("login.html")
+
+ def logout(self) -> Any:
+ return self._redirect("/login", [("Set-Cookie", self._cookie_header(0))])
+
+ async def new(self) -> Any:
+ if redirect := self._require_admin():
+ return redirect
+ project = {
+ "customer_name": "",
+ "customer_email": "",
+ "scope_terms": "",
+ "line_items": [{"name": "", "amount_cents": 0}],
+ "errors": [],
+ }
+ return await self._render("form.html", project=project, mode="new")
+
+ async def create(self) -> Any:
+ if redirect := self._require_admin():
+ return redirect
+ if self.request.method != "POST":
+ return self._redirect("/new")
+
+ project = self._project_from_form()
+ if project["errors"]:
+ return await self._render("form.html", project=project, mode="new")
+
+ now = now_utc()
+ project_doc = {
+ "project_number": self._next_project_number(),
+ "share_token": secrets.token_urlsafe(24),
+ "customer_name": project["customer_name"],
+ "customer_email": project["customer_email"],
+ "scope_terms": project["scope_terms"],
+ "line_items": project["line_items"],
+ "status": "open",
+ "created_at": now,
+ "updated_at": now,
+ }
+ result = self.db.projects.insert_one(project_doc)
+ return self._redirect(f"/project/{result.inserted_id}")
+
+ async def edit(self, project_id: str) -> Any:
+ if redirect := self._require_admin():
+ return redirect
+ oid = object_id(project_id)
+ project = self.db.projects.find_one({"_id": oid}) if oid else None
+ if not project:
+ return 404, "Project not found"
+
+ if self.request.method == "POST":
+ updated = self._project_from_form(project)
+ if updated["errors"]:
+ return await self._render("form.html", project=updated, mode="edit")
+ self.db.projects.update_one(
+ {"_id": project["_id"]},
+ {
+ "$set": {
+ "customer_name": updated["customer_name"],
+ "customer_email": updated["customer_email"],
+ "scope_terms": updated["scope_terms"],
+ "line_items": updated["line_items"],
+ "updated_at": now_utc(),
+ }
+ },
+ )
+ self._refresh_project_status(project["_id"])
+ return self._redirect(f"/project/{project['_id']}")
+
+ project = self._summarize_project(project)
+ project["errors"] = []
+ return await self._render("form.html", project=project, mode="edit")
+
+ async def project(self, project_id: str) -> Any:
+ if redirect := self._require_admin():
+ return redirect
+ oid = object_id(project_id)
+ project = self.db.projects.find_one({"_id": oid}) if oid else None
+ if not project:
+ return 404, "Project not found"
+ summary = self._summarize_project(project)
+ payments = list(
+ self.db.payments.find({"project_id": project["_id"]}).sort("created_at", -1)
+ )
+ for payment in payments:
+ payment["id"] = str(payment["_id"])
+ return await self._render(
+ "detail.html",
+ project=summary,
+ payments=payments,
+ )
+
+ async def delete(self, project_id: str) -> Any:
+ if redirect := self._require_admin():
+ return redirect
+ oid = object_id(project_id)
+ project = self.db.projects.find_one({"_id": oid}) if oid else None
+ if not project:
+ return 404, "Project not found"
+ if self.request.method != "POST":
+ return self._redirect(f"/project/{project_id}")
+
+ payment_webhook_ids = [
+ payment["webhook_id"]
+ for payment in self.db.payments.find(
+ {"project_id": project["_id"], "webhook_id": {"$exists": True}},
+ {"webhook_id": 1},
+ )
+ if payment.get("webhook_id")
+ ]
+ if payment_webhook_ids:
+ self.db.webhook_events.delete_many({"webhook_id": {"$in": payment_webhook_ids}})
+ self.db.checkout_sessions.delete_many({"project_id": project["_id"]})
+ self.db.payments.delete_many({"project_id": project["_id"]})
+ self.db.projects.delete_one({"_id": project["_id"]})
+ return self._redirect("/")
+
+ async def p(self, share_token: str) -> Any:
+ project = self.db.projects.find_one({"share_token": share_token})
+ if not project:
+ return 404, "Project not found"
+ summary = self._summarize_project(project)
+ checkout_state = self.request.query("checkout", "") or ""
+ return await self._render(
+ "public_project.html",
+ project=summary,
+ checkout_state=checkout_state,
+ )
+
+ async def pay(self, share_token: str) -> Any:
+ project = self.db.projects.find_one({"share_token": share_token})
+ if not project:
+ return 404, "Project not found"
+ summary = self._summarize_project(project)
+
+ if self.request.method != "POST":
+ return self._redirect(f"/p/{share_token}")
+
+ if summary["balance_cents"] <= 0:
+ return self._redirect(f"/p/{share_token}")
+
+ try:
+ checkout = await asyncio.to_thread(self._create_checkout_session, project)
+ except Exception as exc:
+ return await self._render(
+ "public_project.html",
+ project=summary,
+ error=str(exc),
+ checkout_state="",
+ )
+
+ checkout_doc = {
+ "project_id": project["_id"],
+ "provider": "stripe",
+ "currency": self.currency,
+ "status": "created",
+ "created_at": now_utc(),
+ }
+ if checkout.get("session_id"):
+ checkout_doc["session_id"] = checkout["session_id"]
+ if checkout.get("stripe_price_id"):
+ checkout_doc["stripe_price_id"] = checkout["stripe_price_id"]
+ if checkout.get("balance_cents"):
+ checkout_doc["balance_cents_at_creation"] = checkout["balance_cents"]
+ self.db.checkout_sessions.insert_one(checkout_doc)
+ return self._redirect(checkout["checkout_url"])
+
+
+load_dotenv()
+app = InvoiceApp()
+
+
+if __name__ == "__main__":
+ import uvicorn
+
+ uvicorn.run(
+ app,
+ host=os.getenv("HOST", "127.0.0.1"),
+ port=int(os.getenv("PORT", "8000")),
+ )
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..e3c5a0a
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+micropie[all]
+pymongo
+stripe
diff --git a/templates/_project_rows.html b/templates/_project_rows.html
new file mode 100644
index 0000000..69a1685
--- /dev/null
+++ b/templates/_project_rows.html
@@ -0,0 +1,24 @@
+{% for project in projects %}
+ <a class="project-card" href="/project/{{ project.id }}">
+ <div class="project-card-main">
+ <div>
+ <h2><span class="status {{ project.status }}">{{ project.status }}</span> {{ project.customer_name }}</h2>
+ <div class="project-card-id">{{ project.project_number }}</div>
+ </div>
+ <div class="project-card-amount">
+ {% if project.status == "paid" %}
+ <span>Total</span>
+ <strong>{{ project.total_cents|money(currency) }}</strong>
+ {% else %}
+ <span>Balance</span>
+ <strong>{{ project.balance_cents|money(currency) }}</strong>
+ {% endif %}
+ </div>
+ </div>
+ </a>
+{% else %}
+ {% if page == 1 %}
+ <div class="empty">No projects found.</div>
+ {% endif %}
+{% endfor %}
+<span data-next-page="{{ page + 1 }}" data-has-more="{{ '1' if has_more else '0' }}" hidden></span>
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..99ef978
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,336 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{% block title %}Project Payments{% endblock %}</title>
+ <style>
+ * { box-sizing: border-box; }
+ [hidden] { display: none !important; }
+ body {
+ margin: 0;
+ background: #f7f7f7;
+ color: #1f2933;
+ font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ }
+ a { color: inherit; }
+ h1, h2, p { margin-top: 0; }
+ h1 { font-size: 26px; margin-bottom: 4px; }
+ h2 { font-size: 18px; margin: 0 0 12px; }
+ table { width: 100%; border-collapse: collapse; }
+ th, td { border-bottom: 0px solid #ddd; padding: 20px 0; text-align: left; vertical-align: top; }
+ th { color: #697586; font-size: 13px; }
+ input, textarea {
+ width: 100%;
+ border: 1px solid #cfd6dd;
+ border-radius: 6px;
+ padding: 10px;
+ font: inherit;
+ }
+ label { display: block; font-weight: 650; margin-bottom: 6px; }
+ button, .button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 38px;
+ border: 1px solid #176b5b;
+ border-radius: 6px;
+ background: #176b5b;
+ color: #fff;
+ padding: 8px 12px;
+ font: inherit;
+ font-weight: 650;
+ text-decoration: none;
+ cursor: pointer;
+ }
+ .secondary { background: #fff; border-color: #cfd6dd; color: #1f2933; }
+ .danger { background: #fff; border-color: #efb5ad; color: #b42318; }
+ .topbar { background: #fff; border-bottom: 1px solid #ddd; }
+ .topbar-inner, .page { max-width: 1080px; margin: 0 auto; padding: 16px 20px; }
+ .topbar-inner, .header-row, .nav, .searchbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+ .topbar-inner, .header-row { justify-content: space-between; }
+ .brand { font-weight: 750; text-decoration: none; }
+ .title-line {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+ .back-link {
+ color: #697586;
+ font-size: 24px;
+ line-height: 1;
+ text-decoration: none;
+ }
+ .page { padding-top: 28px; padding-bottom: 48px; }
+ .narrow { max-width: 680px; }
+ .section {
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 18px;
+ margin-bottom: 16px;
+ }
+ .muted, .footer-note { color: #697586; }
+ .field { margin-bottom: 14px; }
+ .prewrap { white-space: pre-wrap; }
+ .star-emphasis { font-size: calc(1em + 1px); font-weight: 700; }
+ .inline { display: inline; }
+ .nav .inline { display: flex; }
+ .grid-2, .stats {
+ display: grid;
+ gap: 12px;
+ }
+ .grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 16px 0; }
+ .stat { border: 1px solid #ddd; border-radius: 8px; padding: 12px; }
+ .stat span { display: block; color: #697586; font-size: 13px; }
+ .stat strong { font-size: 19px; }
+ .alert { border: 1px solid #ddd; border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; }
+ .alert.error { border-color: #efb5ad; color: #b42318; background: #fff0ee; }
+ .alert.info { background: #fff8e8; }
+ .alert.ok { background: #eaf7ef; }
+ .status {
+ border-radius: 999px;
+ background: #edf5ff;
+ color: #164c7d;
+ padding: 3px 9px;
+ font-size: 13px;
+ font-weight: 700;
+ text-transform: capitalize;
+ }
+ .status.paid { background: #eaf7ef; color: #166534; }
+ .project-list {
+ display: grid;
+ gap: 12px;
+ }
+ .project-card {
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ color: inherit;
+ text-decoration: none;
+ padding: 14px;
+ }
+ .project-card-main {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: start;
+ }
+ .project-card h2 { margin: 0 0 4px; }
+ .project-card-id { color: #697586; font-size: 13px; }
+ .project-card-amount { text-align: right; }
+ .project-card-amount span {
+ display: block;
+ color: #697586;
+ font-size: 13px;
+ }
+ .project-card-footer {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 12px;
+ }
+ .line-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: end;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 14px;
+ margin-bottom: 12px;
+ background: #fafafa;
+ }
+ .line-item-text { grid-column: 1 / -1; }
+ .line-item-price { max-width: 180px; }
+ .line-item [data-remove-line] {
+ align-self: end;
+ justify-self: end;
+ }
+ .searchbar { margin-bottom: 14px; }
+ .searchbar input { flex: 1; }
+ .search-field {
+ position: relative;
+ flex: 1;
+ }
+ .search-field input { padding-right: 40px; }
+ .search-clear {
+ position: absolute;
+ top: 50%;
+ right: 8px;
+ min-height: 28px;
+ width: 28px;
+ border: 0;
+ background: transparent;
+ color: #697586;
+ padding: 0;
+ transform: translateY(-50%);
+ }
+ .search-clear:hover { background: transparent; color: #1f2933; }
+ .copy-field { position: relative; }
+ .copy-field input {
+ cursor: pointer;
+ padding-right: 76px;
+ }
+ .copy-indicator {
+ position: absolute;
+ top: 50%;
+ right: 12px;
+ width: 22px;
+ height: 22px;
+ color: #697586;
+ pointer-events: none;
+ transform: translateY(-50%);
+ }
+ .copy-indicator::before,
+ .copy-indicator::after {
+ content: "";
+ position: absolute;
+ width: 13px;
+ height: 13px;
+ border: 1.5px solid currentColor;
+ border-radius: 2px;
+ background: #fff;
+ }
+ .copy-indicator::before { left: 2px; top: 6px; }
+ .copy-indicator::after { left: 7px; top: 1px; }
+ .copy-indicator.copied {
+ width: auto;
+ height: auto;
+ color: #176b5b;
+ font-size: 13px;
+ font-weight: 700;
+ }
+ .copy-indicator.copied::before {
+ content: "Copied!";
+ position: static;
+ width: auto;
+ height: auto;
+ border: 0;
+ background: transparent;
+ }
+ .copy-indicator.copied::after { display: none; }
+ .has-sticky-pay { padding-bottom: 100px; }
+ .sticky-pay {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 10;
+ border-top: 1px solid #ddd;
+ background: rgba(247, 247, 247, .96);
+ padding: 12px 20px;
+ }
+ .sticky-pay-inner {
+ max-width: 1080px;
+ margin: 0 auto;
+ display: flex;
+ justify-content: flex-end;
+ }
+ .sticky-pay button {
+ width: 100%;
+ }
+ .has-sticky-actions { padding-bottom: 104px; }
+ .sticky-actions {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 10;
+ border-top: 1px solid #ddd;
+ background: rgba(247, 247, 247, .96);
+ padding: 12px 20px;
+ }
+ .sticky-actions-inner {
+ max-width: 1080px;
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+ .sticky-actions .button,
+ .sticky-actions button,
+ .sticky-actions .inline,
+ .add-line-button {
+ width: 100%;
+ }
+ .full-width-form,
+ .full-width-form button {
+ width: 100%;
+ }
+ .has-sticky-index {
+ padding-top: 78px;
+ padding-bottom: 92px;
+ }
+ .sticky-index-top,
+ .sticky-index-search {
+ position: fixed;
+ left: 0;
+ right: 0;
+ z-index: 10;
+ background: rgba(247, 247, 247, .96);
+ padding: 12px 20px;
+ }
+ .sticky-index-top {
+ top: 0;
+ border-bottom: 1px solid #ddd;
+ }
+ .sticky-index-search {
+ bottom: 0;
+ border-top: 1px solid #ddd;
+ }
+ .sticky-index-actions,
+ .sticky-index-search-inner {
+ max-width: 1080px;
+ margin: 0 auto;
+ }
+ .sticky-index-actions {
+ display: grid;
+ grid-template-columns: 3fr 1fr;
+ gap: 10px;
+ }
+ .sticky-index-actions .button {
+ width: 100%;
+ }
+ .sticky-index-actions .logout-button {
+ background: #fff;
+ border-color: #efb5ad;
+ color: #b42318;
+ }
+ .sticky-index-search .searchbar {
+ margin: 0;
+ }
+ .empty { color: #697586; padding: 18px 0; }
+ .amount { text-align: right; }
+ @media (max-width: 760px) {
+ .topbar-inner, .header-row, .nav, .searchbar { align-items: stretch; flex-direction: column; }
+ .nav .inline, .nav .inline button, .nav .button { width: 100%; }
+ .grid-2, .stats, .line-item, .project-card-main { grid-template-columns: 1fr; }
+ .line-item-text { grid-column: auto; }
+ .line-item-price { max-width: none; }
+ .project-card-amount { text-align: left; }
+ .project-card-footer { justify-content: flex-start; }
+ .sticky-pay button { width: 100%; }
+ }
+ @media print {
+ .sticky-pay {
+ display: none !important;
+ }
+
+ body.has-sticky-pay {
+ padding-bottom: 0 !important;
+ }
+ }
+ </style>
+ {% block head %}{% endblock %}
+ </head>
+ <body>
+ <main class="page {% block page_class %}{% endblock %}">
+ {% block content %}{% endblock %}
+ </main>
+ </body>
+</html>
diff --git a/templates/detail.html b/templates/detail.html
new file mode 100644
index 0000000..3dcc01d
--- /dev/null
+++ b/templates/detail.html
@@ -0,0 +1,118 @@
+{% extends "base.html" %}
+{% block title %}{{ project.project_number }}{% endblock %}
+{% block page_class %}has-sticky-actions{% endblock %}
+{% block content %}
+ <div class="header-row">
+ <div>
+ <div class="title-line">
+ <a class="back-link" href="/" aria-label="Back to projects">←</a>
+ <h1>Project for {{ project.customer_name }}</h1>
+ </div>
+ <p class="muted">{{ project.project_number }}</p>
+ </div>
+ </div>
+
+ <section class="section" style="margin-top:25px;">
+ <h2>Payment Link</h2>
+ <div class="copy-field">
+ <input id="share-url" value="{{ project.share_url }}" readonly aria-label="Payment link">
+ <span id="copy-status" class="copy-indicator" aria-hidden="true"></span>
+ </div>
+ </section>
+
+ <section class="section">
+ <table>
+ <tbody>
+ {% for item in project.line_items %}
+ <tr>
+ <td>{{ item.name|star_emphasis }}</td>
+ <td class="amount">{{ item.amount_cents|money(currency) }}</td>
+ </tr>
+ {% endfor %}
+ <tr>
+ <td></td>
+ <td class="amount"><strong>Total {{ project.total_cents|money(currency) }}</strong></td>
+ </tr>
+ {% if project.paid_cents > 0 %}
+ <tr>
+ <td></td>
+ <td class="amount">Paid {{ project.paid_cents|money(currency) }}</td>
+ </tr>
+ <tr>
+ <td></td>
+ <td class="amount"><strong>Balance {{ project.balance_cents|money(currency) }}</strong></td>
+ </tr>
+ {% endif %}
+ </tbody>
+ </table>
+ </section>
+
+ {% if project.scope_terms %}
+ <section class="section">
+ <h2>Terms and Scope</h2>
+ <div class="prewrap">{{ project.scope_terms|star_emphasis }}</div>
+ </section>
+ {% endif %}
+
+ <section class="section">
+ <h2>Payments</h2>
+ {% if payments %}
+ <table>
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Stripe Payment</th>
+ <th>Status</th>
+ <th class="amount">Amount</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for payment in payments %}
+ <tr>
+ <td>{{ payment.created_at.strftime("%Y-%m-%d %H:%M") if payment.created_at else "" }}</td>
+ <td>{{ payment.stripe_payment_intent_id or payment.stripe_checkout_session_id or payment.webhook_id }}</td>
+ <td>{{ payment.status }}</td>
+ <td class="amount">{{ payment.amount_cents|money(payment.currency or currency) }}</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ {% else %}
+ <div class="empty">No payments yet.</div>
+ {% endif %}
+ </section>
+
+ <form class="full-width-form" method="post" action="/delete/{{ project.id }}" onsubmit="return confirm('Delete {{ project.project_number }}? This cannot be undone.');">
+ <button class="danger" type="submit">Delete</button>
+ </form>
+
+ <div class="sticky-actions">
+ <div class="sticky-actions-inner">
+ <a class="button secondary" href="/edit/{{ project.id }}">Edit</a>
+ <button type="button" id="share-link">Share</button>
+ </div>
+ </div>
+
+ <script>
+ async function copyPaymentLink() {
+ const input = document.getElementById("share-url");
+ const status = document.getElementById("copy-status");
+ input.select();
+ if (navigator.clipboard) {
+ await navigator.clipboard.writeText(input.value);
+ } else {
+ document.execCommand("copy");
+ }
+ status.classList.add("copied");
+ setTimeout(() => status.classList.remove("copied"), 1400);
+ }
+
+ document.getElementById("share-url").addEventListener("click", copyPaymentLink);
+ document.getElementById("share-link").addEventListener("click", async () => {
+ const button = document.getElementById("share-link");
+ await copyPaymentLink();
+ button.textContent = "Copied!";
+ setTimeout(() => button.textContent = "Share", 1400);
+ });
+ </script>
+{% endblock %}
diff --git a/templates/form.html b/templates/form.html
new file mode 100644
index 0000000..75ffcb5
--- /dev/null
+++ b/templates/form.html
@@ -0,0 +1,132 @@
+{% extends "base.html" %}
+{% block title %}{{ "Edit" if mode == "edit" else "New" }} Project{% endblock %}
+{% block page_class %}has-sticky-actions{% endblock %}
+{% block content %}
+ <div class="header-row">
+ <div>
+ <h1>{{ "Edit" if mode == "edit" else "New" }} Project</h1>
+ {% if mode == "edit" %}
+ <p class="muted">{{ project.project_number }}</p>
+ {% endif %}
+ </div>
+ </div>
+
+ <section class="section" style="margin-top:10px;">
+ {% if project.errors %}
+ <div class="alert error">
+ {% for error in project.errors %}
+ <div>{{ error }}</div>
+ {% endfor %}
+ </div>
+ {% endif %}
+
+ <form method="post" action="{{ '/edit/' ~ project.id if mode == 'edit' else '/create' }}" id="project-form">
+ <div class="grid-2">
+ <div class="field">
+ <label for="customer_name">Customer Name</label>
+ <input id="customer_name" name="customer_name" value="{{ project.customer_name }}" required>
+ </div>
+ <div class="field">
+ <label for="customer_email">Customer Email</label>
+ <input id="customer_email" name="customer_email" type="email" value="{{ project.customer_email }}">
+ </div>
+ </div>
+
+ <h2>Line Items</h2>
+ <div id="line-items">
+ {% set single_line_item = project.line_items|length <= 1 %}
+ {% for item in project.line_items %}
+ <div class="line-item">
+ <div class="line-item-text">
+ <label>Description</label>
+ <textarea name="item_name" rows="5" required>{{ item.name }}</textarea>
+ </div>
+ <div class="line-item-price">
+ <label>Price</label>
+ <input name="item_price" inputmode="decimal" value="{{ item.price_input if item.price_input is defined else '%.2f'|format((item.amount_cents or 0) / 100) }}" required>
+ </div>
+ <button class="secondary danger" type="button" data-remove-line {% if single_line_item %}hidden{% endif %}>Remove</button>
+ </div>
+ {% endfor %}
+ </div>
+
+ <button class="secondary add-line-button" type="button" id="add-line">Add Another</button>
+
+ <div class="stats">
+ <div class="stat">
+ <span>Total</span>
+ <strong id="form-total">{{ 0|money(currency) }}</strong>
+ </div>
+ </div>
+
+ <div class="field">
+ <label for="scope_terms">Scope / Terms</label>
+ <textarea id="scope_terms" name="scope_terms" rows="7">{{ project.scope_terms }}</textarea>
+ </div>
+
+ <div class="sticky-actions">
+ <div class="sticky-actions-inner">
+ {% if mode == "edit" %}
+ <a class="button secondary" href="/project/{{ project.id }}">Back</a>
+ {% else %}
+ <a class="button secondary" href="/">Cancel</a>
+ {% endif %}
+ <button type="submit">{{ "Save Project" if mode == "edit" else "Create Project" }}</button>
+ </div>
+ </div>
+ </form>
+ </section>
+
+ <script>
+ const lineItems = document.getElementById("line-items");
+ const addLine = document.getElementById("add-line");
+ const total = document.getElementById("form-total");
+ const currency = "{{ currency }}";
+
+ function money(cents) {
+ return `${currency} ${(cents / 100).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
+ }
+
+ function recalc() {
+ let cents = 0;
+ lineItems.querySelectorAll('input[name="item_price"]').forEach((input) => {
+ const parsed = Number(String(input.value).replace(/[$,]/g, ""));
+ if (!Number.isNaN(parsed)) cents += Math.round(parsed * 100);
+ });
+ total.textContent = money(cents);
+ }
+
+ function bindRemove(button) {
+ button.addEventListener("click", () => {
+ if (lineItems.querySelectorAll(".line-item").length <= 1) return;
+ button.closest(".line-item").remove();
+ updateRemoveButtons();
+ recalc();
+ });
+ }
+
+ function updateRemoveButtons() {
+ const rows = lineItems.querySelectorAll(".line-item");
+ rows.forEach((row) => {
+ const button = row.querySelector("[data-remove-line]");
+ button.hidden = rows.length <= 1;
+ });
+ }
+
+ addLine.addEventListener("click", () => {
+ const row = lineItems.querySelector(".line-item").cloneNode(true);
+ row.querySelectorAll("input, textarea").forEach((input) => input.value = "");
+ bindRemove(row.querySelector("[data-remove-line]"));
+ row.querySelectorAll("input").forEach((input) => input.addEventListener("input", recalc));
+ lineItems.appendChild(row);
+ row.querySelector("textarea").focus();
+ updateRemoveButtons();
+ recalc();
+ });
+
+ lineItems.querySelectorAll("[data-remove-line]").forEach(bindRemove);
+ lineItems.querySelectorAll("input").forEach((input) => input.addEventListener("input", recalc));
+ updateRemoveButtons();
+ recalc();
+ </script>
+{% endblock %}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..4467967
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,102 @@
+{% extends "base.html" %}
+{% block title %}Projects{% endblock %}
+{% block page_class %}has-sticky-index{% endblock %}
+{% block content %}
+ <div class="sticky-index-top">
+ <div class="sticky-index-actions">
+ <a class="button" href="/new">New Project</a>
+ <a class="button logout-button" href="/logout">Logout</a>
+ </div>
+ </div>
+
+ <div class="sticky-index-search">
+ <div class="sticky-index-search-inner">
+ <div class="searchbar">
+ <div class="search-field">
+ <input id="project-search" name="q" value="{{ q }}" placeholder="Search project number, id, or customer" autocomplete="off">
+ <button class="search-clear" id="clear-search" type="button" aria-label="Clear search" hidden>×</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="project-list" id="project-list" data-query="{{ q }}">
+ {% include "_project_rows.html" %}
+ </div>
+ <div id="scroll-sentinel" class="footer-note">
+ {% if has_more %}Loading more projects as you scroll{% endif %}
+ </div>
+
+ <script>
+ const list = document.getElementById("project-list");
+ const sentinel = document.getElementById("scroll-sentinel");
+ const searchInput = document.getElementById("project-search");
+ const clearSearch = document.getElementById("clear-search");
+ let nextPage = {{ page + 1 if has_more else 0 }};
+ let loading = false;
+ let searchTimer = null;
+ let activeRequest = 0;
+
+ function applyRows(html, append) {
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = html;
+ const marker = wrapper.querySelector("[data-next-page]");
+ if (!append) {
+ list.innerHTML = "";
+ }
+ wrapper.querySelectorAll(".project-card, .empty").forEach((row) => {
+ list.appendChild(row);
+ });
+ nextPage = marker && marker.dataset.hasMore === "1" ? Number(marker.dataset.nextPage) : 0;
+ sentinel.textContent = nextPage ? "Loading more projects as you scroll" : "";
+ }
+
+ async function fetchProjects(page, append) {
+ loading = true;
+ const q = encodeURIComponent(list.dataset.query || "");
+ const requestId = ++activeRequest;
+ const response = await fetch(`/projects_page?page=${page}&q=${q}`);
+ if (!response.ok) {
+ loading = false;
+ return;
+ }
+ const html = await response.text();
+ if (requestId === activeRequest) applyRows(html, append);
+ loading = false;
+ }
+
+ function loadNextPage() {
+ if (!nextPage || loading) return;
+ fetchProjects(nextPage, true);
+ }
+
+ function updateClearButton() {
+ clearSearch.hidden = searchInput.value.length === 0;
+ }
+
+ function queueSearch() {
+ clearTimeout(searchTimer);
+ searchTimer = setTimeout(() => {
+ list.dataset.query = searchInput.value;
+ nextPage = 0;
+ fetchProjects(1, false);
+ }, 200);
+ updateClearButton();
+ }
+
+ searchInput.addEventListener("input", queueSearch);
+ clearSearch.addEventListener("click", () => {
+ searchInput.value = "";
+ searchInput.focus();
+ queueSearch();
+ });
+ updateClearButton();
+
+ if ("IntersectionObserver" in window) {
+ const observer = new IntersectionObserver((entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) loadNextPage();
+ });
+ observer.observe(sentinel);
+ }
+ </script>
+{% endblock %}
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..bc909d5
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+{% block title %}Sign In{% endblock %}
+{% block page_class %}narrow{% endblock %}
+{% block content %}
+ <section class="section">
+ <h1>Sign In</h1>
+ {% if error %}
+ <div class="alert error">{{ error }}</div>
+ {% endif %}
+ <form method="post" action="/login">
+ <div class="field">
+ <label for="password">Password</label>
+ <input id="password" name="password" type="password" autofocus required>
+ </div>
+ <button type="submit">Sign In</button>
+ </form>
+ </section>
+{% endblock %}
diff --git a/templates/public_project.html b/templates/public_project.html
new file mode 100644
index 0000000..26d6f6c
--- /dev/null
+++ b/templates/public_project.html
@@ -0,0 +1,71 @@
+{% extends "base.html" %}
+{% block title %}{{ project.project_number }}{% endblock %}
+{% block page_class %}{{ 'has-sticky-pay' if project.balance_cents > 0 else '' }}{% endblock %}
+{% block content %}
+ <center>
+ <img src="https://i.postimg.cc/sDdprXgH/ea51ebc1-6a86-4b80-9a94-e8c30dd94c3a-removebg-preview.png" alt="After Dark Labs Logo" style="max-width:350px;">
+ </center>
+ <div class="header-row">
+ <div>
+ <div class="title-line">
+ <h1>Project for {{ project.customer_name }}</h1>
+ </div>
+ <p class="muted">{{ project.project_number }}</p>
+ </div>
+ </div>
+
+ {% if checkout_state == "return" %}
+ <div class="alert info">Payment is pending webhook confirmation.</div>
+ {% elif checkout_state == "cancel" %}
+ <div class="alert info">Checkout was canceled.</div>
+ {% endif %}
+ {% if error %}
+ <div class="alert error">{{ error }}</div>
+ {% endif %}
+
+ <section class="section">
+ <table>
+ <tbody>
+ {% for item in project.line_items %}
+ <tr>
+ <td>{{ item.name|star_emphasis }}</td>
+ <td class="amount">{{ item.amount_cents|money(currency) }}</td>
+ </tr>
+ {% endfor %}
+ <tr>
+ <td></td>
+ <td class="amount"><strong>Total {{ project.total_cents|money(currency) }}</strong></td>
+ </tr>
+ {% if project.paid_cents > 0 %}
+ <tr>
+ <td></td>
+ <td class="amount">Paid {{ project.paid_cents|money(currency) }}</td>
+ </tr>
+ <tr>
+ <td></td>
+ <td class="amount"><strong>Balance {{ project.balance_cents|money(currency) }}</strong></td>
+ </tr>
+ {% endif %}
+ </tbody>
+ </table>
+ </section>
+
+ {% if project.scope_terms %}
+ <section class="section">
+ <h2>Terms and Scope</h2>
+ <div class="prewrap">{{ project.scope_terms|star_emphasis }}</div>
+ </section>
+ {% endif %}
+
+ {% if project.balance_cents > 0 %}
+ <form class="sticky-pay" method="post" action="/pay/{{ project.share_token }}">
+ <div class="sticky-pay-inner">
+ <button type="submit">Pay or Make a Deposit</button>
+ </div>
+ </form>
+ {% else %}
+ <section class="section">
+ <div class="alert ok">Paid in full.</div>
+ </section>
+ {% endif %}
+{% endblock %}