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 theHostheader (public surface), the JWTtenant_idclaim (authed surface), orX-Tenant-Idheader / 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
tenantsrow). A path-slug form (app.transitpin.com/<slug>/...) is also supported because_slug_from_pathand 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.themeand expose it atGET /api/theme/{slug}. The frontend runsloadTenantTheme()to fetch the theme and set CSS variables on:root, andapplyBrandName()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.0charged 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 totrip_type(two-way/one-way), and each child'srate_weeklyis persisted. Household weekly total = sum of child rates. Billing cycle is a householdbilling_cyclecolumn (defaultweekly) rather than a hardcoded constant at the call site. - Consequences: Registration writes a
registrationinvoice 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 theget_dbdependency. No ORM; raw SQL withsqlite3.Rowrows. - Consequences: Backups are a single
cpcommand; 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 on127.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 (seecommon.pyfor 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 tohttps://auth2-api.itpropartner.comand then calling/api/latest/users/mewith anx-hexclave-access-tokenheader, requiring membership in thetransitpin-it-staffgroup. The product API validates auth2 session tokens server-side viaGET /users/mewithx-hexclave-access-token(plusx-hexclave-access-type: clientandx-hexclave-project-id: internal). auth2 tokens are opaque, soroleandtenant_idare read from the user'sclient_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.pyandmain.py). As of 2026-08-15 the/auth/whoami404 defect is fixed:get_current_usernow callsusers/me, and real auth2 tokens resolverole/tenant_idfromclient_metadata. Thedemo-token fallback (roleadmin) 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 twoimpersonated/*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_SUBDOMAINSset ofwww,my,app,ops, andwebmail._slug_from_hostskips 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 (resolveTenantSlugin 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.stopsinteger count on the route row, and a normalizedroute_stopstable (one row per stop,seq1..N,ridersas a JSON string). The_route_with_stopshelper joins them and JSON-decodesriders. 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
ridersis 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 = 5days before the household'sfirst_pickup_date(or once it is past). The generator (generate_due_transportation_invoices) is idempotent: any household that already has atransportationinvoice is skipped. It runs at startup as a catch-up and daily via a systemd timer throughgenerate_due.py, and can be triggered manually viaPOST /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 manualPOST /api/billing/generateremains 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
PaymentProviderinterface withcreate_invoiceandget_status.SquareProvideris implemented (envSQUARE_ACCESS_TOKEN+SQUARE_LOCATION_ID);StripeProviderraisesNotImplementedError. When no provider is connected, generation falls back toprovider='none'andstatus='draft'(orsent) 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).