Skip to Content
DevelopersArchitecture

Architecture

PennyPlan v2 is two applications that talk only over HTTP, sharing one PostgreSQL database (owned by the API) and one Auth0 tenant.

Browser (PWA) ──HTTPS /v1──▶ pennyplan-api (NestJS) ──▶ PostgreSQL IndexedDB outbox │ ├──▶ Redis / BullMQ ──▶ workers (AI, notifications, digests) ├──▶ S3 (voice notes, receipts, exports) └──▶ WhatsApp Cloud API (webhook in, replies out)

The API is a modular monolith

One deployable, divided into modules with clear responsibilities. Services are split out only when load demands it.

ModuleResponsibilityStatus
healthLiveness and readinessBuilt
authAuth0 JWT validation, rolesPlanned
usersProfile, language, preferences, consentPlanned
ledgerEntries, accounts, categories, dedupe, syncPlanned
captureTurns raw input into a draft entryPlanned
budgetsBudgets and safe-to-spendPlanned
insightsSummaries, weekly digest, assistantPlanned
notificationsScheduling, delivery, quiet hoursPlanned
whatsappWebhook, number linking, repliesPlanned
aiProvider adapters, PII stripping, cost trackingPlanned

Conventions built into the API today

  • Everything is under /v1. Breaking changes go to /v2 or behind a flag.
  • One error shape: every error is { "code", "message", "details" }. Internal errors never leak details.
  • Validated configuration. The environment is checked once at boot and the process refuses to start if anything is missing or invalid.
  • User-scoped data access. Services obtain a database client with PrismaService.forUser(userId), which adds the user’s id to every query and refuses to touch any model that has not been explicitly marked as user-scoped. A forgotten model fails loudly instead of silently exposing data.

Offline-first by design

The web app writes entries locally first (IndexedDB) with client-generated UUIDs, then syncs through an outbox. The server de-duplicates by that UUID, so retries and reconnects never create duplicates.

Capture pipeline

Every input channel (text, voice, forwarded bank alert, receipt photo) ends in the same place: a draft entry the user confirms. Cheap, deterministic parsing always runs first; a model is used only when that fails or is unsure.

Last updated on