patx/projectpay

afterdark labs payments

Commit d1084f4 · patx · 2026-06-28T15:29:16-04:00

Changeset
d1084f460825c557c4e624adae4502cd261436c4

View source at this commit

Comments

No comments yet.

Log in to comment

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">&larr;</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>&times;</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 %}