Appearance
Crest — Technical Design
| Product | Crest — Personal Finance App |
| Document version | 0.13 (Draft) — local-first: the app works on its own with a database on the device; the API, login, and sync are for invited people |
| Date | 2026-10-05 |
| Based on | BusinessRequirements.md v0.23, UserJourneys.md v0.12, Conventions.md v0.3 |
| Owner | Reizkian Y. Radityatama |
| Status | Draft — pending review |
This document describes how Crest is built: stack, architecture, data model, security, sync, deployment, and operations. The Business Requirements Document (BRD) says what Crest does and the User Journeys say how people use it; where this document disagrees with them, they win and this document must be fixed.
Terminology. As in the other documents, the word "account" is never used on its own for Crest concepts.
- User: a person who can log in (table
app_user).- Financial account: cash wallet, bank, e-wallet, or credit card (table
financial_account).- Space: container of financial accounts, categories, budgets, and goals, with members (tables
space,space_member).
Contents
- Summary of decisions
- Architecture
- Technology stack
- Repository layout
- Data model
- Money, currencies, and calculations
- Authorization and row-level security
- Authentication
- Application design
- Local data, sync, and backup
- App distribution, notifications, and email
- Background jobs
- Infrastructure and deployment
- Backups and recovery
- Observability
- Security checklist
- Testing strategy
- Delivery plan
- Open technical questions
- Requirements traceability
1. Summary of decisions
| Topic | Decision | Main reason |
|---|---|---|
| Architecture | Local-first. The Crest app always keeps its own database on the device and works without the API (local mode, no login, open to anyone). Connected mode adds login and sync for invited people. Backend completely separate from the frontends: one API, three clients (the Crest app, the admin portal, the public site) | Anyone can use Crest at no running cost; the app can ship before the API exists (FR-MOD-*); owner's decision to keep a clear contract between backend and clients |
| Backend | NestJS (TypeScript) REST API, described by an OpenAPI specification | Structured, well known to the owner, generates the API contract |
| Users' app | Flutter, one codebase in Apps/Mobile. The first release is the web build, a Progressive Web App (PWA) that runs in the browser and can be added to the home screen. Native Android and iPhone apps can be built from the same code when there is demand (FR-APP-8) | No store accounts, fees, or reviews at launch; one codebase for web now and native later |
| Admin portal | Angular single-page app (web) | Small desktop tool; same language as the API |
| Public site | Static HTML pages: landing, request access, activate, reset password, install, privacy, terms | No framework needed; works before the user has opened the app |
| API clients | Generated from the OpenAPI spec: Dart client for Flutter, TypeScript client for Angular | Clients can't drift from the API |
| Hosting | One self-managed Biznet Gio NEO Lite VPS in Indonesia, Ubuntu 24.04 LTS, 2 GB RAM | NFR-13, NFR-15 |
| Web addresses | crest.radityatama.web.id (public site), app.crest.radityatama.web.id (the Crest app), api.crest.radityatama.web.id (API), admin.crest.radityatama.web.id (admin portal) | NFR-17 |
| Database | PostgreSQL 17, same VPS, never exposed to the internet | NFR-2, NFR-15 |
| Data access | Hand-reviewed SQL migrations applied by a small runner (Apps/Api/Src/Database/Migrations.ts); queries through pg, with a typed query layer (Drizzle) added alongside the first services in M1 | The schema, policies, and functions are plain SQL that can be read and reviewed |
| Authorization | Postgres row-level security (RLS) + separate DB roles for user endpoints, admin endpoints, and the worker | NFR-2, FR-ADM-17 enforced by the database |
| Authentication | Connected mode only (local mode has no login). Built into the API: email + password (argon2id), short-lived access tokens + rotating refresh tokens kept in an HttpOnly cookie, TOTP 2FA for Super Admins; no sign-up, no external providers | FR-AUTH-*, BR-20 |
| App lock | A Crest PIN. Device biometrics are added with the native apps | FR-AUTH-6 |
| Background jobs | pg-boss (queue stored in Postgres) in a separate worker process | No Redis needed; fits 2 GB RAM |
| Web server / HTTPS | Caddy: reverse proxy for the API, file server for the Crest app, the admin portal, and the site | Automatic certificates; simplest to run |
| Deployment | Docker Compose on the VPS; images and static bundles built by GitHub Actions | NFR-16 |
Brevo for sending; how contact@ receives mail is not decided yet (§11.3) | Deliverability; $0 | |
| DNS | Biznet NEO DNS (free with the domain); domain registered at Biznet Gio | Already the nameserver; $0 |
| Notifications | At launch: the notification list inside the app, plus email. Web Push (the browser standard, no third-party service) in Phase 2 | FR-NOT-3; works in an installed PWA on iPhone 16.4+ and Android |
| Backups | Connected users: nightly encrypted pg_dump → Biznet NEO Object Storage. Local-mode users: a backup file they save themselves | NFR-10, FR-MOD-5 |
| Monorepo | One repository: Apps/Api, Apps/Mobile, Apps/Admin, Apps/Site, Apps/Docs (pnpm workspaces + Turborepo for the TypeScript apps) | Owner's preference; one place for the contract and docs |
| Naming | PascalCase files in every app, replacing each framework's default | Conventions.md |
2. Architecture
2.1 Overview
The API is the only thing that touches the server database. The Crest app, the admin portal, and the public site are separate programs that talk to it over HTTPS. The API still enforces every rule, including on data uploaded from a device, so a client never has to be trusted.
In local mode, the Crest app talks to no API at all. It loads its static files from app.crest.radityatama.web.id and then reads and writes only the database on the device (§10). The arrows to the API in the diagram apply to connected mode, and to the admin portal and the public site's forms.
2.2 Processes on the server
| Container | Image | Purpose | Exposed |
|---|---|---|---|
caddy | caddy:2 | HTTPS, certificates, reverse proxy to api, file server for the Crest app, the admin portal, and the public site | 80, 443 |
api | ghcr.io/…/crest-api | NestJS REST API | Internal 3000 |
worker | same image as api, started with WorkerMain | Scheduled and queued jobs | None |
postgres | postgres:17 | Database | None (internal network only) |
backup | small image with pg_dump, age, rclone | Nightly backup upload | None |
The Crest app, the admin portal, and the public site are static files on the server (/srv/app, /srv/admin, /srv/site); they need no running process.
2.3 Hosts
| Host | Serves | Used by |
|---|---|---|
crest.radityatama.web.id | Public site: landing, /request-access, /activate, /reset-password, /install, /privacy, /terms | Visitors, invited users |
app.crest.radityatama.web.id | The Crest app: the Flutter web build, installable as a PWA. Also serves /app-config.json (§11.1) | Everyone (local mode), invited users (connected mode) |
api.crest.radityatama.web.id | The API: /v1/*, /health | Crest app, admin portal, public site forms |
admin.crest.radityatama.web.id | Admin portal (Angular) | Super Admins |
Activation and password-reset links open pages on the public site, so they work in any browser, before the user has ever opened the app. After activating, the page shows an Open Crest button that leads to the app.
The app has its own host, separate from the public site, so that its cached files, its home-screen icon, and its Content-Security-Policy are independent of the site's pages.
2.4 API principles
- REST + JSON, versioned under
/v1. Paths are lowercase kebab-case plural nouns (/v1/financial-accounts); JSON fields are camelCase. - The OpenAPI specification is the contract. It is generated from the NestJS code, committed as
Packages/ApiContract/OpenApi.json, and used to generate the Dart and TypeScript clients. CI fails if the committed spec is out of date. - Errors use one shape (
type,title,status,detail,code,requestId, optional field errors), with stablecodevalues the clients translate (the API never returns user-facing sentences). - Lists are cursor-paginated. Creates are idempotent: clients send the new row's UUID, so a retried request doesn't create a duplicate.
- CORS allows only the Crest app, admin, and public-site origins, with credentials (the refresh cookie, §8.3).
- Every request from the Crest app carries its app version; the API can refuse versions that are too old (§11.1).
3. Technology stack
Versions are the current stable/LTS releases at the start of development and are pinned in lockfiles.
3.1 API and worker (Apps/Api)
| Area | Choice | Notes |
|---|---|---|
| Language / runtime | TypeScript (strict) on Node.js LTS | |
| Framework | NestJS | Modules per domain (§4) |
| API description | @nestjs/swagger → OpenAPI 3.1 | Source of the generated clients |
| Validation | class-validator + class-transformer DTOs, global validation pipe (whitelist on) | DTOs also feed the OpenAPI spec |
| Database | PostgreSQL 17 + extensions pgcrypto, pg_trgm, citext | |
| Migrations | SQL files in Apps/Api/Migrations (0001_InitialSchema.sql, …), applied in order by Migrations.ts, which records a checksum so an applied migration is never edited. RLS policies, triggers, and functions are hand-written SQL | |
| Queries | pg; a typed query layer (Drizzle) is added with the first services in M1 | |
| Passwords, PIN | argon2 (argon2id) | |
| Tokens | @nestjs/jwt for access tokens; random refresh tokens stored hashed | §8 |
| Two-factor | otplib (TOTP) + hashed backup codes | Super Admins |
| Rate limiting | @nestjs/throttler | Login, reset, access requests |
| Jobs | pg-boss | Queue + cron schedules in Postgres |
| Nodemailer over Brevo SMTP; Handlebars templates in English and Bahasa Indonesia | ||
| Push | web-push (Web Push standard, Phase 2) | §11.2 |
| Logging | pino (structured JSON) | No financial data in logs |
| Testing | Jest, Supertest, Testcontainers (Postgres) | §17 |
3.2 The Crest app (Apps/Mobile)
The folder keeps the name Apps/Mobile: the app is designed for phones, and the native Android and iPhone projects live in the same folder.
| Area | Choice | Notes |
|---|---|---|
| Framework | Flutter (stable channel), Dart | Web (PWA) is the released target; android/ and ios/ stay in the project for development and for the later native apps |
| Web build | flutter build web --release, served as static files from app.crest.radityatama.web.id | About 3 MB compressed on first visit, then cached (NFR-4) |
| PWA | web/manifest.json (name, icons, display: standalone, theme colours) and a service worker that caches the app's files | Add to home screen (FR-APP-2), opens offline (FR-APP-4) |
| Layout | Phone-width: on wide screens the app is centred in a phone-sized frame (lib/Core/Widgets/PhoneFrame.dart) | FR-APP-6 |
| State management | Riverpod | |
| Navigation | go_router | Bottom tabs + nested routes; Stats state in route parameters (§9.4) |
| API client | Generated from OpenApi.json with openapi-generator (dart-dio), wrapped in lib/Core/Api. Used only in connected mode, through the sync layer | Adds auth and refresh handling; requests are sent with credentials so the refresh cookie travels |
| Session | Connected mode only: access token in memory; refresh token in an HttpOnly cookie the app's code can't read | §8.3 |
| App lock | PIN screen (optional in local mode) | §8.4. local_auth (Face ID, fingerprint) is added with the native apps |
| Local database | drift (SQLite compiled to WebAssembly, stored in the browser's private storage) | The primary store in both modes: the only copy in local mode, the working copy in connected mode (§10) |
| Backup file | JSON written and read in Dart, shared through share_plus or a file download | §10.3 |
| Storage persistence | navigator.storage.persist() through dart:js_interop, and a check for display mode standalone | §10.3, FR-MOD-6, FR-MOD-7 |
| Charts | fl_chart | Donut, bars; the calendar heatmap is a custom grid |
| Localization | flutter_localizations + intl, ARB files (app_en.arb, app_id.arb) | |
| Notifications | Connected mode only: in-app list (Phase 1b); Web Push through the service worker (Phase 2) | §11.2 |
| Sharing | share_plus | CSV export through the device share menu, or a file download where the browser has no share menu |
| Theme | Material 3 with the design tokens from the prototype (seed #4C662B, light and dark); fonts Newsreader and Geist bundled as assets | |
| Testing | flutter_test (unit, widget), integration_test | §17 |
3.3 Admin portal (Apps/Admin)
| Area | Choice | Notes |
|---|---|---|
| Framework | Angular 21 (standalone components, signals), TypeScript 5.9 | Angular 22 needs Node 24.15+; upgrade when the development machine and CI have it |
| UI | Angular Material | Tables, forms, dialogs |
| API client | Generated from OpenApi.json (typescript-angular) | |
| Localization | JSON files in Public/i18n (en.json, id.json) | |
| Testing | Angular's test runner for units; Playwright for the key flows |
3.4 Public site (Apps/Site)
Plain HTML, CSS, and a little JavaScript, no framework and no build step. Forms (request access, activate, reset password) call the API with fetch. Pages exist in English and Bahasa Indonesia.
3.5 Shared and infrastructure
| Area | Choice |
|---|---|
| API contract | Packages/ApiContract/OpenApi.json (generated, committed) |
| Money test vectors | Packages/MoneyTestVectors/*.json: input → expected output cases run by both the API's Jest tests and the app's Dart tests (§6) |
| Monorepo tooling | pnpm workspaces + Turborepo for Apps/Api and Apps/Admin; Flutter's own tooling for Apps/Mobile |
| Lint / format | ESLint + Prettier (TypeScript); flutter analyze + dart format (Dart); Scripts/CheckFileNames.ts for every file |
| CI/CD | GitHub Actions + GitHub Container Registry (GHCR) |
| Reverse proxy | Caddy 2 |
| Backups | pg_dump, age (encryption), rclone (S3 upload) |
| Uptime | UptimeRobot (free) |
4. Repository layout
File and folder names follow Conventions.md: code and document files in PascalCase in every app (replacing the NestJS, Angular, and Dart defaults); every folder in PascalCase, except the names a tool fixes (src, lib, test, l10n, web) and the static assets and public site in lowercase kebab-case; tool files (e.g. package.json, pubspec.yaml, Dockerfile) with their standard names.
crest/
├─ Apps/
│ ├─ Api/ # NestJS API + worker
│ │ ├─ Src/
│ │ │ ├─ Main.ts # API entry
│ │ │ ├─ WorkerMain.ts # worker entry (pg-boss)
│ │ │ ├─ AppModule.ts
│ │ │ ├─ Auth/ # AuthController.ts, AuthService.ts, TokenService.ts, TotpService.ts,
│ │ │ │ # Guards/JwtAuthGuard.ts, Guards/SuperAdminGuard.ts, Dto/LoginBody.ts
│ │ │ ├─ AccessRequests/ Admin/ Users/ Spaces/ FinancialAccounts/ Transactions/ Transfers/
│ │ │ ├─ Categories/ Budgets/ Goals/ Stats/ Export/ Notifications/ Jobs/ Health/
│ │ │ │ # each: XxxModule.ts, XxxController.ts, XxxService.ts, Dto/
│ │ │ ├─ Bootstrap/ Config/ # AppBootstrap.ts (routes, validation, CORS, OpenAPI); Environment.ts, EnvironmentLoader.ts
│ │ │ ├─ Database/ # DatabaseModule.ts (one pool per role), WithUser.ts, Migrations.ts
│ │ │ ├─ Money/ # Money.ts, Currencies.ts, Conversion.ts, BudgetPeriods.ts, Spending.ts
│ │ │ ├─ Emails/ # EmailService.ts, Templates/ActivationEmail.hbs, …
│ │ │ └─ Scripts/ # CreateSuperAdmin.ts, ResetTwoFactor.ts, SeedDemoData.ts, GenerateOpenApi.ts
│ │ ├─ Migrations/ # 0001_InitialSchema.sql, 0002_RowLevelSecurity.sql, …
│ │ ├─ Test/ # Unit/ (mirrors Src/), Integration/ (Database/RowLevelSecuritySpec.ts, …), Support/
│ │ ├─ nest-cli.json, package.json, tsconfig.json, Dockerfile
│ ├─ Mobile/ # the Crest app (Flutter): web/PWA now, native later
│ │ ├─ lib/
│ │ │ ├─ main.dart # thin entry required by Flutter → CrestApp.dart
│ │ │ ├─ CrestApp.dart, AppRouter.dart
│ │ │ ├─ Core/ # Database/ (drift schema, migrations), Money/ (MoneyFormatter.dart, …),
│ │ │ │ # Api/ + Sync/ (connected mode only), Theme/ (CrestTheme.dart), Storage/, Widgets/
│ │ │ ├─ Features/ # Welcome/ Onboarding/ Home/ Activity/ AddTransaction/ Stats/ Budgets/
│ │ │ │ # FinancialAccounts/ Categories/ Goals/ Recurring/ Backup/ Settings/
│ │ │ │ # connected mode: Connect/ (login, upload) Spaces/
│ │ │ │ # e.g. Welcome/WelcomePage.dart, Backup/BackupPage.dart, Stats/StatsPage.dart
│ │ │ └─ Generated/ # generated API client and localization classes
│ │ ├─ l10n/ # app_en.arb, app_id.arb
│ │ ├─ assets/ # fonts/, images/ (lowercase kebab-case)
│ │ ├─ Test/ # mirrors lib/, e.g. Core/Money/MoneyFormatter_test.dart
│ │ ├─ integration_test/ # end-to-end journeys, e.g. LogExpenseJourney_test.dart
│ │ ├─ web/ # web runner: index.html, manifest.json, icons/ (names fixed by Flutter)
│ │ ├─ android/, ios/ # platform projects generated by Flutter (native apps, later)
│ │ └─ pubspec.yaml, analysis_options.yaml, l10n.yaml
│ ├─ Admin/ # Angular admin portal
│ │ ├─ Src/
│ │ │ ├─ Main.ts, Index.html, App.ts, AppRoutes.ts
│ │ │ ├─ Core/ # Api/ (generated client), Auth/ (AuthService.ts, AuthGuard.ts, TokenInterceptor.ts)
│ │ │ └─ Features/ # Login/ Overview/ AccessRequests/ Users/ AuditLog/
│ │ │ # e.g. AccessRequests/AccessRequestList.ts + .html + .scss
│ │ ├─ Public/i18n/ # en.json, id.json
│ │ └─ angular.json, package.json, tsconfig.json
│ ├─ Site/ # static public pages (lowercase kebab-case)
│ │ ├─ index.html, request-access.html, activate.html, reset-password.html,
│ │ │ install.html, privacy.html, terms.html (+ id/ for Bahasa Indonesia)
│ │ └─ assets/ # css/, js/, images/
│ └─ Docs/ # the documentation site (VitePress)
│ ├─ Product/, Engineering/, Legal/ # the planning documents, one folder per section
│ ├─ Database/Overview.md, Index.md # the rest is generated into Database/tables/
│ ├─ .vitepress/ # site configuration and theme
│ └─ Data/ # database documentation settings and requirement mapping
├─ Packages/
│ ├─ ApiContract/ # OpenApi.json (generated from the API, committed)
│ └─ MoneyTestVectors/ # Parsing.json, Formatting.json, Conversion.json, BudgetPeriods.json
├─ Infra/
│ ├─ docker-compose.yml # production services
│ ├─ docker-compose.dev.yml # local Postgres + mail catcher
│ ├─ Caddyfile
│ ├─ Backup/ # Dockerfile + Backup.sh
│ ├─ ServerSetup.md # VPS hardening steps (runbook)
│ └─ Runbooks/ # RebuildServer.md, RestoreBackup.md
├─ Scripts/ # CheckFileNames.ts, FileNamingRules.ts (naming enforcement)
├─ logo/ # brand assets
├─ .github/workflows/ # ci.yml, deploy.yml
├─ CLAUDE.md, README.md, lefthook.yml
└─ turbo.json, pnpm-workspace.yaml, package.json, eslint.config.mjsThe money rules in Apps/Api/Src/Money have no dependency on NestJS or the database, so they are unit-tested in isolation; the app's lib/Core/Money mirrors the parts it needs and is tested against the same vectors (§6).
5. Data model
5.1 Conventions
Standard columns. Every business table has the same six columns, and they are not repeated in the table descriptions below:
| Column | Type | Meaning |
|---|---|---|
id | uuid PK | May be generated by the client (UUIDv7, time-ordered), so offline creation and retries are safe; otherwise the database fills it with gen_random_uuid() |
created_at | timestamptz not null, default now() | When the record was created. For transactions it is also the transaction's date and time and the user may change it (e.g. logging yesterday's coffee today); for every other table it is set automatically |
created_by | uuid → app_user null, ON DELETE SET NULL | Who created it. Null = created by the system, or the user was deleted ("Former member", BR-34) |
updated_at | timestamptz not null, set by trigger | Last change; also the sync cursor (§10) |
updated_by | uuid → app_user null, ON DELETE SET NULL | Who made the last change ("Edited by", FR-SPC-7) |
deleted_at | timestamptz null | Soft delete, so devices learn about deletions (§10). Rows are hard-deleted by a job after 90 days |
Tables that use all six: space, space_invitation, financial_account, category, txn, transfer, budget, savings_goal, goal_contribution, recurrence, access_request.
Exceptions:
| Table | Columns | Why |
|---|---|---|
audit_log | id, created_at, created_by only | Append-only; never updated or deleted |
space_member, exchange_rate, device_push_token, contribution_payment | Standard columns without deleted_at | Link or settings rows; removed with a normal delete |
app_user | Standard columns without deleted_at (and created_by/updated_by null for self-service changes) | Deleted users are removed, not soft-deleted (BR-11) |
auth_session, auth_token, auth_totp | Standard columns without deleted_at | Security records; removed with a normal delete |
Other conventions
- Money:
bigintin minor units; never floating point (NFR-1). The currency comes from the owning financial account or space and is not copied onto each row. See §6. - Exchange rates:
numeric(24,12). Rates are not money and are never used to store balances. - Time: all timestamps are
timestamptz, stored in UTC and shown in the viewer's time zone (NFR-8). Budget periods and reports turn a transaction'screated_atinto a calendar date using the space's time zone (space.time_zone), so all members of a space see a transaction in the same month. - Enums: Postgres enum types for small fixed sets. Their values are PascalCase (
Pending,CreditCard), the same spelling the API sends and receives, so nothing translates between the database and the clients. - Deliberate copies:
txn.space_idduplicates the financial account's space because security rules filter every query on it (§7). A trigger keeps it correct. It is the only intentional copy.
5.2 Entity relationship overview
(txn is the transaction table; transaction is avoided as a table name because it is an SQL keyword.)
5.3 Identity and access tables
Only the columns beyond the standard ones (§5.1) are listed.
app_user — one row per person.
| Column | Type | Notes |
|---|---|---|
| name | text | |
| citext unique | BR-9 | |
| password_hash | text null | argon2id; null until the user activates |
| email_verified | boolean | True after activation |
| pending_email | citext null | New address waiting for confirmation (§8.6) |
| status | enum user_status (Invited, Active, Deactivated) | "Pending"/"Rejected" live on access_request; "Deleted" = row removed |
| is_super_admin | boolean default false | BR-41 |
| base_currency | char(3) | FR-SET-1 |
| language | text (en, id) | |
| time_zone | text (IANA, e.g. Asia/Jakarta) | Set from the device on first login |
| theme | enum (Light, Dark, System) | FR-SET-2 |
| daily_reminder_time | time null | FR-NOT-1 |
| pin_hash | text null | App-lock PIN (see §8.4) |
| last_login_at | timestamptz null | FR-ADM-8 |
| paid_until | date null | Phase 4 (FR-SUB-1) |
| contribution_exempt | boolean default false | Phase 4 (FR-SUB-2) |
Authentication tables (standard columns without deleted_at):
| Table | Columns | Notes |
|---|---|---|
auth_session | user_id, client (Mobile = the Crest app, Admin), refresh_token_hash, device_name, two_factor_verified, last_used_at, expires_at, revoked_at | One row per logged-in device; deleting rows logs the user out (§8) |
auth_token | user_id, purpose (Activation, PasswordReset, EmailChange), token_hash, expires_at, used_at | Single-use links (BR-10) |
auth_totp | user_id (unique), secret_encrypted, confirmed_at, backup_code_hashes | Two-factor for Super Admins |
There are no tables for external login providers (BR-20).
access_request
| Column | Type | Notes |
|---|---|---|
| full_name, email (citext), country (char(2)), message | FR-REQ-1 | |
| consent_at | timestamptz | Privacy consent (NFR-3) |
| status | enum (Pending, Approved, Rejected) | Who decided and when = updated_by / updated_at |
| rejection_note | text null | Internal only; never emailed (FR-REQ-5) |
| user_id | uuid → app_user null | Set on approval |
| source_ip_hash | text | For rate limiting, hashed |
Partial unique index: one pending request per email. The 30-day rule (BR-14) is checked in the service.
audit_log — append-only (NFR-12). Columns: id (bigint identity), created_at, created_by (the acting Super Admin, or null for system/scripts), plus:
| Column | Type | Notes |
|---|---|---|
| action | text | e.g. RequestApprove, UserDeactivate, UserSuperAdminGrant, AdminTwoFactorReset |
| target_user_id | uuid null | Not a foreign key, so entries survive user deletion |
| target_request_id | uuid null | |
| details | jsonb | Never financial data or space details (FR-ADM-17) |
| ip_hash | text null |
UPDATE/DELETE are revoked from every role, and a trigger rejects them anyway. A monthly job may delete entries older than the retention period (≥ 1 year) using a dedicated function.
5.4 Spaces
space
| Column | Type | Notes |
|---|---|---|
| kind | enum (Personal, Shared) | |
| name | text | Personal spaces display as "Personal" |
| currency | char(3) | FR-SPC-12; for personal = user's base currency |
| time_zone | text (IANA) | For turning transaction times into dates (§5.1). Personal = user's time zone; shared = creator's, changeable by Owners |
| budget_start_day | smallint 1–28 | FR-SET-3, BR-4 (29–31 not allowed for simplicity) |
Constraint: one personal space per user (unique index on created_by where kind = 'personal'). A trigger keeps a personal space's currency and time zone equal to the user's settings.
space_member (standard columns without deleted_at; created_at = when they joined)
| Column | Type | Notes |
|---|---|---|
| space_id | uuid → space | Unique together with user_id |
| user_id | uuid → app_user | |
| role | enum (Owner, Member) | FR-SPC-6 |
| include_in_totals | boolean | FR-SPC-13; default true for owner, false for member |
| notify_new_txn | boolean default false | FR-SPC-16 |
"Longest-standing member" (BR-33) = earliest created_at. Triggers enforce: a personal space has exactly one member, who is owner (BR-32); a shared space always has ≥ 1 owner (BR-33).
space_invitation (created_by = the Owner who invited)
| Column | Type | Notes |
|---|---|---|
| space_id | uuid → space | |
| citext | ||
| role | enum (Owner, Member) | |
| invitee_user_id | uuid null | Set only if the email is an Active user |
| status | enum (Pending, Accepted, Declined, Cancelled, Expired) | |
| expires_at | timestamptz | created_at + 14 days (FR-SPC-5) |
If the email is not an Active user, the row is stored with invitee_user_id = null, nothing is sent, and it expires silently; the Owner sees the same neutral message either way (FR-SPC-4).
5.5 Money tables
financial_account
| Column | Type | Notes |
|---|---|---|
| space_id | uuid → space | BR-30; cannot change (BR-37, enforced by trigger) |
| name | text | |
| type | enum (Cash, Bank, Ewallet, CreditCard) | |
| currency | char(3) | Locked once any txn exists (FR-FIN-14, BR-26; trigger). The currency of all its transactions (BR-1) |
| opening_balance | bigint | Minor units, signed (see §6.3) |
| provider, color, icon | text null | FR-FIN-8 |
| last4 | char(4) null | CHECK (last4 ~ '^[0-9]{4}$') (BR-19) |
| credit_limit | bigint null | Credit cards only |
| statement_day, due_day | smallint null | FR-FIN-11 |
| sort_order | int | FR-FIN-2 |
| archived_at | timestamptz null |
category
| Column | Type | Notes |
|---|---|---|
| space_id | uuid → space | BR-30 |
| parent_id | uuid → category null | Same space; sub-categories (FR-CAT-3) |
| kind | enum (Expense, Income) | |
| name, color, icon | ||
| system_key | text null | e.g. fees (FR-TRX-2e); system categories can't be deleted |
| archived_at | timestamptz null | |
| sort_order | int |
New spaces are seeded by the API with the default categories (FR-CAT-1), in the creator's language.
txn (transactions). created_at is the transaction's date and time: it defaults to the moment of entry, and the user can change it to when the money actually moved, in the past or in the future (planned payments, FR-TRX-9). created_by = "Added by", updated_by = "Edited by".
| Column | Type | Notes |
|---|---|---|
| space_id | uuid | Deliberate copy of the financial account's space, used by RLS (§5.1) |
| financial_account_id | uuid → financial_account | Its currency is the transaction's currency (BR-1) |
| kind | enum (Expense, Income, Refund, Transfer) | Decides how the row is counted (§6.3). For a transfer, the sign of amount tells which side it is: negative = sending, positive = receiving |
| amount | bigint | Signed effect on the financial account balance (§6.3) |
| category_id | uuid → category null | Must be in the same space (BR-31); null = Uncategorized (BR-5) |
| note | text null | |
| transfer_id | uuid → transfer null | Set on both legs of a transfer |
| fee_for_transfer_id | uuid → transfer null | Fee expense linked to a transfer (FR-TRX-2e) |
| refund_of_txn_id | uuid → txn null | Optional link (FR-TRX-8) |
| recurrence_id | uuid → recurrence null | |
| counterparty_label | text null | A code, not a sentence: DeletedFinancialAccount or DeletedSpace, set when a transfer's other side was deleted (BR-6). The apps turn it into text |
| receipt_path | text null | Phase 2+ (FR-TRX-6) |
Check constraints: expense has amount < 0; income and refund have amount > 0; transfer has amount <> 0 and a transfer_id. Transfer legs have category_id = null.
Indexes: (financial_account_id, created_at), (space_id, created_at), (space_id, category_id, created_at), (space_id, created_by), (space_id, updated_at) for sync, trigram index on note for search (FR-TRX-4).
transfer (created_by is shown on the other side, e.g. "from Boss", BR-35)
| Column | Type | Notes |
|---|---|---|
| rate | numeric(24,12) null | Units of the receiving currency per unit of the sending currency; null for same-currency transfers (BR-16) |
A transfer always has exactly two txn legs of kind transfer, one negative (sending) and one positive (receiving), with the same created_at, created and edited together by one database function (§7.4). Legs can be in different spaces (FR-SPC-14).
budget
| Column | Type | Notes |
|---|---|---|
| space_id | uuid → space | |
| category_id | uuid → category | Same space |
| amount | bigint | In the space's currency (BR-22) |
| effective_from | date | First day of the budget period this limit starts in; the latest row ≤ period applies ("repeats until changed") |
savings_goal and goal_contribution (amounts in the space's currency)
| Table | Columns |
|---|---|
savings_goal | space_id, name, target_amount, target_date, financial_account_id null (same space, FR-BUD-5), completed_at |
goal_contribution | goal_id, amount. created_at is the contribution date and can be changed like a transaction's (manual mode, FR-BUD-6) |
recurrence (Phase 2; created_by owns it, BR-40)
| Column | Notes |
|---|---|
| space_id | |
| template | jsonb: kind, financial_account_id, to_financial_account_id, amount, category_id, note, rate |
| frequency | enum (Daily, Weekly, Monthly, Yearly) + interval |
| anchor_day | For monthly/yearly; 29–31 fall back to the last day (BR-25) |
| start_on, end_on, next_run_on | Dates |
| stopped_at | Set when stopped or when the creator leaves the space |
exchange_rate — private per user (FR-FIN-6, decision D5). Standard columns without deleted_at; updated_at is shown as the rate date (FR-FIN-7).
| Column | Notes |
|---|---|
| user_id, currency | Unique together |
| rate_to_base | numeric(24,12): 1 unit of currency = rate_to_base units of the user's base currency |
device_push_token (Phase 2; standard columns without deleted_at): user_id, token (unique), platform (Android, Ios), device_name, last_success_at. The table was designed for native apps; the Phase 2 migration adds the platform Web and the two keys of a Web Push subscription (the token column holds the subscription's endpoint).
contribution_payment (Phase 4, reserved; standard columns without deleted_at, created_by = the Super Admin who recorded it): user_id, paid_until, note.
5.6 Deletion rules in the database
| Event | Implementation |
|---|---|
| Category deleted (BR-5) | txn.category_id → null (FK ON DELETE SET NULL); budgets for it deleted |
| Financial account deleted (BR-6) | Function delete_financial_account(id): for each transfer leg in other financial accounts, convert it to expense/income with counterparty_label = 'Transfer to/from deleted financial account' and transfer_id = null; then delete the financial account and its own transactions |
| Space deleted (FR-SPC-10) | Function delete_space(id): same conversion for transfers linked to other spaces, then cascade delete of everything in the space |
| Member leaves / removed (FR-SPC-8) | Delete space_member row; stop recurrences created by that user in that space (BR-40); transactions untouched |
| User deleted (FR-AUTH-8, FR-ADM-11) | Function delete_user(id): resolve shared spaces where they are the last Owner (promote longest-standing member or delete space, BR-33); delete personal space (via delete_space); delete auth rows, push subscriptions, rates; txn.created_by in shared spaces becomes null → "Former member" (BR-34) |
6. Money, currencies, and calculations
The API is the authority for every money rule in connected mode. The rules below live in Apps/Api/Src/Money and are unit-tested; SQL mirrors them where aggregation happens in the database. In local mode the app has to apply the same rules on its own, so Apps/Mobile/lib/Core/Money implements everything the app computes on the device: parsing what the user types, formatting, conversions, balances, budget periods, and the statistics (§9.4). Both implementations run the same cases from Packages/MoneyTestVectors, so they can't drift apart, and the API validates every record uploaded from a device (§10.5) rather than trusting it.
6.1 Amounts
- Stored as integers in minor units (
bigint), and sent through the API as integers in minor units plus a currency code. The API and the app each contain the ISO 4217 table of minor units (e.g. IDR 0 in practice, USD 2, JPY 0, KWD 3) used for parsing and display (NFR-8). - Parsing user input: locale-aware (
1.000,50vs1,000.50), converted to minor units without floating point (string → integer arithmetic). - Display: formatted on the device (Dart
intl) with the user's locale and the currency's minor units.
6.2 Conversions
- Converting amount
a(minor units of currency X) into currency Y uses a rate held as a decimal string and big-integer arithmetic:a × rate × 10^(minorY − minorX), rounded half away from zero to Y's minor units. - Cross-currency transfers store both leg amounts exactly as entered; the stored
rateis informational (BR-16). When the user edits the received amount, the rate is recalculated asreceived / sent(FR-TRX-2c). - Converted totals (net worth, budgets with foreign-currency spending, dashboards) are estimates, computed per viewer with their own saved rates (BR-17, decision D5). Missing rate → item excluded and flagged (FR-FIN-7, FR-BUD-7).
6.3 Sign convention and balances
Every txn.amount is the signed effect on its financial account:
| Kind | Sign | Example |
|---|---|---|
| expense | − | Lunch −45,000 |
| income | + | Salary +7,500,000 |
| refund | + | Refund +150,000 |
| transfer (sending leg) | − | BCA −500,000 |
| transfer (receiving leg) | + | Kitchen fund +500,000 |
- Balance =
opening_balance + Σ amount(BR-3), for non-deleted transactions withcreated_at ≤ now()(balances are as of now, BR-42). - Balance after upcoming = the same sum over all non-deleted transactions, including future-dated ones (FR-TRX-9).
- Credit cards: the same formula; a negative balance means money owed. The UI shows "Owed 1,200,000" for a balance of −1,200,000, and "Credit balance" when positive (FR-FIN-10a). Opening "currently owed 1,200,000" is stored as −1,200,000.
- Net worth of a space = Σ balances of its non-archived financial accounts (assets positive, credit cards negative), converted into the space currency (FR-FIN-4). Total net worth for a user = personal space + shared spaces with
include_in_totals, converted into the base currency with the user's rates (FR-SPC-13). - Spending for a category in a period = −Σ(expense amounts) − Σ(refund amounts) in that category and its sub-categories (refunds reduce spending, BR-24; FR-BUD-8). Future-dated transactions count in the period their date falls in. Transfers never count (BR-2). Credit card purchases count when made; bill payments are transfers (BR-18).
Balances are computed with SQL aggregates over indexed columns. At the expected size (≤ 10k transactions per user) this is fast; a cached balance column can be added later without changing the model.
6.4 Budget periods
A transaction's date x is its created_at converted to the space's time zone. For a space with budget_start_day = d, the period containing date x starts on day d of x's month if x.day ≥ d, otherwise on day d of the previous month, and ends the day before the next start. Implemented in the API and mirrored in the app, checked by the shared test vectors.
7. Authorization and row-level security
7.1 Principle
The database decides what each person can see and change. The app code also checks permissions to show the right UI, but a bug in the app cannot leak another user's or another space's data, because Postgres row-level security filters every query (NFR-2).
7.2 Database roles
| Role | Used by | Access |
|---|---|---|
crest_owner | Migrations and backups | Owns all tables and functions. Not a superuser |
crest_app | api, for user endpoints (the Crest app) and public endpoints | RLS applies (no BYPASSRLS). Full use of financial tables through policies; authentication tables |
crest_admin | api, for /v1/admin/* endpoints only | No grants on any financial or space table. Access to app_user (selected columns), access_request, audit_log (insert/select), and EXECUTE on admin functions (e.g. delete_user) — enforces FR-ADM-17 at the database level |
crest_worker | worker | Narrow grants plus SECURITY DEFINER job functions (recurrences, expiries, reminders) |
Each role has its own connection string. The api process opens a pool for crest_app and a separate pool for crest_admin. Controllers under /v1/admin are the only code given the admin pool, and they are protected by the Super Admin guard (§8.3).
7.3 Request context
Every database call for a user endpoint runs inside a transaction that first sets the current user:
ts
// Apps/Api/Src/Database/WithUser.ts (sketch)
export class WithUser {
static async run<T>(userId: string, fn: (tx: Tx) => Promise<T>) {
return db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`);
return fn(tx);
});
}
}Helper SQL functions (STABLE, SECURITY DEFINER, fixed search_path):
sql
create function app_user_id() returns uuid ... -- current_setting('app.user_id')::uuid
create function is_space_member(s uuid) returns boolean ...
create function is_space_owner(s uuid) returns boolean ...7.4 Policies (summary)
| Table | SELECT | INSERT | UPDATE | DELETE |
|---|---|---|---|---|
space | member | via create_shared_space() | owner | via delete_space() (owner) |
space_member | member of that space | via invitation acceptance | owner (roles); self (own settings) | owner (remove) or self (leave) |
space_invitation | owner of space, or invitee | owner | invitee (accept/decline), owner (cancel) | — |
financial_account | member | owner | owner | owner (via function) |
category | member | owner | owner | owner |
budget, savings_goal | member | owner | owner | owner |
txn | member of txn.space_id | member, created_by = me, financial account and category in the same space | owner, or created_by = me (BR-36) | same as update |
transfer | member of the space of either leg | via upsert_transfer() | via function | via function |
recurrence | member | member (created_by = me) | creator or owner | creator or owner |
exchange_rate, device_push_token | user_id = me | same | same | same |
Cross-space transfers are created by upsert_transfer(from_account, to_account, amount_out, amount_in, rate, …), a SECURITY DEFINER function that verifies the caller is a member of both spaces, then writes both legs. Because RLS on txn only returns legs in the caller's spaces, Jojo never receives Boss's sending leg (BR-35); the UI shows the other side as "from/to {transfer.created_by name}".
Triggers enforce the cross-row rules that policies can't express: category in the same space as the financial account (BR-31), space_id of a txn equals its financial account's space, financial account space_id immutable (BR-37), currency lock (BR-26), personal space membership (BR-32), last owner (BR-33).
7.5 Admin functions
Admin endpoints never touch financial tables directly. Everything else goes through functions granted to crest_admin:
| Function | Purpose |
|---|---|
admin_approve_request(request_id) | Creates app_user (status Invited), personal space, activation token; writes audit |
admin_reject_request(request_id, note) | Marks rejected; writes audit |
admin_create_user(name, email) | FR-ADM-7/7a |
admin_set_status(user_id, status) | Deactivate/reactivate; revokes sessions (FR-AUTH-9) |
admin_set_super_admin(user_id, bool) | FR-ADM-16, BR-13 |
delete_user(user_id) | §5.6 |
Each function writes its own audit_log row in the same transaction, so admin actions can't happen without an audit entry (FR-ADM-14).
8. Authentication
Authentication is built into the API (Apps/Api/Src/Auth) and is used only in connected mode: local mode has no login (§10.2). There is no third-party login service and no sign-up endpoint.
8.1 Building blocks
- Email + password only (FR-AUTH-4, FR-AUTH-5, BR-20). Users are created only by the admin functions (BR-8).
- Passwords are hashed with argon2id. Minimum length 10; checked against a list of common passwords.
- Access token: a signed JWT valid for 15 minutes, carrying the user ID, session ID, and whether the session passed two-factor. Sent as
Authorization: Bearer …. - Refresh token: 256 random bits, stored only as a hash in
auth_session. Each use rotates it (the old one stops working). If an already-used refresh token is presented again, the whole session is revoked, because it means the token was copied. - Two-factor (TOTP + backup codes) for Super Admins (FR-ADM-2, FR-ADM-2a). The TOTP secret is stored encrypted; backup codes are stored hashed.
- Rate limiting on login, refresh, password reset, activation, and access requests, plus a growing delay per email after failed logins.
- Revocation: deactivation, deletion, and password reset delete the user's
auth_sessionrows, and the API checks the session on every request, so access ends immediately (FR-AUTH-9).
8.2 Activation and password reset (FR-AUTH-2, FR-AUTH-3, FR-AUTH-7, BR-10)
- Approval creates the user with no password and a single-use token (stored hashed in
auth_token) that expires in 72 hours. - The email links to the public site:
https://crest.radityatama.web.id/activate?token=…. - The page sends the token and the new password to
POST /v1/auth/activate. The API sets the password, marks the token used, and setsstatus = active. - The page then shows an Open Crest button (to
https://app.crest.radityatama.web.id) and a link to the install page (§11.1). - "Resend link" (FR-ADM-10) deletes older tokens first, so old links stop working.
Password reset works the same way through /reset-password?token=…, with a 1-hour token; a successful reset revokes all sessions.
8.3 Sessions
| Crest app | Admin portal | |
|---|---|---|
| Access token | In memory | In memory |
| Refresh token | In a __Host-crest_app cookie: Secure, HttpOnly, SameSite=Strict, sent only to the API | In a __Host-crest_admin cookie with the same settings |
| Lifetime | 30 days, extended on use | 12 hours at most, 30 minutes idle (NFR-11) |
| Extra factor | App lock (PIN) on the device (§8.4) | TOTP at every login |
- Why a cookie: code running in a web page can't read an HttpOnly cookie, so a script injected into the page can't steal the long-lived token. Nothing secret is kept in the browser's storage where scripts can reach it.
- Why SameSite=Strict works:
app.,admin., andapi.crest.radityatama.web.idbelong to the same site, so the browser sends the cookie from the app to the API, and never from any other website. The API additionally checks theOriginheader on the refresh and logout endpoints. - Two cookie names keep a Super Admin's admin session and their own Crest app session apart in the same browser (BR-41).
- Opening the app: the app calls
POST /v1/auth/refresh; the cookie yields a new access token (and a rotated cookie), or the app shows the login screen. - iPhone note: an app added to the home screen has its own cookies, separate from Safari's, so the user logs in once more there (UJ-38).
- Native apps (later) will keep the refresh token in the device's secure storage (Keychain / Keystore) and send it in the request body;
auth_sessionneeds no change for that.
Admin endpoints (/v1/admin/*) additionally require is_super_admin = true and a two-factor-verified session, checked on every request. A user's Crest app session and admin session are separate rows, even for the same person (BR-41).
8.4 App lock (PIN)
The app lock protects an already-logged-in device from someone else picking it up (FR-AUTH-6).
- PIN: 4–6 digits. In connected mode it is one per user for all their devices: the API stores a hash (
app_user.pin_hash), and the device keeps its own salted, deliberately slow hash in the browser's storage, so the PIN also works offline. In local mode the PIN is optional and exists only as that device hash. - Crest locks when opened and after 5 minutes in the background.
- Wrong PINs, connected mode: after 5 wrong attempts the app logs out and wipes its local copy, because the server has the data.
- Wrong PINs, local mode: the delay before the next attempt grows (for example 30 seconds, then 5 minutes, then 1 hour) and nothing is ever erased, because the device holds the only copy (BR-49). A forgotten PIN is fixed by erasing the app's data on that device and restoring a backup file (UJ-51).
- When a local user connects, the app offers to reuse the device PIN: the person enters it once and the app sends it to
PUT /auth/pinover HTTPS. - In connected mode the lock is a gate in front of a valid session, not a replacement for login. A new device or browser always requires email + password.
- What the PIN is not: it does not encrypt the data stored on the device. Someone with full access to the unlocked device and its browser's developer tools could read the database (§16). The phone's own screen lock is the real protection; the PIN stops casual access.
- Biometrics (Face ID, fingerprint through
local_auth) arrive with the native apps. Nothing biometric will ever leave the device or reach Crest.
8.5 Super Admin bootstrap and recovery
pnpm --filter @crest/api admin:create --email … --name …(runsCreateSuperAdmin.ts, on the server) creates the first Super Admin (FR-ADM-3).pnpm --filter @crest/api admin:reset-2fa --email …(runsResetTwoFactor.ts) resets 2FA as a last resort and writes an audit entry (FR-ADM-3a).- Newly promoted Super Admins must complete TOTP setup before any admin endpoint works (FR-ADM-16).
8.6 Email change (FR-ADM-12)
When a Super Admin changes an Active user's email, the new address is stored as pending, a notice goes to the old address, and a confirmation link (24 hours, single use) goes to the new one. The email changes only when the link is used. For an Invited user, the email changes directly and a new activation link is sent.
9. Application design
9.1 API modules and main endpoints
All paths are under /v1. User endpoints run with the crest_app database role inside WithUser.run (§7.3); admin endpoints run with crest_admin.
| Module | Main endpoints | Used by |
|---|---|---|
| Auth | POST /auth/login, /auth/refresh, /auth/logout, /auth/activate, /auth/password/forgot, /auth/password/reset, /auth/totp/*, PUT /auth/pin | App, admin, site |
| AccessRequests | POST /access-requests (public) | Site, the app's Connect screen |
| Admin | GET /admin/overview, GET/POST /admin/access-requests/* (approve, reject), GET/POST/PATCH/DELETE /admin/users/*, GET /admin/audit-log | Admin |
| Users | GET/PATCH /me, DELETE /me, GET/PUT /me/exchange-rates | App |
| Spaces | GET/POST /spaces, PATCH/DELETE /spaces/{id}, members, invitations (/spaces/{id}/invitations, /invitations/{id}/accept) | App |
| FinancialAccounts | GET/POST /spaces/{id}/financial-accounts, PATCH/DELETE /financial-accounts/{id} | App |
| Transactions | GET/POST /spaces/{id}/transactions (search, filters, cursor), PATCH/DELETE /transactions/{id} | App |
| Transfers | POST /transfers, PATCH/DELETE /transfers/{id} (legs may be in different spaces) | App |
| Categories, Budgets, Goals | CRUD under /spaces/{id}/… | App |
| Sync | GET /sync/pull, POST /sync/push, GET /me/sync-state, POST /sync/import (§10.4, §10.5) | App, connected mode |
| Notifications | GET /notifications, PUT /me/devices/{id} (push subscription, Phase 2) | App |
| Health, AppConfig | GET /health, GET /app-config (minimum and latest app version; store links stay empty until native apps exist). The same two numbers are published as the static /app-config.json on the app host for local mode (§11.1) | Monitoring, app |
There are no Stats or Export modules: the app computes statistics and creates CSV files itself from its local database (§9.4), in both modes. They can be added later if a shared space ever grows too large for a device.
Every handler: authenticate → validate the DTO → run inside the right database role and transaction → return a typed response. Authorization is enforced twice: by NestJS guards (clear errors) and by row-level security (the real barrier). How each module is laid out, and the exact guard, error, and pagination rules, are in ApiModuleStandard.md.
9.2 Crest app structure
- Feature folders under
lib/Features, each with its pages, widgets, and Riverpod providers; shared code inlib/Core. - Data flow: page → provider → repository → local database. Repositories are the only code that reads or writes the database. In connected mode a sync layer (
lib/Core/Sync) sits beside them and exchanges rows with the API through the generated client; pages never call the API. - Token handling (connected mode): a Dio interceptor adds the access token, refreshes it once on a 401 (the browser sends the refresh cookie), and logs out if the refresh fails.
- Theme: Material 3
ThemeDatabuilt from the prototype's tokens (light and dark), Newsreader for numbers and titles, Geist for UI text.
9.3 Crest app screens
| Screen | Journeys |
|---|---|
| Welcome (first launch: terms and 18+, storage warning, install-first on iPhone) | UJ-50 |
| Connect to Crest (login, request access, upload or download choice), forgot password (opens the public site) | UJ-04, UJ-05, UJ-52 |
| Unlock (PIN) | UJ-04 |
| Onboarding (language, base currency, app lock, financial accounts, categories, budgets, add to home screen) | UJ-16, UJ-38 |
| Backup (save, restore, reminders) | UJ-51 |
| Home (per space; Personal shows total net worth) | UJ-30, UJ-31, UJ-46 |
| Add / edit transaction sheet (expense, income, refund, transfer) | UJ-17 to UJ-23, UJ-44, UJ-45, UJ-47 |
| Activity (search, filters, "Added by", Upcoming) | UJ-24, UJ-46 |
| Stats (Categories / Calendar / Trend / Accounts or Members, plus the transaction list for the selection) | UJ-30, UJ-46 |
| Budgets, Goals | UJ-27 to UJ-29 |
| Financial accounts (grouped by type) | UJ-31 to UJ-33 |
| Spaces: switcher, members, invitations, space settings | UJ-42, UJ-43, UJ-48, UJ-49 |
| More / Settings: profile, language, theme, currency, exchange rates, app lock, notifications, export, backup, connect or log out, delete | UJ-08, UJ-35 to UJ-37, UJ-51, UJ-52 |
9.4 Navigation and Stats
- Tabs (FR-NAV-1): a bottom navigation bar with Home, Activity, Budgets, Stats, More, built with go_router's shell route so each tab keeps its own back stack. The + button opens the add-transaction sheet from any main screen. The space switcher sits at the top of each main screen.
- Stats state lives in the route:
/stats?view=cat|cal|trend|acct|member&period=week|month|year&at=2026-09&sel=<id>, so the back button and Open in Activity work; Activity receives the same values as its filter. - Queries: each view is one SQL aggregate over the local database (sum by category, by local day using the space's time zone, by period, by financial account or
created_by). The transaction list for the selection uses the same filter as Activity. Transfers are excluded and refunds subtracted (BR-2, BR-24); amounts in other currencies are converted with the viewer's saved rates (§6). The same code runs in both modes and offline, so a number never depends on whether the device is connected. - Performance: aggregates use the
txn (space_id, created_at)index on the device database; at the expected size (≤ 10k transactions per user, NFR-6) they take milliseconds.
9.5 Admin portal screens
Login (email, password, TOTP), Overview (counts), Access requests (filter, approve, reject, bulk actions), Users (search, status, roles, resend link, reset email, delete), Audit log. Phase 4: contributions. The portal is designed for desktop and usable on a phone.
9.6 Public site pages
| Page | Purpose |
|---|---|
/ | What Crest is, with an Open Crest button (the app, no sign-up) and a smaller Request access for sync and sharing |
/request-access | The access-request form for connected mode (FR-REQ-1 to 6) |
/activate?token=… | Set a password from the invitation, then an Open Crest button (UJ-03) |
/reset-password?token=… | Set a new password (UJ-05) |
/install | How to add Crest to the home screen, with the steps for the visitor's device (FR-APP-2, UJ-38) |
/privacy, /terms | Privacy Policy and Terms of Use |
Each page exists in English and Bahasa Indonesia (/id/…).
9.7 Access-request protection (FR-REQ-2)
- Honeypot field + minimum time-to-submit check.
- Rate limit: 3 requests per IP hash per hour, 10 per day.
- Optional: Cloudflare Turnstile if spam appears (free; not enabled by default to avoid a third-party script).
- Same neutral response for new, duplicate, and recently rejected emails (FR-REQ-4, BR-14).
9.8 Localization
- Crest app: ARB files (
app_en.arb,app_id.arb); numbers, currencies, and dates formatted withintland Crest's own minor-unit table, in the user's locale and time zone (NFR-8). - Admin portal: JSON translation files.
- API: returns codes and data, never sentences, so each client shows text in the user's language. Emails are rendered by the API in the user's
language.
10. Local data, sync, and backup
The Crest app is local-first: it always reads and writes its own database on the device. What changes between the two modes is only whether a sync layer is attached to that database.
10.1 The database on the device
- Same business tables as the server:
space,financial_account,category,txn,transfer,budget,savings_goal,goal_contribution,recurrence, andexchange_rate, with the same standard columns (§5.1) and the same money rules (§6). The device has no identity tables (app_user, sessions, tokens, invitations) and, in local mode, nospace_member: there is one Personal space and one person. created_byandupdated_byare null in local mode. They are set to the user's ID when the data is uploaded (§10.5).- IDs are UUIDv7 created on the device for every row, including the local Personal space. This is what makes uploading and restoring safe to repeat (BR-48).
- Schema versions: drift migrations upgrade the database when the app updates. The version is stored in the database and written into every backup file.
- Everything is computed here: balances, budgets, statistics, search, and CSV export (§9.4), in both modes.
- Where it lives: drift's web storage in the browser's private storage for the app's address. It is not encrypted (§8.4, §16).
10.2 Local mode (Phase 1a)
- No login, no API calls. The app requests only its own files and
/app-config.jsonfrom the app host. Nothing about the person or their money reaches Crest (BR-46). - The service worker keeps the app's files, so after the first load the app opens and works with no internet.
- Recurring transactions (Phase 2) are created when the app opens: it creates every occurrence that fell due since it was last opened, dated on its due date (FR-TRX-5b, BR-25).
- Terms and age: the welcome stores the acceptance and its date in the device's settings table (FR-MOD-4). It is never sent anywhere.
10.3 Keeping the data safe on the device
The device holds the only copy, so durability is a feature of its own (NFR-14).
- Persistent storage: on first launch the app calls
navigator.storage.persist()and shows the answer in Settings. Browsers may refuse; the app works either way (FR-MOD-7). - iPhone and iPad: Safari clears all script-written storage for a site that was not used for about seven days, and a home-screen app has storage of its own, separate from Safari. So the welcome asks iPhone and iPad users to add Crest to the home screen first. The app detects the standalone display mode (
display-mode: standalone,navigator.standalone) and warns when it runs in a Safari tab (FR-MOD-6, UJ-50, UJ-38). - Backup file (FR-MOD-5, UJ-51): one JSON file with a header (
format: "crest-backup",formatVersion,schemaVersion,createdAt,appVersion) and the rows of every table in the same shape the sync uses (§10.4). It holds no PIN, no session, and no identity data. It is plain, unencrypted text; the app says so when saving (FR-MOD-6). - Restoring: the app checks the header and the structure, applies the same validation the API applies to an upload (§10.5), and replaces the database inside one transaction, so a bad file changes nothing. A file from a newer app version is refused with a request to update; an older one is migrated.
- Reminder: the app stores the date of the last backup and compares it with the latest
updated_at; if the backup is older than 30 days and something changed, it shows a dismissible reminder at most once a week (FR-MOD-8). - Tests: a round trip (create data → save → erase → restore) must give identical balances and rows, and restoring the same file twice must change nothing (§17).
10.4 Connected mode: sync (Phase 1b)
- Outbox: each create, update, and delete is stored with its client-generated ID and applied to the local database at once (optimistic UI).
- Push: when online, the outbox is sent in order. The API applies each change idempotently (same ID twice = no-op) under RLS, and rejects a row that breaks a rule (wrong sign for its kind, a category from another space, a currency that does not match the financial account, BR-31, BR-26).
- Conflicts (BR-21): changes are applied in the order the server receives them, so the last change received wins; a delete always wins over an edit (an edit to a deleted row is ignored). The device then refreshes its copy from the server.
- Pull: per space, rows with
updated_at > cursor, including tombstones. Cursor = (updated_at,id). - Deactivated or deleted user: sync returns 401, the device discards its outbox, removes the synced data, and continues in local mode with an empty start (BR-21, BR-50, UJ-07).
10.5 Connecting: the first upload (FR-MOD-10, BR-47, UJ-52)
- The person logs in (§8). The app calls
GET /v1/me/sync-state, which returns the user's Personal space ID and whether that space is empty (no financial accounts, transactions, budgets, or goals). - Empty, and the device has data: the app asks Upload or Start fresh. For Upload it rewrites
space_idon every row to the server's Personal space ID (the local space row itself is not uploaded) and sends the rows in chunks toPOST /v1/sync/import. - The import is accepted only while the Personal space is empty (otherwise
409 Conflict). Each chunk is one transaction. It is idempotent by row ID, so a dropped connection or a retried chunk creates no duplicates (BR-48). Every row goes through the same validation as a pushed change, plus the money checks (minor units, sign convention). The default categories created at activation (FR-SPC-3) are replaced by the uploaded ones, which is safe because the space is empty.created_byandupdated_bybecome the user. Rows the server rejects are returned to the app, which offers to save them to a file (UJ-52 A4). - Settings travel too: base currency, language, time zone, and budget start day from the device fill the user's profile and Personal space, unless the user already set them (then FR-SET-4 asks to review budgets).
- The app then runs a normal pull from cursor zero, so the device gets the server's
updated_atvalues and everything is in step. - Not empty (a second device), and the device has local data: nothing is merged. The person chooses Use my Crest data (the app first offers to save this device's data as a backup file, then clears the database and pulls everything) or Keep this device's data local only (log out, stay in local mode, BR-47).
- Several devices with local data are handled one at a time: one uploads, the others use the server's data. Merging two histories is not attempted (BRD §14, question 5).
10.6 Logging out
- Log out, keep a local copy (FR-MOD-11): the app keeps the Personal space only. Shared-space rows are removed first, because local mode never holds other people's data. Sync state (outbox, cursor, session) is dropped, and the rows become local data with
created_byset to null. - Log out, remove the data wipes the database.
- Forced logout (deactivated or deleted user) always removes everything that was synced (BR-50).
11. App distribution, notifications, and email
11.1 Distribution and updates (FR-APP-1, FR-APP-2, FR-APP-5)
The first release is a web app. There is no store listing, no developer fee, and no review; publishing a new version is a deployment (§13.6).
| Who | How they get Crest |
|---|---|
| Everyone | Open https://app.crest.radityatama.web.id in a browser and start. Invited people connect later from More → Connect to Crest |
| iPhone and iPad (Safari, iOS/iPadOS 16.4+) | Share → Add to Home Screen. Crest then opens full-screen from its own icon |
| Android (Chrome, Android 10+) | The app's Install Crest button or the browser's Install option |
| Computers (Chrome, Edge) | The browser's Install option; other browsers use the app in a tab |
- The public site's
/installpage detects the device and shows the right steps; the activation page and email link to it. Inside the app, the same guide is under More → Add to home screen (UJ-38). web/manifest.jsonsets the name Crest, the icons (including a maskable icon and the Apple touch icon),display: standalone, and the theme colours.- Supported browsers (NFR-9): Safari on iOS/iPadOS 16.4+, Chrome on Android 10+, and the latest two versions of Chrome, Safari, Edge, and Firefox on computers.
- Caching: files with a content hash in their name are cached for a long time;
index.html, the bootstrap script, the service worker, andmanifest.jsonare served withno-cache, so a new version is picked up at the next visit. - Updates (UJ-39): the app knows its own build version. On start, and when it returns to the foreground, it reads
/app-config.jsonfrom its own host (a static file written at deploy time, so it works in local mode with no API). In connected mode the API serves the same two numbers atGET /v1/app-configand can refuse a request from a version belowminimumVersion:- below
latestVersion→ "A new version of Crest is available" with a Reload button, which activates the new service worker and reloads; - below
minimumVersion→ "Update required", then the same reload without a choice. This lets the API retire old versions safely.
- below
- The static file and the API's
/v1/app-configalways carry the same two numbers (both come from the deploy). The API stays backward-compatible with every app version at or aboveminimumVersion. Because a web app updates within a day for almost everyone,minimumVersioncan followlatestVersionclosely.
Native apps (FR-APP-8, when there is demand). The same Flutter code builds Android and iPhone apps. That step adds: store accounts (Google Play USD 25 once, Apple USD 99 per year), signing, a mobile-release.yml workflow, biometric unlock, secure-storage sessions, and the store links in /v1/app-config. Nothing in the API or the data model has to change. App identifier: id.web.radityatama.crest.
11.2 Notifications
- Phase 1b (connected mode only): every notification appears in the notification list inside the app (FR-NOT-3): invitations, being removed from a space, a space being deleted (FR-NOT-4). Invitations are also sent by email.
- Phase 2: Web Push. Budget 80% / 100% (to all members of the space), credit card due, recurrence recorded, invitations, new transaction in a shared space (opt-in), and the daily reminder (FR-NOT-1). They are sent by the worker with the Web Push standard (VAPID keys, the
web-pushlibrary) straight to the browser's push service, so no Firebase account is needed. Subscriptions are stored indevice_push_token; ones the push service reports as gone are deleted. - The app asks for notification permission once, after explaining what it's for (FR-APP-3). On iPhone and iPad the browser allows this only after Crest is on the home screen, so the app asks there.
- A web app can't schedule a notification on the device by itself, which is why the daily reminder waits for Web Push and is sent by the server at the user's chosen time.
- Nothing depends on push being allowed: the in-app list always has every notification. In local mode nothing is sent from a server; the app shows reminders only while it is open.
11.3 Email (Brevo)
- Sender
Crest <no-reply@radityatama.web.id>; reply-tocontact@radityatama.web.id. contact@radityatama.web.idis the public contact address (Privacy Policy, Terms, emails). How it receives mail is not decided. The first plan was Cloudflare Email Routing, which needs the domain's nameservers on Cloudflare; they are on Biznet NEO DNS. The options are: move the nameservers to Cloudflare; use a mailbox from Biznet NEO Web Hosting (a monthly cost); or stop offeringcontact@as a receiving address. Until it is decided, the app sends withReply-To: contact@radityatama.web.idand the Privacy Policy still names Cloudflare.- Domain authentication (done 2026-10-06): Brevo's verification code, two DKIM records, and a DMARC record at
p=none, all in Biznet NEO DNS (§13.2). DMARC moves top=quarantineafter a monitoring period. - The API connects with an SMTP key named
crest-api. A Brevo SMTP key expires after one year (this one on 2027-10-06) and after 90 days without use, so a quiet period can silently stop sending: rotate it before then and check the API log for a535authentication error when sending fails. The key lives only in/opt/crest/.env. - Templates (Handlebars, both languages): access request received (to Super Admins), approved + activation link, rejected (short, no reason), activation link resent, password reset, email change, space invitation, contribution reminders (Phase 4).
- Free tier limits (about 300 emails/day) are far above expected volume.
12. Background jobs
The worker container runs the same NestJS code as the API, started from WorkerMain.ts, with pg-boss cron schedules (UTC) and on-demand queues.
| Job | Schedule / trigger | Requirement |
|---|---|---|
recurrence.run | Every hour; creates occurrences whose due date (in the space's time zone) has arrived, with created_at set to that due date | FR-TRX-5b, BR-25 |
budget.check | Queued after each expense/refund write | FR-BUD-3, FR-NOT-2 (one alert per threshold per period) |
card.due | Daily; credit cards with due_day in 3 days; notifies the space's Owners | FR-FIN-11 |
invitation.expire | Hourly | FR-SPC-5 |
token.cleanup | Daily; remove expired activation, reset, and email-change tokens and expired sessions | BR-10 |
push.send, email.send | Queue, with retries and backoff | §11 |
reminder.daily | Every 15 minutes (Phase 2); sends the daily reminder to users whose chosen time has come | FR-NOT-1, §11.2 |
tombstone.purge | Weekly; hard-delete rows soft-deleted > 90 days | §5.1 |
access_request.purge | Monthly; delete rejected requests older than 6 months | NFR-18 |
audit.retention | Monthly; entries older than retention (≥ 1 year) | NFR-12 |
contribution.remind | Daily (Phase 4) | FR-SUB-4 |
Backups run in the separate backup container (§14), not in pg-boss, so they work even if the API is broken.
13. Infrastructure and deployment
13.1 Server
| Item | Value |
|---|---|
| Provider / product | Biznet Gio NEO Lite (Indonesia) |
| Size | 2 GB RAM for the full stack, ≥ 40 GB SSD, + 2 GB swap. The server was first ordered as XS 1.1 (1 vCPU, 1 GB, 60 GB) to host only the public site; upgrade it to SS 2.1 (1 vCPU, 2 GB) before the API and database are deployed (neolite change-package, or the portal) |
| OS | Ubuntu 24.04 LTS |
| Hostname | crest-prod.radityatama.web.id |
| Expected memory use | Postgres ~400 MB, api ~250 MB, worker ~150 MB, Caddy ~40 MB, OS ~300 MB |
Postgres is tuned for the small machine (shared_buffers 256 MB, max_connections 40).
13.2 DNS records (radityatama.web.id)
The domain is registered at Biznet Gio and its DNS is Biznet NEO DNS (nameservers satu.neodns.id and dua.neodns.id), managed in the Biznet portal. There is no proxy: Caddy obtains certificates directly and traffic goes straight to the VPS.
| Name | Type | Value |
|---|---|---|
crest-prod | A | VPS IPv4 |
crest | A (or CNAME → crest-prod) | VPS IPv4 (public site) |
app.crest | A (or CNAME → crest-prod) | VPS IPv4 (the Crest app) |
api.crest | A (or CNAME → crest-prod) | VPS IPv4 (API) |
admin.crest | A (or CNAME → crest-prod) | VPS IPv4 (admin portal) |
@ | MX | Not set. How contact@ receives mail is undecided (§11.3) |
@ | TXT | brevo-code:<verification code shown by Brevo> |
brevo1._domainkey | CNAME | b1.radityatama-web-id.dkim.brevo.com |
brevo2._domainkey | CNAME | b2.radityatama-web-id.dkim.brevo.com |
_dmarc | TXT | v=DMARC1; p=none; rua=mailto:rua@dmarc.brevo.com → later p=quarantine |
The four Brevo records were added on 2026-10-06. Brevo asked for no SPF record; one is added only if a receiving service needs it.
13.3 Server hardening (NFR-15)
- Non-root sudo user; SSH key-only, root login and password auth disabled.
- UFW firewall: allow 22 (optionally only from known IPs), 80, 443; deny everything else. Postgres has no published port.
unattended-upgradesfor security updates; automatic reboot window at 03:00 WIB if required.fail2banfor SSH.- Docker log rotation (max size and files) so logs can't fill the disk; logs older than 14 days are deleted (NFR-18).
- Secrets in
/opt/crest/.env(mode 600, owned by the deploy user), never in git. - Full steps in
Infra/ServerSetup.md.
13.4 Caddy
(security_headers) {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
}
crest.radityatama.web.id {
import security_headers
root * /srv/site
try_files {path} {path}.html
file_server
}
app.crest.radityatama.web.id {
import security_headers
root * /srv/app
@fresh path /index.html /flutter_service_worker.js /flutter_bootstrap.js /version.json /app-config.json /manifest.json
header @fresh Cache-Control "no-cache"
try_files {path} /index.html
file_server
}
admin.crest.radityatama.web.id {
import security_headers
root * /srv/admin
try_files {path} /index.html
file_server
}
api.crest.radityatama.web.id {
import security_headers
reverse_proxy api:3000
}The Crest app, the admin portal, and the public site also send a strict Content-Security-Policy that allows scripts only from themselves and connections only to the API. The Crest app's policy additionally allows WebAssembly ('wasm-unsafe-eval'), which Flutter's web build needs. The real file is Infra/Caddyfile, with the hosts taken from .env.
13.5 Environments
| Environment | Where | Purpose |
|---|---|---|
| Local | Developer machine: docker compose -f Infra/docker-compose.dev.yml (Postgres on port 54329, so it never clashes with another local PostgreSQL, + Mailpit), pnpm --filter @crest/api db:migrate and db:seed (demo data: Andi, Rina, Boss, Jojo, Maria), pnpm dev for the API, admin, and site, pnpm dev:mobile:web for the app in Chrome (or pnpm dev:mobile for a simulator or phone) | Development |
| CI | GitHub Actions: Postgres service container | Tests |
| Production | The VPS | Real users |
Local ports are fixed. Crest has its own block of ports, so it never clashes with other projects on the same machine, and a port never changes on its own: if one is already taken, that part fails to start with a clear error instead of moving to another port.
| Part | Port | Set in |
|---|---|---|
| API | 4300 | dev:api script (PORT=4300); default in Apps/Api/Src/Config/EnvironmentLoader.ts |
| Admin portal | 4310 | Apps/Admin dev script (ng serve --port 4310) and angular.json |
| Public site | 4320 | dev:site script |
| Crest app (in Chrome) | 4330 | dev:mobile:web script (--web-port 4330); allowed by the API as ORIGIN_APP |
| PostgreSQL | 54329 | Infra/docker-compose.dev.yml |
| Mail catcher (SMTP / web) | 1025 / 8025 | Infra/docker-compose.dev.yml |
The clients point at the API through Apps/Admin/Public/config.js, Apps/Site/assets/js/config.js, and the Crest app's ORIGIN_API (default http://localhost:4300; an Android emulator reaches the computer at http://10.0.2.2:4300). In production, the API container listens on 3000 behind Caddy.
No permanent staging server (2 GB RAM, cost target). The Crest app can be built against a local or temporary API for testing (--dart-define=ORIGIN_API=…).
13.6 CI/CD
Every push and pull request runs ci.yml, with one job per area:
deploy.yml is started by hand until automatic deploys are wanted:
The uploads go to /opt/crest/app, /opt/crest/admin and /opt/crest/site, which Caddy serves as /srv/app, /srv/admin and /srv/site.
- API images are tagged by git SHA; the last 5 images are kept in GHCR, and the previous tag is used for rollback (
CREST_TAG=<old-sha> docker compose up -d). - Migrations must be backward-compatible with the previous API release (expand → migrate → contract), so a rollback never needs a down-migration.
- The API and the Crest app are deployed together. The API is migrated and restarted first, and it never removes anything an app version at or above
minimumVersionstill uses. - The VPS never builds anything; it only pulls images and receives static files.
13.7 Configuration (environment variables)
| Variable | Example / note |
|---|---|
ORIGIN_SITE, ORIGIN_APP, ORIGIN_ADMIN, ORIGIN_API | https://crest.radityatama.web.id, https://app.crest.…, https://admin.crest.…, https://api.crest.… (NFR-17: the domain is configuration) |
DATABASE_URL_APP, DATABASE_URL_ADMIN, DATABASE_URL_WORKER, DATABASE_URL_OWNER | One per DB role |
JWT_ACCESS_SECRET, TOTP_ENCRYPTION_KEY, IP_HASH_SECRET | Random 32+ bytes each. IP_HASH_SECRET keys the hash of client IPs (rate limits, access_request.source_ip_hash) |
TRUST_PROXY_HOPS | Proxies in front of the API (1: Caddy), so the client IP is read correctly |
MIN_APP_VERSION, LATEST_APP_VERSION | Served by /v1/app-config (PLAY_STORE_URL, APP_STORE_URL, ANDROID_DOWNLOAD_URL stay empty until native apps exist) |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, MAIL_FROM | Brevo's SMTP relay (host, user, and password are required in production; port defaults to 587, MAIL_FROM to no-reply@radityatama.web.id). In development they default to Mailpit on localhost:1025, which catches every message at http://localhost:8025 |
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY | Web Push (Phase 2) |
BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_KEY, BACKUP_S3_SECRET, BACKUP_AGE_RECIPIENT | Backups |
CREST_TAG | API image tag to run |
The Crest app's only build-time setting is the API address (--dart-define=ORIGIN_API=…).
14. Backups and recovery
Server backups cover connected users' data only. People who use local mode hold their own backup file (§10.3); Crest has no copy of their data.
14.1 Nightly backup (NFR-10)
- 02:00 WIB:
pg_dump --format=customascrest_owner(the table owner can read every row; no extra role is needed). - Encrypt with
ageto a public key; the private key is kept offline by the owner (password manager + printed copy), never on the server. If this key is lost, the backups can't be opened, so both copies are checked during each quarterly restore test. - Upload with
rcloneto Biznet NEO Object Storage bucketcrest-backups/daily/YYYY-MM-DD.dump.age. - Keep the last 30 daily and 3 monthly copies (lifecycle rule or script), so deleted users' data is gone from every backup within 90 days (NFR-10, NFR-3).
- Report success/failure: a heartbeat URL on UptimeRobot (missing heartbeat → alert).
Also backed up: /opt/crest/.env (encrypted the same way) and the Caddy data volume (certificates can be re-issued, so optional).
14.2 Restore test (quarterly)
On a laptop or temporary VPS: download the latest backup, decrypt, pg_restore into a fresh Postgres, run the app against it, and check a few known balances. Record the date in the runbook.
14.3 Rebuilding the server (NFR-16, target ≤ 4 hours)
Infra/Runbooks/RebuildServer.md:
- Create a new NEO Lite VPS (same size), apply
Infra/ServerSetup.md. - Install Docker, copy
Infra/and the restored.env. - Start Postgres, restore the latest backup.
- Point DNS (
crest-prod,crest,app.crest,api.crest,admin.crest) to the new IP. docker compose up -d; Caddy issues certificates.- Verify login, a known balance, and email sending.
15. Observability
| What | How |
|---|---|
| Uptime | UptimeRobot checks https://api.crest.…/health (database reachable, worker heartbeat fresh) and the public site every 5 min; alerts by email/Telegram |
| Backups | Heartbeat monitor (§14.1) |
| Logs | Structured JSON logs from api, worker, and Caddy, rotated by Docker and kept 14 days (NFR-18). No financial data, no request bodies, no tokens in logs (NFR-2). User IDs only |
| Errors | API errors logged with their class, request id, and stack frames, never the message; a weekly glance at error counts. The Crest app reports an unexpected error to the API as an error code and app version only (no financial data), so it appears in the same logs. A hosted error tracker (e.g. Sentry free tier) is optional and would require scrubbing all personal/financial data first |
| Disk / memory | Daily cron script emails the owner if disk > 80% or swap usage is high |
| Jobs | pg-boss tables show failed jobs; an admin-only endpoint lists failures (no financial content) |
16. Security checklist
| Area | Measure | Requirement |
|---|---|---|
| Transport | HTTPS only, HSTS, TLS via Caddy | NFR-2 |
| Data at rest | VPS disk encryption where the provider supports it; backups encrypted with age | NFR-2, NFR-10 |
| Isolation | RLS on every financial/space table; separate DB roles; admin role has no financial grants | NFR-2, FR-ADM-17, BR-12 |
| Authentication | No sign-up, no external providers, argon2id hashing, short-lived access tokens, rotating refresh tokens with reuse detection, refresh tokens only in HttpOnly cookies, rate limits, immediate revocation | FR-AUTH-*, BR-20 |
| Admin | Separate host, HttpOnly cookie for the refresh token, TOTP + backup codes, 30-min idle timeout, full audit log | FR-ADM-2, NFR-11, NFR-12 |
| API | Input validation on every DTO (unknown fields rejected), CORS allow-list, rate limiting, no financial data in error messages | |
| Web (app, admin, site) | Strict Content-Security-Policy, no third-party scripts, no framing by other sites, output escaping by Angular | |
| Crest app | No token readable by scripts (access token in memory, refresh token in an HttpOnly cookie); app lock with a PIN; in connected mode the local copy is wiped on logout or 5 wrong PINs; minimum-version check | FR-AUTH-6, FR-APP-5 |
| Local mode | No data leaves the device (BR-46); the PIN never erases data, it only slows attempts (BR-49); the database and the backup file are not encrypted, and the app and the Privacy Policy say so; the phone's own screen lock is the protection | FR-MOD-2, NFR-2, BR-46, BR-49 |
| Upload and restore | Every uploaded or restored row is validated like a pushed change; the import endpoint refuses a non-empty Personal space; backup files are parsed defensively (size limit, strict schema) | FR-MOD-5, FR-MOD-10, BR-47, BR-48 |
| Sensitive fields | Only last 4 digits stored (DB check constraint); no card numbers, CVV, or bank logins anywhere | BR-19 |
| Server | Key-only SSH, firewall, automatic updates, fail2ban, Postgres not exposed | NFR-15 |
| Dependencies | Lockfiles, Dependabot alerts, pnpm audit and flutter pub outdated in CI | |
| Privacy | Consent to Privacy Policy and Terms stored on the device at first launch (FR-MOD-4) and with the access request (FR-REQ-6); export and delete available; the policy discloses shared-space visibility, backup retention (≤ 90 days after deletion), and the operator's technical server access | NFR-3 |
17. Testing strategy
| Level | Tool | What |
|---|---|---|
| API unit | Jest | Apps/Api/Src/Money: parsing and formatting per currency, conversions and rounding, budget periods, spending with refunds, net worth with credit cards; services with mocked repositories |
| Shared money vectors | Jest + flutter_test | The JSON cases in Packages/MoneyTestVectors run against both the API's TypeScript and the app's Dart, so the two implementations can't disagree |
| Database | Jest + Testcontainers (real Postgres) | Migrations apply cleanly; RLS tests that act as different users: Jojo can't see Operations; Jojo sees only his leg of Boss's transfer; a Member can't edit others' transactions; the admin role can't select txn; BR-31/32/33/37 triggers; deletion functions (BR-6, BR-34) |
| API integration | Jest + Supertest | Endpoints against a test database: approve request → user + personal space + token; login, refresh rotation and reuse detection; invitation lifecycle; cross-space and cross-currency transfers |
| Contract | CI check | OpenApi.json matches the code; generated Dart and TypeScript clients compile |
| App unit and widget | flutter_test | Money formatting, providers, form validation, key widgets (add-transaction sheet, space switcher); balances, budgets, and statistics computed from the local database, against the shared money vectors |
| Backup and upload | flutter_test + Jest | Backup round trip (data → file → erase → restore gives identical rows and balances); restoring twice changes nothing; a damaged or newer file is refused; the import endpoint is idempotent and refuses a non-empty Personal space |
| App end-to-end | integration_test (in Chrome) | UJ-50 → UJ-16 → UJ-17 (first launch to first expense, no login), UJ-51 (backup and restore), UJ-52 (connect and upload), UJ-43/44/45 (sharing) |
| Admin | Angular unit tests, Playwright | UJ-09 (approve with 2FA), UJ-12, UJ-13 |
| Public site | Playwright | UJ-01 (request access), UJ-03 (activate) |
| Manual | Real devices | An Android phone (Chrome) and an iPhone (Safari): add to home screen, PIN, working with no internet, update notice, dark mode. On the iPhone, check that data entered in a Safari tab is not visible in the home-screen app, that restoring a backup there works, and that the data survives a week unused |
Tests run in CI on every pull request; master is deployed only when all pass. The RLS test suite is treated as a release blocker.
18. Delivery plan
Phase 1 (MVP) is delivered in two steps, each ending in something people can use. Phase 1a is the local app and needs no API or database on the server, so it can ship while the connected part is still being built. Phase 1b adds login, sync, and sharing for invited people.
| # | Milestone | Contents | Key journeys |
|---|---|---|---|
| M0 | Foundations (done) | Monorepo and naming checks; API skeleton with health endpoint; database schema + RLS + tests; money rules in TypeScript and Dart with shared vectors; OpenAPI → generated clients pipeline; Flutter shell (theme, tabs, empty pages) for web and phones; Angular shell; public-site skeleton; local dev; CI; VPS setup, backups, deploy pipeline | — |
| Phase 1a: Local app | |||
| M1 | Local core | The database on the device (drift schema, migrations); first-launch welcome; onboarding; financial accounts, transactions (expense, income, refund, same-currency transfer), categories, Activity with search; bundled fonts. Early spike: check on a real iPhone that drift's storage survives, and how it behaves in a Safari tab versus the home-screen app (§10.3) | UJ-50, UJ-16 to UJ-21, UJ-23, UJ-24, UJ-32 to UJ-34 |
| M2 | Local insight and release | Budgets, Stats, net worth, CSV export, backup file save and restore, optional PIN, storage-persistence request and warnings, app-config.json version check and update notice, service worker and offline start, install page and add-to-home-screen guide, the public site's Open Crest button; first release at app.crest.radityatama.web.id | UJ-25, UJ-27, UJ-30, UJ-31, UJ-36 to UJ-39, UJ-51 |
| Phase 1b: Connected | |||
| M3 | Access & admin | API: access requests, authentication, admin functions, email. Public site: request access, activate, reset password. Admin portal: 2FA login, requests, users, audit log. App: Connect screen, login, PIN per user | UJ-01 to UJ-15 |
| M4 | Sync | API: sync/pull, sync/push, me/sync-state, sync/import. App: outbox, first upload, "use my Crest data", log out with or without a local copy, forced logout. Server backup restore test | UJ-52, UJ-25 (sync), UJ-07 |
| M5 | Spaces | Shared spaces, invitations, roles, cross-space transfers, "Added by", leave/remove/delete | UJ-42 to UJ-49 |
Phase 2 adds: recurring transactions, Web Push notifications, the daily reminder and budget alerts (connected mode), CSV import, cross-currency transfers with manual rates, savings goals.
18.1 Milestone M0 status (done, 2026-10-01)
| Built | Where |
|---|---|
| Monorepo, lint, format, file-name check in a pre-commit hook and CI | root, Scripts/, lefthook.yml, .github/workflows/ci.yml |
API skeleton: /health, /v1/app-config, configuration, one database pool per role, OpenAPI file with an up-to-date test | Apps/Api, Packages/ApiContract/OpenApi.json |
| Database: all tables, row-level security, triggers, functions; 40 tests against real PostgreSQL | Apps/Api/Migrations, Apps/Api/Test/Database |
| Money rules in TypeScript and Dart, both passing the same 94 shared cases | Apps/Api/Src/Money, Apps/Mobile/lib/Core/Money, Packages/MoneyTestVectors |
| Flutter shell: theme, five tabs, English and Bahasa Indonesia; runs in Chrome (phone-width frame), the iPhone simulator, and Android | Apps/Mobile |
| One-command local development on fixed ports | root package.json, Scripts/RunMobile.sh, §13.5 |
| Angular admin shell: navigation and four placeholder screens | Apps/Admin |
| Public site skeleton | Apps/Site |
| Local development: database with real roles, demo data | Infra/docker-compose.dev.yml, SeedDemoData.ts |
| Production files: API image, Compose, Caddy, backup image, server setup and recovery runbooks | Apps/Api/Dockerfile, Infra/, Infra/Runbooks/ |
Moved to the milestone where they are first needed:
| Item | Moved to | Why |
|---|---|---|
| Generated Dart and TypeScript API clients | M3 | The API has only two endpoints so far, and the app does not call it until connected mode; the contract file and its check are in place |
| Typed query layer (Drizzle) | M3 | No service code queries the database yet |
Worker process (WorkerMain.ts, pg-boss) | M3 | The first job is sending email |
| Bundled fonts (Newsreader, Geist) | M1 | Needed when real screens are built |
| Service worker, install guide, update notice | M2 | First release |
mobile-release.yml, signing, store listings | With the native apps | Not part of the first release (FR-APP-8) |
| Running the deployment on the real server | When the VPS and DNS exist | deploy.yml and Infra/ServerSetup.md are written but not yet run |
19. Open technical questions
- Admin framework: Angular is assumed. React is an equally good choice if preferred; only
Apps/Adminwould change. VPS planAnswered: SS 2.1 (2 GB) is Rp 80.000 a month before VAT; XS 1.1 (1 GB) is Rp 59.000. See Infra/BiznetResources.md.NEO Object Storage pricingAnswered: NSS Single Region 1 is Rp 1.000 a month. See Infra/BiznetResources.md.- Error tracking: logs only (default), or a hosted tracker with strict scrubbing?
- Brevo vs. Resend: Brevo is assumed; either works.
- Service worker: Flutter's generated service worker or a small hand-written one (precache the app's files, update on Reload). Decide in M2 by testing offline start and updates on an iPhone and an Android phone.
- Drift storage on the web: which backend (IndexedDB or the Origin Private File System) survives best on iPhone Safari, in a home-screen app, and across a week unused. Decide in the M1 spike (§18) before any more screens are built.
- Encryption on the device: whether the database and the backup file should be encrypted with a key derived from the PIN or a password. This protects against a lost phone but makes a forgotten PIN unrecoverable (BRD §14, question 4).
- Dropping the
StatsandExportAPI modules: the plan computes everything on the device (§9.4). Revisit only if a shared space can grow beyond what a phone handles comfortably. - Several devices with local data: the plan lets one device upload and the others use the server's data (§10.5). A real merge is not attempted; decide whether people will ask for it.
20. Requirements traceability
| Requirement | Design section |
|---|---|
| FR-MOD-1 to 12 | §2, §8.4, §9.3, §10, §11.1 |
| FR-REQ-1 to 6 | §5.3 access_request, §9.1, §9.6, §9.7, §11.3 |
| FR-AUTH-1 to 9 | §8 (connected mode) |
| FR-ADM-1 to 18 | §2.3, §7.2, §7.5, §8.3, §8.5, §8.6, §9.5 |
| FR-SPC-1 to 17 | §5.4, §7.4, §5.6, §9.1, §9.3 |
| FR-FIN-1 to 15 | §5.5, §6.3, §5.6 |
| FR-TRX-1 to 9 | §5.5, §6, §7.4, §10, §12 |
| FR-CAT-1 to 4 | §5.5 category, §7.4 (BR-31 trigger) |
| FR-BUD-1 to 8 | §5.5, §6.2, §6.3, §6.4, §12 |
| FR-DSH-1 to 11, FR-NAV-1 | §6.3, §9.3, §9.4 |
| FR-DAT-1, 2 | §9.4 (CSV created on the device), §10.3 (backup file), Phase 2 import |
| FR-NOT-1 to 4 | §11.2, §12 |
| FR-SET-1 to 4 | §5.3 app_user, §5.4 space |
| FR-APP-1 to 7 | §2, §3.2, §8.2, §9.2, §9.6, §10.1, §11.1, §11.2 |
| FR-APP-8 (native apps, later) | §3.2, §8.3, §8.4, §11.1 |
| FR-SUB-1 to 7 (Phase 4) | §5.3 (paid_until, contribution_exempt), §5.5 contribution_payment, §12 |
| NFR-1 Accuracy | §5.1, §6 |
| NFR-2 Security | §7, §8, §15, §16 |
| NFR-3 Privacy | §5.6, §9.7, §16 |
| NFR-4 Performance | §5.5 indexes, §6.3, §9.4 |
| NFR-5 Availability | §13, §15 |
| NFR-6 Scalability | §5.5 indexes, §13.1 |
| NFR-7 Usability | §9.3, §9.4 |
| NFR-8 Localization | §6.1, §9.8 |
| NFR-9 Platforms | §3, §11.1, §17 |
| NFR-10 Backup | §14.1, §14.2 |
| NFR-11 Admin security | §8.3, §16 |
| NFR-12 Audit | §5.3 audit_log, §7.5 |
| NFR-13 Running cost | §1, §11.1, §13.1, §19 |
| NFR-14 Device data | §10.3 |
| NFR-15 Hosting | §13.1, §13.3 |
| NFR-16 Recoverability | §13.6, §14.3 |
| NFR-17 Web addresses | §2.3, §11.3, §13.2, §13.7 |
| NFR-18 Data retention | §12, §13.3, §15 |