TailSpend Docs

Architecture

TailSpend is a Splitwise-style expense sharing product with stronger group controls and explicit spending limits. The system is split into three tiers:

A single TypeScript monorepo (Turborepo) holds clients, services, infrastructure, and a generated API contract that is the source of truth across all tiers.

An optional receipt-scanning subsystem layers AI expense prefill on top of this base (PP-OCRv6 small image and rendered-PDF OCR + Ministral 3 8B on Bedrock, metered with ad-funded credits, backed by an S3 receipts bucket and a credit balance/ledger folded into the user table). It is additive and respects every contract above; see receipt-scanning.md for its design and plan.

High-level diagram

TailSpend system overview

How a request reaches a function:

TailSpend API request routing

The uncached developer-token authorizer loads only verification dependencies; management/validation schemas stay in the core APIs. Service Lambda artifacts retain compact TypeScript source mappings without embedded source text. New Relic's slim layer and ESM wrapper remain enabled, with no provisioned concurrency. See the cold-start investigation.

Backend New Relic release tags use the last backend-affecting commit, resolved from full Git history in the deployment workflow. Web/docs/CI-only changes that leave backend and shared build inputs untouched therefore do not change Lambda environment configuration just to stamp a new deployment SHA. Shared build and lockfile changes are included conservatively; frontend release IDs remain the full deployment commit.

The optional startup investigation workflow copies deployed beta bundles/configuration into temporary, unexposed Lambda functions using the existing beta execution roles. They retain New Relic's layer, wrapper and SSM licensing, import application modules without invoking business handlers, and compare CPU allocations. The probe stack and its logs are deleted after measurement, with an additional workflow cleanup step. Only numeric timings and aggregate telemetry results are retained for two days. Production functions and API routes are not modified.

Temporary beta startup probes and telemetry

How notifications are recorded and pushed (see notifications.md):

TailSpend notifications

The diagrams are generated from diagrams/; edit the source and re-render rather than editing the images.

An explicit [client-release] main commit extends the verified production pipeline to all changed clients. The same commit is used for native builds, store submissions, OTA publication and desktop installers; native completion gates OTA. Android compilation prepares bounded, regenerable Gradle cache space while retaining the required ARM gate. See delivery. For native release recovery, [client-release] [local-android] builds the production Android bundle on the existing Android runner with EAS-managed signing credentials, while iOS uses EAS cloud. Both results gate OTA publication and the native released tag. The default remains cloud builds for both platforms.

Guest and Invitation Lifecycle

All clients use stable participant IDs for expenses. An invitation reserves an assignable guest in GroupData and records its ID in People in one transaction; acceptance consumes that invitation and claims the same participant with its unique account membership. Shared-link guest claims retain owner approval and cannot take an active invitation's reservation. Bulk history transfers lock the source, move expenses, settlements and recurring templates with concurrency checks, and remove/unlock the source only on completion. Financial writes check participant existence and transfer state transactionally. This reuses the current core API and tables; see the user flow and persistence rules.

OpenAI MCP access

The optional OpenAI MCP integration adds one bounded Lambda and protocol/ OAuth adapter routes to the existing developer REST API when exact stage callbacks are configured. Cognito continues signing tokens. The adapter binds token families and rotated refresh hashes to per-connection group grants in the existing Users table. App consent and disconnect controls use the app core API; the MCP and REST handlers share the extracted developer policy/execution code. No TailSpend model inference or credit charge occurs for structured MCP tools. Deployment is gated by explicit stage enablement and live beta compatibility checks.

OpenAI MCP and Cognito reference architecture

Component responsibilities

Clients

Client Stack Hosting Purpose
Mobile Expo SDK + React Native + TypeScript EAS for builds, App Store / Play Store for distribution Primary day-to-day app for capturing expenses and settling up
Web Vite + React + TypeScript AWS CloudFront + S3 (per-stage bundle) Sign-up landing, group dashboards, settle-up flows, settings
Desktop Electron + Vite + React + TypeScript macOS installers / Microsoft Store Shared app UI with native auth, window controls and updates
Docs Generated static HTML + Swagger UI AWS CloudFront + private S3 (per stage) Help, repository documentation and API references

All app clients consume the typed @repo/api-client package (generated from the OpenAPI spec) so request and response shapes stay aligned with the server.

Authentication is OIDC against Cognito. The clients perform the Authorization Code flow with PKCE against the Cognito Hosted UI and store tokens with the platform's secure store (Keychain / Keystore on mobile, the shared browser auth store for the web app).

The web CloudFront distribution enforces a Content-Security-Policy on every response, including SPA fallback pages. Scripts and fonts load only from the app's origin; connections are limited to that origin, generated blob URLs for exports, the stage's app API and Cognito domain, the fixed New Relic beacon host and the stage's regional receipts bucket for presigned uploads. Images are local, data or blob URLs. Inline React/Radix positioning styles remain allowed, while inline scripts, eval, objects, frames, base URL overrides and form posts are blocked. Web startup selects Zod's non-JIT parser, then dynamically imports the application bootstrap so Vite's shared schema chunks cannot execute before that configuration. New Relic's npm-installed lazy chunks remain local, so its CDN needs no script exception.

The desktop renderer loads the same optional @repo/browser-monitoring agent as web, using its own New Relic application id from the stage's SSM config. Renderer errors, page/navigation timing and API timing go directly to New Relic; API requests carry distributed-tracing headers. The desktop CSP pins connections to its API and Cognito origins and bam.nr-data.net. Telemetry identifies the desktop platform, stage and checked-out release, with local installation paths and credential query values redacted. See observability for configuration and privacy limits.

Identity (Amazon Cognito)

API tier (API Gateway + Lambda)

Authorization happens inside each Lambda by reading participant rows in the GroupData table (see database.md). A Query on the UserParticipantsIndex GSI resolves (authenticated UserId, requested GroupId) → ParticipantId, Role, and the role gates the action:

Public invite reads are unauthenticated: clients hit /groups/{groupId}/invite/... without a JWT, the invite Lambda reads (<gid>, "INVITE") from GroupData, returns 404 if the row's Enabled flag is false, and otherwise serves a read-only view of the group. There is no token in the URL — the path itself is the capability, and OWNERs can disable the invite at any time. To turn into a participant, the visitor signs in and submits a join request, which an OWNER approves with a TransactWriteItems across the JOINREQ#… row and the PARTICIPANT#… row in GroupData (flipping a guest from GUEST to USER, or creating a fresh USER participant).

The core API reads People block edges before disclosing another user's public identity or accepting a join request. Join checks cover every current OWNER; the notification worker rechecks those edges before delivery. Denial atomically replaces the applicant's pending lock in GroupData with a persistent denial guard, preventing tokenless resubmission. A valid invite may replace that guard but cannot overwrite a pending request.

Data tier (DynamoDB)

DynamoDB tables, all on-demand billing. See database.md for keys, GSIs, and access patterns. The main ones are below; DeveloperTokens, People and Notifications (devices, preferences, history and the streamed outbox) are described there too.

Comments are first-class rows in the same partition as their expense. A single Query returns the expense and its full thread; concurrent comment writes don't contend on a list attribute, and individual comments can be edited or deleted as standalone items.

Receipt scanning & credits

The receipt-scanning subsystem lets a user photograph or upload a receipt and have an expense draft prefilled by AI, metered with ad-funded credits. It introduces:

The #694 replacement is implemented in source but not deployed. Its Python helper reads only receipt objects and has no model or user-table access. The 1 GiB Node worker still bills while awaiting OCR/Bedrock; Step Functions is a separate orchestration follow-up. PDF rendering now runs locally; representative PDF financial accuracy and deployed Lambda timings still require evaluation.

Full design, data model, and phased plan: receipt-scanning.md.

Notifications

Expense creation/edits/deletion, settlement, join-request, structured comment mention, friend-request and direct-invitation writes add an outbox EVENT row to the Notifications table in the same TransactWriteItems as the change. The table's stream (filtered to new EVENT rows) invokes the notification-worker Lambda, which works out recipients from the current roster, writes one history entry per recipient, and sends pushes through the Expo Push Service (APNs / FCM) when the stage's SSM switch /tailspend/<stage>/notifications/push-enabled is true. Records that keep failing go to an SQS failure queue. A scheduled notification-maintenance Lambda (EventBridge, every 15 minutes) checks Expo push receipts and re-dispatches events the stream missed. The hourly recurring-expense job conditionally adds one reminder per upcoming template/slot within 24 hours. Dispatch rechecks current membership, pending People state and account/category/group preferences; mutes preserve history. People balance reads report request-scoped latency and DynamoDB read capacity to New Relic. Full design: notifications.md.

Infrastructure & build

Environments

Environment Stage tag Notes
dev dev Per-developer or shared, deployed manually from a workstation
prod prod Single production account, currently us-east-1

Stage drives DynamoDB table names (tailspend-{stage}-{table}) and Cognito domain prefixes. Future work: split AWS accounts per environment and move deploys behind GitHub Actions.

Cross-cutting concerns

Repository map

tailspend/
├── apps/
│   ├── web/             # Vite + React SPA (CloudFront + S3)
│   ├── mobile/          # (planned) Expo app
│   ├── services/        # Lambda handlers + domain services
│   └── infra/           # AWS CDK app
├── packages/
│   ├── api-schema/      # Zod schemas (source of truth)
│   ├── openapi/         # generates OpenAPI JSON from api-schema
│   ├── api-client/      # generated typed client (openapi-fetch)
│   ├── ui/              # shared React UI primitives
│   ├── eslint-config/
│   └── typescript-config/
└── docs/
    ├── architecture.md  # this file
    ├── api.md           # API reference
    └── database.md      # DynamoDB schema and access patterns

Native Release Validation

Both mobile platforms retain Expo's default Hermes V1. Android shipping binaries contain only ARM32/ARM64 libraries. The native workflow requires ARM compilation and packaged-ABI checks on the Intel build host; its x86 emulator UI suite is advisory because that architecture is outside the app's shipping support scope. ARM runtime/push/OCR and iOS acceptance remain separate release checklist gates; a cross-build cannot execute ARM code. See CI and the release runbook.

Build and delivery validation

Expense chat

The expense chat subsystem uses dedicated JWT route functions, a bounded Mistral Large 3 worker using one native constrained-JSON InvokeModel call over accepted server-owned conversation turns, temporary UserData sessions/outbox tasks, and a one-minute EventBridge recovery rule. Preparation rejects duplicate normalized participant names before admission or inference. The model copies major-unit decimal strings; server-side integer arithmetic converts amounts and FX charges using their respective expense/group currency exponents, deriving same-currency charges directly from paid amounts. Admission atomically spends credits and reserves a global dollar budget. An SSM switch and configurable contribution-margin gate control activation; production starts disabled and the pipeline enables it after live validation using the approved $1 net eCPM assumption and a 20% cost buffer. Confirmation alone writes expenses through the existing service with an atomic session marker for duplicate-save protection. Post-activation production smoke uses an ephemeral account at the real price to verify paid inference, credit deduplication, at-most-once saving and persisted data before client publishing. The strict JSON contract uses a 4,000-input/1,000-output-token bound with a USD 0.00400 minimum variable-cost reservation; launch requires 25 credits at the approved revenue assumption and blocks costs above that price ceiling.

Expense chat data flow

Changed chat backends are live-validated in dev and beta before promotion, with bounded paid evaluation and temporary, expiring synthetic-user access. Explicit stage test conditions tolerate skipped optional ancestors, while a final rollout check requires successful production deployment/tests and requested chat activation before client publishing. Production ignores validation allowlists and retains its cost/revenue-assumption margin gate. See release validation.

The standard infra deploy command provisions a configured MCP stage’s Auth stack exclusively before the remaining stacks, ensuring the Cognito client ID exists before Core resolves it from SSM. Beta uses OpenAI’s stable OAuth callback with RFC 9207 issuer identification; dev and production MCP remain disabled. See MCP rollout.

View Markdown source