Skip to content

TransitPin - Architecture Decision Records

Each entry records a decision as it exists in the live system (verified 2026-08-15), in ADR form: Context, Decision, Consequences.

ADR-001: Subdomain-per-tenant URL scheme

  • Context: TransitPin must serve many transportation providers from one deployment, each with a branded parent-facing surface, without per-tenant code or infra.
  • Decision: Use a wildcard DNS record (*.transitpin.com) plus a subdomain-per-tenant convention (<slug>.transitpin.com). Tenant identity is resolved from the Host header (public surface), the JWT tenant_id claim (authed surface), or X-Tenant-Id header / path slug as fallbacks. The apex and brand surfaces (www, my, app, ops, webmail) are reserved and never treated as tenants.
  • Consequences: One wildcard TLS cert and one nginx server block serve all tenants, so onboarding a tenant requires no nginx or TLS work (only a tenants row). A path-slug form (app.transitpin.com/<slug>/...) is also supported because _slug_from_path and the client resolver accept it, which gives a fallback when a customer cannot use a subdomain. Tenant identity is not cryptographically bound to the Host header for public endpoints, so the public surface trusts DNS; the authed surface is bound to the JWT claim.

ADR-002: White-label theming via theme resolver + CSS variables

  • Context: Each tenant needs its own colors, fonts, and brand name, but the frontend is static single-file HTML with no build step.
  • Decision: Store a theme as a JSON string on tenants.theme and expose it at GET /api/theme/{slug}. The frontend runs loadTenantTheme() to fetch the theme and set CSS variables on :root, and applyBrandName() to set #brandName. Defaults fall back to a neutral palette when a token is absent.
  • Consequences: One HTML file renders N tenants with zero per-tenant code. Theming is data, not code, so a color change is a DB UPDATE with no deploy. The cost is a flash of default styling before the theme fetch resolves, and theme JSON is hand-edited in the DB (there is no theme editor UI yet).

ADR-003: $50 registration fee + weekly billing model

  • Context: Parents must pay something at signup (to commit the seat) and then a recurring charge tied to their child's trip type.
  • Decision: A one-time REGISTRATION_FEE = 50.0 charged at signup, plus a weekly transportation charge: one-way $120/wk (morning OR afternoon only) and two-way $175/wk (both). transport-need (both / morning / afternoon) is the source of truth, mapped to trip_type (two-way / one-way), and each child's rate_weekly is persisted. Household weekly total = sum of child rates. Billing cycle is a household billing_cycle column (default weekly) rather than a hardcoded constant at the call site.
  • Consequences: Registration writes a registration invoice row immediately (status='sent', provider='none') and emails a notice. Weekly invoices are generated separately (see ADR-009). Because rates are module constants, a rate change is a code change, not a config change; only the cycle is configurable per household today.

ADR-004: SQLite (single file) instead of Postgres

  • Context: The platform runs on one box (app3), write volume is low (signups, route edits, GPS pings), and the team prioritizes zero-operational-overhead and easy backups.
  • Decision: Use SQLite via Python sqlite3, one file at /opt/transitpin-api/data/transitpin.db, accessed synchronously per request through the get_db dependency. No ORM; raw SQL with sqlite3.Row rows.
  • Consequences: Backups are a single cp command; schema is transparent (see DATA-MODEL.md). There is no connection pooling, no FK enforcement, and no WAL mode, and writes serialize on the single file. This is acceptable at current scale but is the first thing to revisit if a second app node or high concurrent write load appears. The schema has no declared foreign keys; relationships are enforced only in application code.

ADR-005: Static frontend + FastAPI API split

  • Context: The product UI is delivered to many tenants and changes often; operator and ops consoles are separate surfaces.
  • Decision: The frontend is static single-file HTML/CSS/JS served directly by nginx from /home/transitpin-dash/htdocs/my.transitpin.com/ (no framework, no build step). The backend is a FastAPI app served by uvicorn on 127.0.0.1:8900. nginx proxies /api/ to the backend. The two are decoupled and deployed independently.
  • Consequences: Fast frontend iteration (edit an HTML file, done) and a clear API contract. No server-side rendering and no shared session between page and API beyond bearer tokens in the browser. Each feature area ships as a self-contained FastAPI router (ops_routes, maps_routes, billing_routes) that declares full /api/... paths, so they can be included without prefix and developed in parallel (see common.py for shared helpers).

ADR-006: auth2 for ops, tenant JWT for parents

  • Context: Two audiences need different identity guarantees: internal IT Pro Partner staff administering the platform, and parents/operators using a tenant's product.
  • Decision: The ops console (ops.transitpin.com/ops/) authenticates directly against auth2 (Hexclave), POSTing credentials to https://auth2-api.itpropartner.com and then calling /api/latest/users/me with an x-hexclave-access-token header, requiring membership in the transitpin-it-staff group. The product API validates auth2 session tokens server-side via GET /users/me with x-hexclave-access-token (plus x-hexclave-access-type: client and x-hexclave-project-id: internal). auth2 tokens are opaque, so role and tenant_id are read from the user's client_metadata (set at registration by the registering service), not from JWT claims. Ops can then impersonate a tenant using a separate DB-backed 15-minute token (ops_routes.py), which is never a valid auth2 token and is fully audited.
  • Consequences: Two auth code paths exist and must be kept in sync (the auth2 validation lives in both common.py and main.py). As of 2026-08-15 the /auth/whoami 404 defect is fixed: get_current_user now calls users/me, and real auth2 tokens resolve role/tenant_id from client_metadata. The demo- token fallback (role admin) remains only for when auth2 is unreachable; wrong credentials are rejected (no demo fallback). Impersonation is auditable end-to-end but is a separate token type that ordinary product endpoints do not yet accept (only the two impersonated/* examples do). Parent auth2 self sign-up is disabled by default (SIGN_UP_NOT_ENABLED); parent enrollment is local via /api/registrations/full, and parent auth2 accounts are admin-provisioned until self sign-up is enabled.

ADR-007: Reserved-slug list

  • Context: Some subdomains are brand surfaces or infrastructure, never tenant slugs, so tenant resolution must not confuse them with customers.
  • Decision: Maintain a RESERVED_SUBDOMAINS set of www, my, app, ops, and webmail. _slug_from_host skips these (and all-numeric labels) when deriving a tenant from the Host header.
  • Consequences: Those labels are unavailable as tenant slugs, which is intended. The list is duplicated in server code (main.py) and client code (resolveTenantSlug in the HTML), so adding a reserved label requires updating both places.

ADR-008: Normalize route stops into route_stops

  • Context: Routes have an ordered list of stops with coordinates, times, and rider lists, which cannot fit a single flat row cleanly.
  • Decision: Keep a denormalized routes.stops integer count on the route row, and a normalized route_stops table (one row per stop, seq 1..N, riders as a JSON string). The _route_with_stops helper joins them and JSON-decodes riders. Create/update insert (or delete-and-reinsert) stops and re-sync the count.
  • Consequences: Stops are queryable and ordered, and the count is cheap for list views. The denormalized count must be kept in sync by every write path, and riders is opaque JSON at the SQL level (no per-rider querying).

ADR-009: First weekly invoice 5 days before pickup

  • Context: The first transportation charge should arrive before service begins but not at signup (the $50 registration fee already covers signup).
  • Decision: Generate and email the first weekly transportation invoice PRE_PICKUP_GENERATE_DAYS = 5 days before the household's first_pickup_date (or once it is past). The generator (generate_due_transportation_invoices) is idempotent: any household that already has a transportation invoice is skipped. It runs at startup as a catch-up and daily via a systemd timer through generate_due.py, and can be triggered manually via POST /api/billing/generate-due.
  • Consequences: Parents are not charged for weekly service until just before their child starts, and re-running the job is safe (no double billing). The first invoice is provider='none'/status='sent' until Square credentials exist; the manual POST /api/billing/generate remains the Square-capable path.

ADR-010: Payment provider abstraction (Square primary, Stripe stub)

  • Context: Invoices should flow through a real payment provider when the customer's Square account is connected, but the system must run end-to-end without credentials.
  • Decision: A PaymentProvider interface with create_invoice and get_status. SquareProvider is implemented (env SQUARE_ACCESS_TOKEN + SQUARE_LOCATION_ID); StripeProvider raises NotImplementedError. When no provider is connected, generation falls back to provider='none' and status='draft' (or sent) so nothing crashes.
  • Consequences: The app is fully testable without keys, and Square can be switched on by setting two env vars. Stripe is a stub, so choosing Stripe later is real work. No Square OAuth credentials are configured as of 2026-08-15 (see Vaultwarden for any that are later added).