diff --git a/migrations/0008_add_donations.sql b/migrations/0008_add_donations.sql
new file mode 100644
index 0000000..cba1f76
--- /dev/null
+++ b/migrations/0008_add_donations.sql
@@ -0,0 +1,23 @@
+-- Migration 0008: Add donations support
+
+CREATE TABLE IF NOT EXISTS donations (
+ id TEXT PRIMARY KEY,
+ user_id TEXT,
+ amount INTEGER NOT NULL,
+ currency TEXT NOT NULL DEFAULT 'usd',
+ donation_type TEXT NOT NULL DEFAULT 'one-time',
+ donor_name TEXT,
+ donor_email TEXT,
+ message TEXT,
+ anonymous INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'pending',
+ stripe_payment_intent_id TEXT,
+ stripe_subscription_id TEXT,
+ stripe_customer_id TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_donations_created_at ON donations(created_at);
+CREATE INDEX IF NOT EXISTS idx_donations_status ON donations(status);
+CREATE INDEX IF NOT EXISTS idx_donations_user_id ON donations(user_id);
diff --git a/public/donate.html b/public/donate.html
new file mode 100644
index 0000000..1f81311
--- /dev/null
+++ b/public/donate.html
@@ -0,0 +1,727 @@
+
+
+
+
+
+
+ Donate - Alpha One Labs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your donation helps us provide quality education to more students around the world.
+
+
+
+
+
+
+
+
+
Make a Donation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Complete Donation
+
+
+
+ ← Back to donation details
+
+
+
+
+
+
+
+
+
Thank You!
+
+ Your donation was received. A thank-you email is on its way.
+
+
+ Make another donation
+
+
+
+
+
+
+
+
+
+
Why Donate?
+
Your donation helps us:
+
+
+
+ Provide scholarships to students in need
+
+
+
+ Develop new educational content and resources
+
+
+
+ Improve our platform and technology
+
+
+
+ Expand our reach to underserved communities
+
+
+
+ We're committed to making quality education accessible to everyone, regardless of their financial situation.
+
+
+
+
+
+
Why Donate Monthly?
+
Monthly donations provide sustainable support that helps us:
+
+
+
+ Plan long-term educational programs
+
+
+
+ Maintain consistent support for students
+
+
+
+ Develop new educational resources continuously
+
+
+
+ Make long-term commitments to communities
+
+
+
+ Your monthly support creates a foundation for sustainable education.
+
+
+
+
+
+
Community Impact
+
Total donations so far
+
+
+ Thank you to all our generous donors who make our mission possible!
+
+
+
+
+
+
Recent Donations
+
+
+
+
+
+
+ No donations yet. Be the first to support our mission!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/partials/navbar.html b/public/partials/navbar.html
index acaf410..f6434a6 100644
--- a/public/partials/navbar.html
+++ b/public/partials/navbar.html
@@ -148,7 +148,7 @@
Feedback
-
+
Donate
diff --git a/src/api/__init__.py b/src/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/api/donations.py b/src/api/donations.py
new file mode 100644
index 0000000..53bbe00
--- /dev/null
+++ b/src/api/donations.py
@@ -0,0 +1,659 @@
+"""
+Donation API handlers with full Stripe integration.
+
+Handles one-time payments (PaymentIntent) and monthly subscriptions
+(Customer + Subscription → latest_invoice.payment_intent).
+
+Environment variables required:
+ STRIPE_SECRET_KEY — sk_live_... or sk_test_...
+ STRIPE_PUBLISHABLE_KEY — pk_live_... or pk_test_...
+ STRIPE_WEBHOOK_SECRET — whsec_...
+ MAILGUN_API_KEY — for thank-you emails
+ MAILGUN_DOMAIN — e.g. mg.alphaonelabs.com
+ FROM_EMAIL — e.g. no-reply@alphaonelabs.com
+"""
+import base64
+import hashlib
+import hmac as _hmac
+import json
+import os
+import time
+from typing import Optional
+from urllib.parse import urlencode
+
+import js # type: ignore[import]
+from pyodide.ffi import to_js # type: ignore[import]
+from workers import Response
+
+from donations import (
+ validate_donation_payload,
+ build_donation_record,
+ format_donation_for_display,
+)
+
+# Response helpers
+
+_CORS = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
+}
+
+
+def _json_resp(data, status: int = 200):
+ return Response(
+ json.dumps(data),
+ status=status,
+ headers={"Content-Type": "application/json", **_CORS},
+ )
+
+
+def _ok(data=None, msg: str = "OK"):
+ body = {"success": True, "message": msg}
+ if data is not None:
+ body["data"] = data
+ return _json_resp(body)
+
+
+def _err(msg: str, status: int = 400):
+ return _json_resp({"error": msg}, status)
+
+
+async def _parse_json(req):
+ try:
+ text = await req.text()
+ body = json.loads(text)
+ except Exception:
+ return None, _err("Invalid JSON body")
+ if not isinstance(body, dict):
+ return None, _err("JSON body must be an object")
+ return body, None
+
+
+def _verify_token(raw: str, secret: str) -> Optional[dict]:
+ if not raw:
+ return None
+ try:
+ token = raw.removeprefix("Bearer ").strip()
+ dot = token.rfind(".")
+ if dot == -1:
+ return None
+ p, sig = token[:dot], token[dot + 1:]
+ exp = _hmac.new(secret.encode(), p.encode(), hashlib.sha256).hexdigest()
+ if not _hmac.compare_digest(sig, exp):
+ return None
+ padding = (4 - len(p) % 4) % 4
+ return json.loads(base64.b64decode(p + "=" * padding).decode())
+ except Exception:
+ return None
+
+
+def _new_id() -> str:
+ b = bytearray(os.urandom(16))
+ b[6] = (b[6] & 0x0F) | 0x40
+ b[8] = (b[8] & 0x3F) | 0x80
+ h = b.hex()
+ return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
+
+# Stripe HTTP client
+
+def _stripe_encode(data: dict, prefix: str = "") -> list:
+ """Recursively flatten a nested dict into Stripe bracket-notation pairs."""
+ pairs = []
+ for key, value in data.items():
+ full_key = f"{prefix}[{key}]" if prefix else key
+ if isinstance(value, dict):
+ pairs.extend(_stripe_encode(value, full_key))
+ elif isinstance(value, list):
+ for i, item in enumerate(value):
+ item_key = f"{full_key}[{i}]"
+ if isinstance(item, dict):
+ pairs.extend(_stripe_encode(item, item_key))
+ elif item is not None:
+ pairs.append((item_key, str(item)))
+ elif isinstance(value, bool):
+ pairs.append((full_key, "true" if value else "false"))
+ elif value is not None:
+ pairs.append((full_key, str(value)))
+ return pairs
+
+
+async def _stripe(method: str, path: str, data: dict, key: str, *, idempotency_key: str = ""):
+ """Make a Stripe REST API call. Returns (response_dict, error_str)."""
+ url = f"https://api.stripe.com/v1/{path.lstrip('/')}"
+ body = urlencode(_stripe_encode(data)) if data else None
+
+ headers: dict = {
+ "Authorization": f"Bearer {key}",
+ "Content-Type": "application/x-www-form-urlencoded",
+ }
+ if idempotency_key and method.upper() in ("POST", "PUT", "PATCH"):
+ headers["Idempotency-Key"] = idempotency_key
+
+ fetch_opts = {"method": method.upper(), "headers": headers}
+ if body and method.upper() != "GET":
+ fetch_opts["body"] = body
+
+ try:
+ resp = await js.fetch(url, to_js(fetch_opts, dict_converter=js.Object.fromEntries))
+ text = await resp.text()
+ obj = json.loads(text)
+ if not resp.ok:
+ msg = (obj.get("error") or {}).get("message") or "Stripe API error"
+ print(f"[stripe] {method} {path} → {resp.status}: {msg}")
+ return None, msg
+ return obj, None
+ except Exception as exc:
+ print(f"[stripe] fetch error: {exc}")
+ return None, str(exc)
+
+
+# Webhook signature verification
+
+_WEBHOOK_TOLERANCE_SECONDS = 300 # Stripe's recommended 5-minute replay window
+
+
+def _verify_stripe_webhook(raw_body: str, sig_header: str, secret: str) -> bool:
+ """Verify a Stripe-Signature header using HMAC-SHA256 with replay protection."""
+ try:
+ parts: dict = {}
+ for chunk in sig_header.split(","):
+ k, v = chunk.split("=", 1)
+ parts.setdefault(k.strip(), []).append(v.strip())
+
+ timestamp = parts.get("t", [None])[0]
+ v1_sigs = parts.get("v1", [])
+ if not timestamp or not v1_sigs:
+ return False
+
+ # Reject replayed signatures older than the tolerance window
+ try:
+ if abs(time.time() - int(timestamp)) > _WEBHOOK_TOLERANCE_SECONDS:
+ print("[webhook] Signature timestamp out of tolerance — possible replay")
+ return False
+ except (ValueError, TypeError):
+ return False
+
+ signed = f"{timestamp}.{raw_body}"
+ expected = _hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
+ return any(_hmac.compare_digest(expected, s) for s in v1_sigs)
+ except Exception:
+ return False
+
+
+# Email — lives here because it uses js.fetch (runtime-only)
+
+async def _send_thank_you_email(env, info: dict) -> None:
+ """Send a donation thank-you email via Mailgun."""
+ try:
+ to_email = (info.get("email") or "").strip()
+ if not to_email:
+ return
+
+ mailgun_key = getattr(env, "MAILGUN_API_KEY", "") or ""
+ mailgun_domain = getattr(env, "MAILGUN_DOMAIN", "") or ""
+ from_email = getattr(env, "FROM_EMAIL", f"noreply@{mailgun_domain}") or ""
+
+ if not mailgun_key or not mailgun_domain:
+ print("[email] Mailgun not configured — skipping thank-you email")
+ return
+
+ amount_cents = int(info.get("amount", 0))
+ amount_str = f"${amount_cents / 100:.2f}"
+ type_label = "monthly" if info.get("type") == "monthly" else "one-time"
+
+ html_body = f"""
+
+
+
Thank You!
+
Your generosity makes a real difference.
+
+
+ We received your {type_label} donation of
+ {amount_str} .
+ Your support helps us provide quality education to students around the world.
+
+
With your donation we can:
+
+ Provide scholarships to students in need
+ Develop new educational content and resources
+ Improve our platform and technology
+ Expand our reach to underserved communities
+
+
+ Thank you for being part of our mission to make quality education accessible to everyone.
+
+
+ Alpha One Labs —
+ alphaonelabs.com
+
+
"""
+
+ plain_body = (
+ f"Thank you for your {type_label} donation of {amount_str} to Alpha One Labs!\n\n"
+ "Your support helps us provide quality education to students around the world.\n\n"
+ "Visit https://alphaonelabs.com to learn more."
+ )
+
+ credentials = base64.b64encode(f"api:{mailgun_key}".encode()).decode()
+ form_data = urlencode({
+ "from": from_email,
+ "to": to_email,
+ "subject": "Thank You for Your Donation to Alpha One Labs!",
+ "html": html_body,
+ "text": plain_body,
+ })
+
+ opts = to_js({
+ "method": "POST",
+ "headers": {
+ "Authorization": f"Basic {credentials}",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ "body": form_data,
+ }, dict_converter=js.Object.fromEntries)
+
+ resp = await js.fetch(
+ f"https://api.mailgun.net/v3/{mailgun_domain}/messages", opts
+ )
+ if resp.ok:
+ print("[email] Thank-you email sent")
+ else:
+ print(f"[email] Mailgun error {resp.status}")
+ except Exception as exc:
+ print(f"[email] _send_thank_you_email error: {exc}")
+
+
+# DB helper shared by webhook handlers
+
+_ALLOWED_DONATION_FIELDS = frozenset({
+ "stripe_payment_intent_id",
+ "stripe_subscription_id",
+ "stripe_customer_id",
+})
+
+# Status values ordered from "least final" to "most final".
+# An update that would move the status backward is skipped unless
+# allow_downgrade=True (used for explicit cancellation/failure events).
+_STATUS_RANK = {"pending": 0, "completed": 1, "cancelled": 2, "failed": 2}
+
+
+async def _mark_donation(env, field: str, value: str, status: str, *, allow_downgrade: bool = False):
+ """Find a donation by a Stripe ID field and update its status.
+
+ Raises ValueError for unknown field names (SQL-injection guard).
+ Raises on DB errors so callers can propagate a 5xx to Stripe for retry.
+ """
+ if field not in _ALLOWED_DONATION_FIELDS:
+ raise ValueError(f"_mark_donation: unknown field '{field}'")
+ row = await env.DB.prepare(
+ f"SELECT * FROM donations WHERE {field} = ? LIMIT 1" # noqa: S608 — field is allowlisted above
+ ).bind(value).first()
+ if not row:
+ return None
+ current = getattr(row, "status", "pending") or "pending"
+ if not allow_downgrade and _STATUS_RANK.get(current, 0) >= _STATUS_RANK.get(status, 0) and current != status:
+ print(f"[donations] Skipping status regression {current!r} → {status!r} for {field}={value}")
+ return row
+ await env.DB.prepare(
+ "UPDATE donations SET status = ? WHERE id = ?"
+ ).bind(status, row.id).run()
+ return row
+
+
+# Public API handlers
+
+async def get_donation_config(_, env):
+ """GET /api/donations/config — return Stripe publishable key."""
+ pub_key = getattr(env, "STRIPE_PUBLISHABLE_KEY", "") or ""
+ return _ok({"publishable_key": pub_key})
+
+
+async def get_donation_stats(request, env):
+ """GET /api/donations/stats"""
+ try:
+ row = await env.DB.prepare(
+ "SELECT COUNT(*) AS donor_count, SUM(amount) AS total_cents"
+ " FROM donations WHERE status = 'completed'"
+ ).first()
+ total_cents = getattr(row, "total_cents", 0) or 0
+ donor_count = int(getattr(row, "donor_count", 0) or 0)
+ return _ok({"total_donated": round(total_cents / 100, 2), "donor_count": donor_count})
+ except Exception as exc:
+ print(f"[donations/stats] DB error: {exc}")
+ return _ok({"total_donated": 0, "donor_count": 0})
+
+
+async def get_recent_donations(request, env):
+ """GET /api/donations/recent"""
+ try:
+ result = await env.DB.prepare(
+ "SELECT id, donor_name, message, amount, donation_type, anonymous, created_at"
+ " FROM donations WHERE status = 'completed'"
+ " ORDER BY created_at DESC LIMIT 10"
+ ).all()
+ rows = result.results or []
+ return _ok({"donations": [format_donation_for_display(r) for r in rows]})
+ except Exception as exc:
+ print(f"[donations/recent] DB error: {exc}")
+ return _ok({"donations": []})
+
+
+async def create_donation_intent(request, env):
+ """POST /api/donations/one-time
+
+ Creates a Stripe PaymentIntent and a pending donation record.
+ Returns client_secret for the frontend to confirm with Stripe.js.
+ """
+ body, error_resp = await _parse_json(request)
+ if error_resp:
+ return error_resp
+
+ body["donation_type"] = "one-time"
+ validated, err_msg = validate_donation_payload(body)
+ if err_msg:
+ return _err(err_msg)
+
+ stripe_key = getattr(env, "STRIPE_SECRET_KEY", "") or ""
+ if not stripe_key:
+ return _err("Payment processing is not configured", 503)
+
+ user = _verify_token(request.headers.get("Authorization") or "", env.JWT_SECRET)
+ user_id = user["id"] if user else None
+
+ donation_id = _new_id() # generate first so it can serve as idempotency key
+
+ intent, stripe_err = await _stripe("POST", "/payment_intents", {
+ "amount": validated["amount_cents"],
+ "currency": "usd",
+ "automatic_payment_methods": {"enabled": True, "allow_redirects": "always"},
+ "receipt_email": validated["email"],
+ "metadata": {
+ "donation_type": "one-time",
+ "user_id": user_id or "",
+ "message": validated["message"][:100],
+ "anonymous": "true" if validated["anonymous"] else "false",
+ "email": validated["email"],
+ },
+ }, stripe_key, idempotency_key=f"pi-{donation_id}")
+
+ if stripe_err:
+ return _err(f"Payment setup failed: {stripe_err}", 502)
+ record = build_donation_record(donation_id, user_id, validated)
+
+ try:
+ await env.DB.prepare(
+ "INSERT INTO donations"
+ " (id, user_id, amount, currency, donation_type, donor_email, message,"
+ " anonymous, status, stripe_payment_intent_id)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)"
+ ).bind(
+ record["id"], record["user_id"], record["amount"], record["currency"],
+ record["donation_type"], record["donor_email"], record["message"],
+ record["anonymous"], intent["id"],
+ ).run()
+ except Exception as exc:
+ print(f"[donations/one-time] DB insert error: {exc}")
+ return _err("Failed to record donation", 500)
+
+ return _ok({
+ "donation_id": donation_id,
+ "client_secret": intent["client_secret"],
+ "amount": validated["amount_dollars"],
+ "currency": "usd",
+ }, "PaymentIntent created")
+
+
+async def create_subscription_intent(request, env):
+ """POST /api/donations/monthly
+
+ Creates a Stripe Customer, a dynamic Price, and a Subscription with
+ payment_behavior=default_incomplete so that the first payment is handled
+ via the Subscription's latest_invoice PaymentIntent client_secret.
+
+ Subsequent monthly charges are handled automatically by Stripe and
+ recorded via the invoice.payment_succeeded webhook.
+ """
+ body, error_resp = await _parse_json(request)
+ if error_resp:
+ return error_resp
+
+ body["donation_type"] = "monthly"
+ validated, err_msg = validate_donation_payload(body)
+ if err_msg:
+ return _err(err_msg)
+
+ stripe_key = getattr(env, "STRIPE_SECRET_KEY", "") or ""
+ if not stripe_key:
+ return _err("Payment processing is not configured", 503)
+
+ user = _verify_token(request.headers.get("Authorization") or "", env.JWT_SECRET)
+ user_id = user["id"] if user else None
+
+ donation_id = _new_id() # generate first so it anchors all idempotency keys
+
+ # 1. Create or retrieve Stripe Customer
+ customer, stripe_err = await _stripe("POST", "/customers", {
+ "email": validated["email"],
+ "metadata": {"user_id": user_id or ""},
+ }, stripe_key, idempotency_key=f"cus-{donation_id}")
+ if stripe_err:
+ return _err(f"Payment setup failed: {stripe_err}", 502)
+
+ # 2. Create a dynamic per-amount Price (recurring monthly)
+ price, stripe_err = await _stripe("POST", "/prices", {
+ "currency": "usd",
+ "unit_amount": validated["amount_cents"],
+ "recurring": {"interval": "month"},
+ "product_data": {"name": "Alpha One Labs Monthly Donation"},
+ }, stripe_key, idempotency_key=f"prc-{donation_id}")
+ if stripe_err:
+ return _err(f"Payment setup failed: {stripe_err}", 502)
+
+ # 3. Create Subscription — default_incomplete means the first invoice
+ # is left unpaid and a PaymentIntent is created for us to confirm.
+ subscription, stripe_err = await _stripe("POST", "/subscriptions", {
+ "customer": customer["id"],
+ "items": [{"price": price["id"]}],
+ "payment_behavior": "default_incomplete",
+ "payment_settings": {"save_default_payment_method": "on_subscription"},
+ "expand": ["latest_invoice.payment_intent"],
+ "metadata": {
+ "donation_type": "monthly",
+ "user_id": user_id or "",
+ "message": validated["message"][:100],
+ "anonymous": "true" if validated["anonymous"] else "false",
+ "email": validated["email"],
+ },
+ }, stripe_key, idempotency_key=f"sub-{donation_id}")
+ if stripe_err:
+ return _err(f"Subscription setup failed: {stripe_err}", 502)
+
+ try:
+ payment_intent = subscription["latest_invoice"]["payment_intent"]
+ client_secret = payment_intent["client_secret"]
+ payment_intent_id = payment_intent["id"]
+ except (KeyError, TypeError):
+ return _err("Failed to retrieve payment details from subscription", 502)
+
+ record = build_donation_record(donation_id, user_id, validated)
+
+ try:
+ await env.DB.prepare(
+ "INSERT INTO donations"
+ " (id, user_id, amount, currency, donation_type, donor_email, message,"
+ " anonymous, status, stripe_payment_intent_id, stripe_subscription_id, stripe_customer_id)"
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)"
+ ).bind(
+ record["id"], record["user_id"], record["amount"], record["currency"],
+ record["donation_type"], record["donor_email"], record["message"],
+ record["anonymous"],
+ payment_intent_id, subscription["id"], customer["id"],
+ ).run()
+ except Exception as exc:
+ print(f"[donations/monthly] DB insert error: {exc}")
+ return _err("Failed to record subscription", 500)
+
+ return _ok({
+ "donation_id": donation_id,
+ "client_secret": client_secret,
+ "subscription_id": subscription["id"],
+ "amount": validated["amount_dollars"],
+ "currency": "usd",
+ }, "Subscription created")
+
+
+async def handle_donation_webhook(request, env):
+ """POST /api/donations/webhook — process Stripe webhook events."""
+ webhook_secret = getattr(env, "STRIPE_WEBHOOK_SECRET", "") or ""
+ if not webhook_secret:
+ return _err("Webhook not configured", 503)
+
+ raw_body = await request.text()
+ sig_header = request.headers.get("Stripe-Signature") or ""
+
+ if not _verify_stripe_webhook(raw_body, sig_header, webhook_secret):
+ print("[webhook] Invalid Stripe signature")
+ return _err("Invalid signature", 400)
+
+ try:
+ event = json.loads(raw_body)
+ except Exception:
+ return _err("Invalid JSON", 400)
+
+ event_type = event.get("type", "")
+ obj = (event.get("data") or {}).get("object") or {}
+ print(f"[webhook] {event_type}")
+
+ handler_exc = None
+ try:
+ if event_type == "payment_intent.succeeded":
+ await _on_payment_succeeded(env, obj)
+ elif event_type == "payment_intent.payment_failed":
+ await _on_payment_failed(env, obj)
+ elif event_type == "customer.subscription.created":
+ await _on_subscription_created(env, obj)
+ elif event_type == "customer.subscription.updated":
+ await _on_subscription_updated(env, obj)
+ elif event_type == "customer.subscription.deleted":
+ await _on_subscription_cancelled(env, obj)
+ elif event_type == "invoice.payment_succeeded":
+ await _on_invoice_paid(env, obj)
+ elif event_type == "invoice.payment_failed":
+ await _on_invoice_failed(env, obj)
+ except Exception as exc:
+ print(f"[webhook] Error processing {event_type}: {exc}")
+ handler_exc = exc
+
+ # Return 500 for transient errors (DB failures, etc.) so Stripe retries.
+ # Return 200 for unknown event types — no retry needed.
+ if handler_exc is not None:
+ return _json_resp({"error": "processing_failed"}, 500)
+ return _json_resp({"received": True}, 200)
+
+
+# Webhook event handlers (mirrors the old Django signal handlers)
+
+async def _on_payment_succeeded(env, intent: dict) -> None:
+ intent_id = intent.get("id", "")
+ row = await _mark_donation(env, "stripe_payment_intent_id", intent_id, "completed")
+ if row:
+ print(f"[webhook] Donation {row.id} completed")
+ await _send_thank_you_email(env, {
+ "email": getattr(row, "donor_email", "") or "",
+ "amount": getattr(row, "amount", 0),
+ "type": getattr(row, "donation_type", "one-time"),
+ })
+ else:
+ print(f"[webhook] No donation found for PaymentIntent {intent_id}")
+
+
+async def _on_payment_failed(env, intent: dict) -> None:
+ intent_id = intent.get("id", "")
+ await _mark_donation(env, "stripe_payment_intent_id", intent_id, "failed", allow_downgrade=True)
+ print(f"[webhook] PaymentIntent {intent_id} failed")
+
+
+async def _on_subscription_created(env, sub: dict) -> None:
+ sub_id = sub.get("id", "")
+ # Only set to pending if not already completed (payment_intent.succeeded fires first
+ # for the initial payment and owns setting completed).
+ if sub.get("status") != "active":
+ await _mark_donation(env, "stripe_subscription_id", sub_id, "pending")
+ print(f"[webhook] Subscription {sub_id} created → {sub.get('status')}")
+
+
+async def _on_subscription_updated(env, sub: dict) -> None:
+ sub_id = sub.get("id", "")
+ stripe_status = sub.get("status", "")
+ # Only map downgrade states; "active" is not forced here because
+ # payment_intent.succeeded already owns the completed transition.
+ status_map = {"past_due": "pending", "canceled": "cancelled", "unpaid": "pending"}
+ if stripe_status in status_map:
+ await _mark_donation(env, "stripe_subscription_id", sub_id, status_map[stripe_status], allow_downgrade=True)
+ print(f"[webhook] Subscription {sub_id} updated → {stripe_status}")
+
+
+async def _on_subscription_cancelled(env, sub: dict) -> None:
+ sub_id = sub.get("id", "")
+ await _mark_donation(env, "stripe_subscription_id", sub_id, "cancelled", allow_downgrade=True)
+ print(f"[webhook] Subscription {sub_id} cancelled")
+
+
+async def _on_invoice_paid(env, invoice: dict) -> None:
+ """Recurring monthly charge succeeded — insert a new completed donation row."""
+ sub_id = invoice.get("subscription")
+ if not sub_id:
+ return
+
+ try:
+ # Find the original subscription donation to copy its details
+ row = await env.DB.prepare(
+ "SELECT * FROM donations WHERE stripe_subscription_id = ? LIMIT 1"
+ ).bind(sub_id).first()
+ if not row:
+ print(f"[webhook] No donation found for subscription {sub_id}")
+ return
+
+ # Avoid duplicating the first payment already handled by payment_intent.succeeded
+ invoice_pi = invoice.get("payment_intent") or ""
+ if invoice_pi:
+ existing = await env.DB.prepare(
+ "SELECT id FROM donations WHERE stripe_payment_intent_id = ? LIMIT 1"
+ ).bind(invoice_pi).first()
+ if existing:
+ return
+
+ new_id = _new_id()
+ await env.DB.prepare(
+ "INSERT INTO donations"
+ " (id, user_id, amount, currency, donation_type, donor_email, message,"
+ " anonymous, status, stripe_subscription_id, stripe_customer_id, stripe_payment_intent_id)"
+ " VALUES (?, ?, ?, ?, 'monthly', ?, ?, ?, 'completed', ?, ?, ?)"
+ ).bind(
+ new_id,
+ getattr(row, "user_id", None),
+ getattr(row, "amount", 0),
+ getattr(row, "currency", "usd"),
+ getattr(row, "donor_email", ""),
+ getattr(row, "message", ""),
+ getattr(row, "anonymous", 0),
+ sub_id,
+ getattr(row, "stripe_customer_id", None),
+ invoice_pi or None,
+ ).run()
+
+ print(f"[webhook] Recurring donation {new_id} recorded for subscription {sub_id}")
+ await _send_thank_you_email(env, {
+ "email": getattr(row, "donor_email", "") or "",
+ "amount": getattr(row, "amount", 0),
+ "type": "monthly",
+ })
+ except Exception as exc:
+ print(f"[webhook] _on_invoice_paid error: {exc}")
+
+
+async def _on_invoice_failed(_, invoice: dict) -> None:
+ sub_id = invoice.get("subscription")
+ if sub_id:
+ print(f"[webhook] Invoice payment failed for subscription {sub_id}")
diff --git a/src/donations.py b/src/donations.py
new file mode 100644
index 0000000..99b121e
--- /dev/null
+++ b/src/donations.py
@@ -0,0 +1,99 @@
+"""
+Business logic for the donations feature.
+Validation, amount handling, and record formatting.
+Email notifications live in api/donations.py (runtime layer).
+"""
+import re
+from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
+from typing import Optional
+
+VALID_DONATION_TYPES = {"one-time", "monthly"}
+MIN_AMOUNT_CENTS = 100 # $1.00
+MAX_AMOUNT_CENTS = 1_000_000 # $10,000.00
+
+
+def dollars_to_cents(amount_dollars) -> int:
+ """Convert a dollar amount to cents using exact decimal arithmetic."""
+ d = Decimal(str(amount_dollars))
+ return int((d * 100).to_integral_value(rounding=ROUND_HALF_UP))
+
+
+def cents_to_dollars(amount_cents: int) -> float:
+ return float(Decimal(amount_cents) / 100)
+
+
+def validate_donation_payload(body: dict) -> tuple[Optional[dict], Optional[str]]:
+ """Validate and normalise a donation payload.
+
+ Returns (normalised_payload, error_message). One of the two will be None.
+ """
+ amount_raw = body.get("amount")
+ if amount_raw is None:
+ return None, "amount is required"
+
+ try:
+ cleaned = str(amount_raw).replace(",", "").strip()
+ amount_d = Decimal(cleaned)
+ if not amount_d.is_finite():
+ raise ValueError("non-finite amount")
+ except (InvalidOperation, ValueError):
+ return None, "amount must be a valid number"
+
+ amount_cents = dollars_to_cents(amount_d)
+ if amount_cents < MIN_AMOUNT_CENTS:
+ return None, "Minimum donation is $1.00"
+ if amount_cents > MAX_AMOUNT_CENTS:
+ return None, "Maximum donation is $10,000.00"
+
+ donation_type = body.get("donation_type", "one-time")
+ if donation_type not in VALID_DONATION_TYPES:
+ return None, f"donation_type must be one of: {', '.join(VALID_DONATION_TYPES)}"
+
+ email = (body.get("email") or "").strip()
+ if not email:
+ return None, "email is required"
+ if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
+ return None, "Invalid email address"
+
+ message = (body.get("message") or "").strip()
+ if len(message) > 500:
+ return None, "Message must be 500 characters or less"
+
+ return {
+ "amount_cents": amount_cents,
+ "amount_dollars": cents_to_dollars(amount_cents),
+ "currency": "usd",
+ "donation_type": donation_type,
+ "email": email,
+ "message": message,
+ "anonymous": bool(body.get("anonymous", False)),
+ }, None
+
+
+def build_donation_record(donation_id: str, user_id: Optional[str], validated: dict) -> dict:
+ """Return a database-ready donation record dict."""
+ return {
+ "id": donation_id,
+ "user_id": user_id,
+ "amount": validated["amount_cents"],
+ "currency": validated["currency"],
+ "donation_type": validated["donation_type"],
+ "donor_email": validated["email"],
+ "message": validated["message"],
+ "anonymous": 1 if validated["anonymous"] else 0,
+ "status": "pending",
+ }
+
+
+def format_donation_for_display(row) -> dict:
+ """Convert a DB row into a public-facing donation summary."""
+ anonymous = bool(getattr(row, "anonymous", 0))
+ donor_name = getattr(row, "donor_name", None) or "Community Member"
+ return {
+ "id": getattr(row, "id", ""),
+ "donor_name": "Anonymous" if anonymous else donor_name,
+ "message": getattr(row, "message", "") or "",
+ "amount": cents_to_dollars(getattr(row, "amount", 0)),
+ "donation_type": getattr(row, "donation_type", "one-time"),
+ "created_at": getattr(row, "created_at", ""),
+ }
diff --git a/src/worker.py b/src/worker.py
index f274a07..31fc348 100644
--- a/src/worker.py
+++ b/src/worker.py
@@ -49,6 +49,15 @@
import js
from pyodide.ffi import to_js
from js import WebSocketPair, WebSocketRequestResponsePair
+
+from api.donations import (
+ get_donation_config,
+ get_donation_stats,
+ get_recent_donations,
+ create_donation_intent,
+ create_subscription_intent,
+ handle_donation_webhook,
+)
import uuid
_SENTRY_INITIALIZED = False
@@ -2708,6 +2717,20 @@ async def _dispatch(request, env):
if path == "/api/notification-preferences" and method == "PATCH":
return await api_patch_notification_preferences(request, env)
+ # Donations
+ if path == "/api/donations/config" and method == "GET":
+ return await get_donation_config(request, env)
+ if path == "/api/donations/stats" and method == "GET":
+ return await get_donation_stats(request, env)
+ if path == "/api/donations/recent" and method == "GET":
+ return await get_recent_donations(request, env)
+ if path == "/api/donations/one-time" and method == "POST":
+ return await create_donation_intent(request, env)
+ if path == "/api/donations/monthly" and method == "POST":
+ return await create_subscription_intent(request, env)
+ if path == "/api/donations/webhook" and method == "POST":
+ return await handle_donation_webhook(request, env)
+
return err("API endpoint not found", 404)
return await serve_static(path, env)
diff --git a/tests/conftest.py b/tests/conftest.py
index 58530c0..9c744f4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -13,6 +13,14 @@
"""
import sys
+from pathlib import Path
+
+# Make src/ sub-packages (e.g. api/) importable during tests.
+# The test runner's CWD is the project root, so src/ is not automatically
+# on sys.path — worker.py's "from api.donations import …" would fail without this.
+_src = Path(__file__).parent.parent / "src"
+if str(_src) not in sys.path:
+ sys.path.insert(0, str(_src))
# ---------------------------------------------------------------------------