Skip to content

Crest — Technical Design ​

ProductCrest — Personal Finance App
Document version0.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
Date2026-10-05
Based onBusinessRequirements.md v0.23, UserJourneys.md v0.12, Conventions.md v0.3
OwnerReizkian Y. Radityatama
StatusDraft — 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 ​

  1. Summary of decisions
  2. Architecture
  3. Technology stack
  4. Repository layout
  5. Data model
  6. Money, currencies, and calculations
  7. Authorization and row-level security
  8. Authentication
  9. Application design
  10. Local data, sync, and backup
  11. App distribution, notifications, and email
  12. Background jobs
  13. Infrastructure and deployment
  14. Backups and recovery
  15. Observability
  16. Security checklist
  17. Testing strategy
  18. Delivery plan
  19. Open technical questions
  20. Requirements traceability

1. Summary of decisions ​

TopicDecisionMain reason
ArchitectureLocal-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
BackendNestJS (TypeScript) REST API, described by an OpenAPI specificationStructured, well known to the owner, generates the API contract
Users' appFlutter, 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 portalAngular single-page app (web)Small desktop tool; same language as the API
Public siteStatic HTML pages: landing, request access, activate, reset password, install, privacy, termsNo framework needed; works before the user has opened the app
API clientsGenerated from the OpenAPI spec: Dart client for Flutter, TypeScript client for AngularClients can't drift from the API
HostingOne self-managed Biznet Gio NEO Lite VPS in Indonesia, Ubuntu 24.04 LTS, 2 GB RAMNFR-13, NFR-15
Web addressescrest.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
DatabasePostgreSQL 17, same VPS, never exposed to the internetNFR-2, NFR-15
Data accessHand-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 M1The schema, policies, and functions are plain SQL that can be read and reviewed
AuthorizationPostgres row-level security (RLS) + separate DB roles for user endpoints, admin endpoints, and the workerNFR-2, FR-ADM-17 enforced by the database
AuthenticationConnected 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 providersFR-AUTH-*, BR-20
App lockA Crest PIN. Device biometrics are added with the native appsFR-AUTH-6
Background jobspg-boss (queue stored in Postgres) in a separate worker processNo Redis needed; fits 2 GB RAM
Web server / HTTPSCaddy: reverse proxy for the API, file server for the Crest app, the admin portal, and the siteAutomatic certificates; simplest to run
DeploymentDocker Compose on the VPS; images and static bundles built by GitHub ActionsNFR-16
EmailBrevo for sending; how contact@ receives mail is not decided yet (§11.3)Deliverability; $0
DNSBiznet NEO DNS (free with the domain); domain registered at Biznet GioAlready the nameserver; $0
NotificationsAt launch: the notification list inside the app, plus email. Web Push (the browser standard, no third-party service) in Phase 2FR-NOT-3; works in an installed PWA on iPhone 16.4+ and Android
BackupsConnected users: nightly encrypted pg_dump → Biznet NEO Object Storage. Local-mode users: a backup file they save themselvesNFR-10, FR-MOD-5
MonorepoOne 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
NamingPascalCase files in every app, replacing each framework's defaultConventions.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 ​

ContainerImagePurposeExposed
caddycaddy:2HTTPS, certificates, reverse proxy to api, file server for the Crest app, the admin portal, and the public site80, 443
apighcr.io/…/crest-apiNestJS REST APIInternal 3000
workersame image as api, started with WorkerMainScheduled and queued jobsNone
postgrespostgres:17DatabaseNone (internal network only)
backupsmall image with pg_dump, age, rcloneNightly backup uploadNone

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 ​

HostServesUsed by
crest.radityatama.web.idPublic site: landing, /request-access, /activate, /reset-password, /install, /privacy, /termsVisitors, invited users
app.crest.radityatama.web.idThe 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.idThe API: /v1/*, /healthCrest app, admin portal, public site forms
admin.crest.radityatama.web.idAdmin 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 stable code values 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) ​

AreaChoiceNotes
Language / runtimeTypeScript (strict) on Node.js LTS
FrameworkNestJSModules per domain (§4)
API description@nestjs/swagger → OpenAPI 3.1Source of the generated clients
Validationclass-validator + class-transformer DTOs, global validation pipe (whitelist on)DTOs also feed the OpenAPI spec
DatabasePostgreSQL 17 + extensions pgcrypto, pg_trgm, citext
MigrationsSQL 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
Queriespg; a typed query layer (Drizzle) is added with the first services in M1
Passwords, PINargon2 (argon2id)
Tokens@nestjs/jwt for access tokens; random refresh tokens stored hashed§8
Two-factorotplib (TOTP) + hashed backup codesSuper Admins
Rate limiting@nestjs/throttlerLogin, reset, access requests
Jobspg-bossQueue + cron schedules in Postgres
EmailNodemailer over Brevo SMTP; Handlebars templates in English and Bahasa Indonesia
Pushweb-push (Web Push standard, Phase 2)§11.2
Loggingpino (structured JSON)No financial data in logs
TestingJest, 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.

AreaChoiceNotes
FrameworkFlutter (stable channel), DartWeb (PWA) is the released target; android/ and ios/ stay in the project for development and for the later native apps
Web buildflutter build web --release, served as static files from app.crest.radityatama.web.idAbout 3 MB compressed on first visit, then cached (NFR-4)
PWAweb/manifest.json (name, icons, display: standalone, theme colours) and a service worker that caches the app's filesAdd to home screen (FR-APP-2), opens offline (FR-APP-4)
LayoutPhone-width: on wide screens the app is centred in a phone-sized frame (lib/Core/Widgets/PhoneFrame.dart)FR-APP-6
State managementRiverpod
Navigationgo_routerBottom tabs + nested routes; Stats state in route parameters (§9.4)
API clientGenerated from OpenApi.json with openapi-generator (dart-dio), wrapped in lib/Core/Api. Used only in connected mode, through the sync layerAdds auth and refresh handling; requests are sent with credentials so the refresh cookie travels
SessionConnected mode only: access token in memory; refresh token in an HttpOnly cookie the app's code can't read§8.3
App lockPIN screen (optional in local mode)§8.4. local_auth (Face ID, fingerprint) is added with the native apps
Local databasedrift (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 fileJSON written and read in Dart, shared through share_plus or a file download§10.3
Storage persistencenavigator.storage.persist() through dart:js_interop, and a check for display mode standalone§10.3, FR-MOD-6, FR-MOD-7
Chartsfl_chartDonut, bars; the calendar heatmap is a custom grid
Localizationflutter_localizations + intl, ARB files (app_en.arb, app_id.arb)
NotificationsConnected mode only: in-app list (Phase 1b); Web Push through the service worker (Phase 2)§11.2
Sharingshare_plusCSV export through the device share menu, or a file download where the browser has no share menu
ThemeMaterial 3 with the design tokens from the prototype (seed #4C662B, light and dark); fonts Newsreader and Geist bundled as assets
Testingflutter_test (unit, widget), integration_test§17

3.3 Admin portal (Apps/Admin) ​

AreaChoiceNotes
FrameworkAngular 21 (standalone components, signals), TypeScript 5.9Angular 22 needs Node 24.15+; upgrade when the development machine and CI have it
UIAngular MaterialTables, forms, dialogs
API clientGenerated from OpenApi.json (typescript-angular)
LocalizationJSON files in Public/i18n (en.json, id.json)
TestingAngular'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 ​

AreaChoice
API contractPackages/ApiContract/OpenApi.json (generated, committed)
Money test vectorsPackages/MoneyTestVectors/*.json: input → expected output cases run by both the API's Jest tests and the app's Dart tests (§6)
Monorepo toolingpnpm workspaces + Turborepo for Apps/Api and Apps/Admin; Flutter's own tooling for Apps/Mobile
Lint / formatESLint + Prettier (TypeScript); flutter analyze + dart format (Dart); Scripts/CheckFileNames.ts for every file
CI/CDGitHub Actions + GitHub Container Registry (GHCR)
Reverse proxyCaddy 2
Backupspg_dump, age (encryption), rclone (S3 upload)
UptimeUptimeRobot (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.mjs

The 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:

ColumnTypeMeaning
iduuid PKMay 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_attimestamptz 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_byuuid → app_user null, ON DELETE SET NULLWho created it. Null = created by the system, or the user was deleted ("Former member", BR-34)
updated_attimestamptz not null, set by triggerLast change; also the sync cursor (§10)
updated_byuuid → app_user null, ON DELETE SET NULLWho made the last change ("Edited by", FR-SPC-7)
deleted_attimestamptz nullSoft 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:

TableColumnsWhy
audit_logid, created_at, created_by onlyAppend-only; never updated or deleted
space_member, exchange_rate, device_push_token, contribution_paymentStandard columns without deleted_atLink or settings rows; removed with a normal delete
app_userStandard 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_totpStandard columns without deleted_atSecurity records; removed with a normal delete

Other conventions

  • Money: bigint in 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's created_at into 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_id duplicates 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.

ColumnTypeNotes
nametext
emailcitext uniqueBR-9
password_hashtext nullargon2id; null until the user activates
email_verifiedbooleanTrue after activation
pending_emailcitext nullNew address waiting for confirmation (§8.6)
statusenum user_status (Invited, Active, Deactivated)"Pending"/"Rejected" live on access_request; "Deleted" = row removed
is_super_adminboolean default falseBR-41
base_currencychar(3)FR-SET-1
languagetext (en, id)
time_zonetext (IANA, e.g. Asia/Jakarta)Set from the device on first login
themeenum (Light, Dark, System)FR-SET-2
daily_reminder_timetime nullFR-NOT-1
pin_hashtext nullApp-lock PIN (see §8.4)
last_login_attimestamptz nullFR-ADM-8
paid_untildate nullPhase 4 (FR-SUB-1)
contribution_exemptboolean default falsePhase 4 (FR-SUB-2)

Authentication tables (standard columns without deleted_at):

TableColumnsNotes
auth_sessionuser_id, client (Mobile = the Crest app, Admin), refresh_token_hash, device_name, two_factor_verified, last_used_at, expires_at, revoked_atOne row per logged-in device; deleting rows logs the user out (§8)
auth_tokenuser_id, purpose (Activation, PasswordReset, EmailChange), token_hash, expires_at, used_atSingle-use links (BR-10)
auth_totpuser_id (unique), secret_encrypted, confirmed_at, backup_code_hashesTwo-factor for Super Admins

There are no tables for external login providers (BR-20).

access_request

ColumnTypeNotes
full_name, email (citext), country (char(2)), messageFR-REQ-1
consent_attimestamptzPrivacy consent (NFR-3)
statusenum (Pending, Approved, Rejected)Who decided and when = updated_by / updated_at
rejection_notetext nullInternal only; never emailed (FR-REQ-5)
user_iduuid → app_user nullSet on approval
source_ip_hashtextFor 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:

ColumnTypeNotes
actiontexte.g. RequestApprove, UserDeactivate, UserSuperAdminGrant, AdminTwoFactorReset
target_user_iduuid nullNot a foreign key, so entries survive user deletion
target_request_iduuid null
detailsjsonbNever financial data or space details (FR-ADM-17)
ip_hashtext 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

ColumnTypeNotes
kindenum (Personal, Shared)
nametextPersonal spaces display as "Personal"
currencychar(3)FR-SPC-12; for personal = user's base currency
time_zonetext (IANA)For turning transaction times into dates (§5.1). Personal = user's time zone; shared = creator's, changeable by Owners
budget_start_daysmallint 1–28FR-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)

ColumnTypeNotes
space_iduuid → spaceUnique together with user_id
user_iduuid → app_user
roleenum (Owner, Member)FR-SPC-6
include_in_totalsbooleanFR-SPC-13; default true for owner, false for member
notify_new_txnboolean default falseFR-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)

ColumnTypeNotes
space_iduuid → space
emailcitext
roleenum (Owner, Member)
invitee_user_iduuid nullSet only if the email is an Active user
statusenum (Pending, Accepted, Declined, Cancelled, Expired)
expires_attimestamptzcreated_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

ColumnTypeNotes
space_iduuid → spaceBR-30; cannot change (BR-37, enforced by trigger)
nametext
typeenum (Cash, Bank, Ewallet, CreditCard)
currencychar(3)Locked once any txn exists (FR-FIN-14, BR-26; trigger). The currency of all its transactions (BR-1)
opening_balancebigintMinor units, signed (see §6.3)
provider, color, icontext nullFR-FIN-8
last4char(4) nullCHECK (last4 ~ '^[0-9]{4}$') (BR-19)
credit_limitbigint nullCredit cards only
statement_day, due_daysmallint nullFR-FIN-11
sort_orderintFR-FIN-2
archived_attimestamptz null

category

ColumnTypeNotes
space_iduuid → spaceBR-30
parent_iduuid → category nullSame space; sub-categories (FR-CAT-3)
kindenum (Expense, Income)
name, color, icon
system_keytext nulle.g. fees (FR-TRX-2e); system categories can't be deleted
archived_attimestamptz null
sort_orderint

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".

ColumnTypeNotes
space_iduuidDeliberate copy of the financial account's space, used by RLS (§5.1)
financial_account_iduuid → financial_accountIts currency is the transaction's currency (BR-1)
kindenum (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
amountbigintSigned effect on the financial account balance (§6.3)
category_iduuid → category nullMust be in the same space (BR-31); null = Uncategorized (BR-5)
notetext null
transfer_iduuid → transfer nullSet on both legs of a transfer
fee_for_transfer_iduuid → transfer nullFee expense linked to a transfer (FR-TRX-2e)
refund_of_txn_iduuid → txn nullOptional link (FR-TRX-8)
recurrence_iduuid → recurrence null
counterparty_labeltext nullA 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_pathtext nullPhase 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)

ColumnTypeNotes
ratenumeric(24,12) nullUnits 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

ColumnTypeNotes
space_iduuid → space
category_iduuid → categorySame space
amountbigintIn the space's currency (BR-22)
effective_fromdateFirst 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)

TableColumns
savings_goalspace_id, name, target_amount, target_date, financial_account_id null (same space, FR-BUD-5), completed_at
goal_contributiongoal_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)

ColumnNotes
space_id
templatejsonb: kind, financial_account_id, to_financial_account_id, amount, category_id, note, rate
frequencyenum (Daily, Weekly, Monthly, Yearly) + interval
anchor_dayFor monthly/yearly; 29–31 fall back to the last day (BR-25)
start_on, end_on, next_run_onDates
stopped_atSet 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).

ColumnNotes
user_id, currencyUnique together
rate_to_basenumeric(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 ​

EventImplementation
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,50 vs 1,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 rate is informational (BR-16). When the user edits the received amount, the rate is recalculated as received / 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:

KindSignExample
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 with created_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 ​

RoleUsed byAccess
crest_ownerMigrations and backupsOwns all tables and functions. Not a superuser
crest_appapi, for user endpoints (the Crest app) and public endpointsRLS applies (no BYPASSRLS). Full use of financial tables through policies; authentication tables
crest_adminapi, for /v1/admin/* endpoints onlyNo 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_workerworkerNarrow 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) ​

TableSELECTINSERTUPDATEDELETE
spacemembervia create_shared_space()ownervia delete_space() (owner)
space_membermember of that spacevia invitation acceptanceowner (roles); self (own settings)owner (remove) or self (leave)
space_invitationowner of space, or inviteeownerinvitee (accept/decline), owner (cancel)—
financial_accountmemberownerownerowner (via function)
categorymemberownerownerowner
budget, savings_goalmemberownerownerowner
txnmember of txn.space_idmember, created_by = me, financial account and category in the same spaceowner, or created_by = me (BR-36)same as update
transfermember of the space of either legvia upsert_transfer()via functionvia function
recurrencemembermember (created_by = me)creator or ownercreator or owner
exchange_rate, device_push_tokenuser_id = mesamesamesame

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:

FunctionPurpose
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_session rows, 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) ​

  1. Approval creates the user with no password and a single-use token (stored hashed in auth_token) that expires in 72 hours.
  2. The email links to the public site: https://crest.radityatama.web.id/activate?token=….
  3. The page sends the token and the new password to POST /v1/auth/activate. The API sets the password, marks the token used, and sets status = active.
  4. The page then shows an Open Crest button (to https://app.crest.radityatama.web.id) and a link to the install page (§11.1).
  5. "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 appAdmin portal
Access tokenIn memoryIn memory
Refresh tokenIn a __Host-crest_app cookie: Secure, HttpOnly, SameSite=Strict, sent only to the APIIn a __Host-crest_admin cookie with the same settings
Lifetime30 days, extended on use12 hours at most, 30 minutes idle (NFR-11)
Extra factorApp 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., and api.crest.radityatama.web.id belong 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 the Origin header 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_session needs 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/pin over 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 … (runs CreateSuperAdmin.ts, on the server) creates the first Super Admin (FR-ADM-3).
  • pnpm --filter @crest/api admin:reset-2fa --email … (runs ResetTwoFactor.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.

ModuleMain endpointsUsed by
AuthPOST /auth/login, /auth/refresh, /auth/logout, /auth/activate, /auth/password/forgot, /auth/password/reset, /auth/totp/*, PUT /auth/pinApp, admin, site
AccessRequestsPOST /access-requests (public)Site, the app's Connect screen
AdminGET /admin/overview, GET/POST /admin/access-requests/* (approve, reject), GET/POST/PATCH/DELETE /admin/users/*, GET /admin/audit-logAdmin
UsersGET/PATCH /me, DELETE /me, GET/PUT /me/exchange-ratesApp
SpacesGET/POST /spaces, PATCH/DELETE /spaces/{id}, members, invitations (/spaces/{id}/invitations, /invitations/{id}/accept)App
FinancialAccountsGET/POST /spaces/{id}/financial-accounts, PATCH/DELETE /financial-accounts/{id}App
TransactionsGET/POST /spaces/{id}/transactions (search, filters, cursor), PATCH/DELETE /transactions/{id}App
TransfersPOST /transfers, PATCH/DELETE /transfers/{id} (legs may be in different spaces)App
Categories, Budgets, GoalsCRUD under /spaces/{id}/…App
SyncGET /sync/pull, POST /sync/push, GET /me/sync-state, POST /sync/import (§10.4, §10.5)App, connected mode
NotificationsGET /notifications, PUT /me/devices/{id} (push subscription, Phase 2)App
Health, AppConfigGET /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 in lib/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 ThemeData built from the prototype's tokens (light and dark), Newsreader for numbers and titles, Geist for UI text.

9.3 Crest app screens ​

ScreenJourneys
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, GoalsUJ-27 to UJ-29
Financial accounts (grouped by type)UJ-31 to UJ-33
Spaces: switcher, members, invitations, space settingsUJ-42, UJ-43, UJ-48, UJ-49
More / Settings: profile, language, theme, currency, exchange rates, app lock, notifications, export, backup, connect or log out, deleteUJ-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 ​

PagePurpose
/What Crest is, with an Open Crest button (the app, no sign-up) and a smaller Request access for sync and sharing
/request-accessThe 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)
/installHow to add Crest to the home screen, with the steps for the visitor's device (FR-APP-2, UJ-38)
/privacy, /termsPrivacy 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 with intl and 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, and exchange_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, no space_member: there is one Personal space and one person.
  • created_by and updated_by are 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.json from 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) ​

  1. 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).
  2. Empty, and the device has data: the app asks Upload or Start fresh. For Upload it rewrites space_id on every row to the server's Personal space ID (the local space row itself is not uploaded) and sends the rows in chunks to POST /v1/sync/import.
  3. 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_by and updated_by become the user. Rows the server rejects are returned to the app, which offers to save them to a file (UJ-52 A4).
  4. 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).
  5. The app then runs a normal pull from cursor zero, so the device gets the server's updated_at values and everything is in step.
  6. 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).
  7. 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_by set 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).

WhoHow they get Crest
EveryoneOpen 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 /install page 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.json sets 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, and manifest.json are served with no-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.json from 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 at GET /v1/app-config and can refuse a request from a version below minimumVersion:
    • 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.
  • The static file and the API's /v1/app-config always carry the same two numbers (both come from the deploy). The API stays backward-compatible with every app version at or above minimumVersion. Because a web app updates within a day for almost everyone, minimumVersion can follow latestVersion closely.

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-push library) straight to the browser's push service, so no Firebase account is needed. Subscriptions are stored in device_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-to contact@radityatama.web.id.
  • contact@radityatama.web.id is 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 offering contact@ as a receiving address. Until it is decided, the app sends with Reply-To: contact@radityatama.web.id and 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 to p=quarantine after 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 a 535 authentication 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.

JobSchedule / triggerRequirement
recurrence.runEvery hour; creates occurrences whose due date (in the space's time zone) has arrived, with created_at set to that due dateFR-TRX-5b, BR-25
budget.checkQueued after each expense/refund writeFR-BUD-3, FR-NOT-2 (one alert per threshold per period)
card.dueDaily; credit cards with due_day in 3 days; notifies the space's OwnersFR-FIN-11
invitation.expireHourlyFR-SPC-5
token.cleanupDaily; remove expired activation, reset, and email-change tokens and expired sessionsBR-10
push.send, email.sendQueue, with retries and backoff§11
reminder.dailyEvery 15 minutes (Phase 2); sends the daily reminder to users whose chosen time has comeFR-NOT-1, §11.2
tombstone.purgeWeekly; hard-delete rows soft-deleted > 90 days§5.1
access_request.purgeMonthly; delete rejected requests older than 6 monthsNFR-18
audit.retentionMonthly; entries older than retention (≥ 1 year)NFR-12
contribution.remindDaily (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 ​

ItemValue
Provider / productBiznet Gio NEO Lite (Indonesia)
Size2 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)
OSUbuntu 24.04 LTS
Hostnamecrest-prod.radityatama.web.id
Expected memory usePostgres ~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.

NameTypeValue
crest-prodAVPS IPv4
crestA (or CNAME → crest-prod)VPS IPv4 (public site)
app.crestA (or CNAME → crest-prod)VPS IPv4 (the Crest app)
api.crestA (or CNAME → crest-prod)VPS IPv4 (API)
admin.crestA (or CNAME → crest-prod)VPS IPv4 (admin portal)
@MXNot set. How contact@ receives mail is undecided (§11.3)
@TXTbrevo-code:<verification code shown by Brevo>
brevo1._domainkeyCNAMEb1.radityatama-web-id.dkim.brevo.com
brevo2._domainkeyCNAMEb2.radityatama-web-id.dkim.brevo.com
_dmarcTXTv=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-upgrades for security updates; automatic reboot window at 03:00 WIB if required.
  • fail2ban for 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 ​

EnvironmentWherePurpose
LocalDeveloper 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
CIGitHub Actions: Postgres service containerTests
ProductionThe VPSReal 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.

PartPortSet in
API4300dev:api script (PORT=4300); default in Apps/Api/Src/Config/EnvironmentLoader.ts
Admin portal4310Apps/Admin dev script (ng serve --port 4310) and angular.json
Public site4320dev:site script
Crest app (in Chrome)4330dev:mobile:web script (--web-port 4330); allowed by the API as ORIGIN_APP
PostgreSQL54329Infra/docker-compose.dev.yml
Mail catcher (SMTP / web)1025 / 8025Infra/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 minimumVersion still uses.
  • The VPS never builds anything; it only pulls images and receives static files.

13.7 Configuration (environment variables) ​

VariableExample / note
ORIGIN_SITE, ORIGIN_APP, ORIGIN_ADMIN, ORIGIN_APIhttps://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_OWNEROne per DB role
JWT_ACCESS_SECRET, TOTP_ENCRYPTION_KEY, IP_HASH_SECRETRandom 32+ bytes each. IP_HASH_SECRET keys the hash of client IPs (rate limits, access_request.source_ip_hash)
TRUST_PROXY_HOPSProxies in front of the API (1: Caddy), so the client IP is read correctly
MIN_APP_VERSION, LATEST_APP_VERSIONServed 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_FROMBrevo'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_KEYWeb Push (Phase 2)
BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_KEY, BACKUP_S3_SECRET, BACKUP_AGE_RECIPIENTBackups
CREST_TAGAPI 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) ​

  1. 02:00 WIB: pg_dump --format=custom as crest_owner (the table owner can read every row; no extra role is needed).
  2. Encrypt with age to 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.
  3. Upload with rclone to Biznet NEO Object Storage bucket crest-backups/daily/YYYY-MM-DD.dump.age.
  4. 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).
  5. 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:

  1. Create a new NEO Lite VPS (same size), apply Infra/ServerSetup.md.
  2. Install Docker, copy Infra/ and the restored .env.
  3. Start Postgres, restore the latest backup.
  4. Point DNS (crest-prod, crest, app.crest, api.crest, admin.crest) to the new IP.
  5. docker compose up -d; Caddy issues certificates.
  6. Verify login, a known balance, and email sending.

15. Observability ​

WhatHow
UptimeUptimeRobot checks https://api.crest.…/health (database reachable, worker heartbeat fresh) and the public site every 5 min; alerts by email/Telegram
BackupsHeartbeat monitor (§14.1)
LogsStructured 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
ErrorsAPI 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 / memoryDaily cron script emails the owner if disk > 80% or swap usage is high
Jobspg-boss tables show failed jobs; an admin-only endpoint lists failures (no financial content)

16. Security checklist ​

AreaMeasureRequirement
TransportHTTPS only, HSTS, TLS via CaddyNFR-2
Data at restVPS disk encryption where the provider supports it; backups encrypted with ageNFR-2, NFR-10
IsolationRLS on every financial/space table; separate DB roles; admin role has no financial grantsNFR-2, FR-ADM-17, BR-12
AuthenticationNo 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 revocationFR-AUTH-*, BR-20
AdminSeparate host, HttpOnly cookie for the refresh token, TOTP + backup codes, 30-min idle timeout, full audit logFR-ADM-2, NFR-11, NFR-12
APIInput 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 appNo 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 checkFR-AUTH-6, FR-APP-5
Local modeNo 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 protectionFR-MOD-2, NFR-2, BR-46, BR-49
Upload and restoreEvery 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 fieldsOnly last 4 digits stored (DB check constraint); no card numbers, CVV, or bank logins anywhereBR-19
ServerKey-only SSH, firewall, automatic updates, fail2ban, Postgres not exposedNFR-15
DependenciesLockfiles, Dependabot alerts, pnpm audit and flutter pub outdated in CI
PrivacyConsent 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 accessNFR-3

17. Testing strategy ​

LevelToolWhat
API unitJestApps/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 vectorsJest + flutter_testThe JSON cases in Packages/MoneyTestVectors run against both the API's TypeScript and the app's Dart, so the two implementations can't disagree
DatabaseJest + 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 integrationJest + SupertestEndpoints against a test database: approve request → user + personal space + token; login, refresh rotation and reuse detection; invitation lifecycle; cross-space and cross-currency transfers
ContractCI checkOpenApi.json matches the code; generated Dart and TypeScript clients compile
App unit and widgetflutter_testMoney 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 uploadflutter_test + JestBackup 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-endintegration_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)
AdminAngular unit tests, PlaywrightUJ-09 (approve with 2FA), UJ-12, UJ-13
Public sitePlaywrightUJ-01 (request access), UJ-03 (activate)
ManualReal devicesAn 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.

#MilestoneContentsKey journeys
M0Foundations (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
M1Local coreThe 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
M2Local insight and releaseBudgets, 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.idUJ-25, UJ-27, UJ-30, UJ-31, UJ-36 to UJ-39, UJ-51
Phase 1b: Connected
M3Access & adminAPI: 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 userUJ-01 to UJ-15
M4SyncAPI: 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 testUJ-52, UJ-25 (sync), UJ-07
M5SpacesShared spaces, invitations, roles, cross-space transfers, "Added by", leave/remove/deleteUJ-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) ​

BuiltWhere
Monorepo, lint, format, file-name check in a pre-commit hook and CIroot, 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 testApps/Api, Packages/ApiContract/OpenApi.json
Database: all tables, row-level security, triggers, functions; 40 tests against real PostgreSQLApps/Api/Migrations, Apps/Api/Test/Database
Money rules in TypeScript and Dart, both passing the same 94 shared casesApps/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 AndroidApps/Mobile
One-command local development on fixed portsroot package.json, Scripts/RunMobile.sh, §13.5
Angular admin shell: navigation and four placeholder screensApps/Admin
Public site skeletonApps/Site
Local development: database with real roles, demo dataInfra/docker-compose.dev.yml, SeedDemoData.ts
Production files: API image, Compose, Caddy, backup image, server setup and recovery runbooksApps/Api/Dockerfile, Infra/, Infra/Runbooks/

Moved to the milestone where they are first needed:

ItemMoved toWhy
Generated Dart and TypeScript API clientsM3The 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)M3No service code queries the database yet
Worker process (WorkerMain.ts, pg-boss)M3The first job is sending email
Bundled fonts (Newsreader, Geist)M1Needed when real screens are built
Service worker, install guide, update noticeM2First release
mobile-release.yml, signing, store listingsWith the native appsNot part of the first release (FR-APP-8)
Running the deployment on the real serverWhen the VPS and DNS existdeploy.yml and Infra/ServerSetup.md are written but not yet run

19. Open technical questions ​

  1. Admin framework: Angular is assumed. React is an equally good choice if preferred; only Apps/Admin would change.
  2. VPS plan Answered: 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.
  3. NEO Object Storage pricing Answered: NSS Single Region 1 is Rp 1.000 a month. See Infra/BiznetResources.md.
  4. Error tracking: logs only (default), or a hosted tracker with strict scrubbing?
  5. Brevo vs. Resend: Brevo is assumed; either works.
  6. 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.
  7. 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.
  8. 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).
  9. Dropping the Stats and Export API modules: the plan computes everything on the device (§9.4). Revisit only if a shared space can grow beyond what a phone handles comfortably.
  10. 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 ​

RequirementDesign 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

Crest is a personal project by Reizkian Y. Radityatama.