Appearance
Crest — API Endpoints
| Product | Crest — Personal Finance App |
| Document version | 0.1 (Draft) |
| Date | 2026-10-06 |
| Based on | TechnicalDesign.md §9.1, ApiModuleStandard.md, BusinessRequirements.md, UserJourneys.md |
| Prototypes | Prototype/CrestPublicSite.dc.html, Prototype/CrestAdminPortal.dc.html, Prototype/CrestPrototypeV3.dc.html |
| Owner | Reizkian Y. Radityatama |
| Status | Plan. The OpenAPI file stays the contract once an endpoint is built; this document says what to build and why |
For every screen in the three prototypes this document says which endpoint it calls, which requirement (FR/BR) and journey (UJ) it serves, and what goes in and out. The Technical Design §9.1 lists the modules; this document goes down to the request and response of each endpoint.
Status column: Built = in Packages/ApiContract/OpenApi.json today. M3, M4, M5 = the milestone in TechnicalDesign.md §18. P2 = Phase 2.
1. Rules that apply to every endpoint
Details are in ApiModuleStandard.md; only what a reader of the tables below needs is repeated here.
- All paths are under
/v1, lowercase kebab-case plural nouns, JSON fields camelCase. - Auth column:
Public=@Public()(listed inTest/Integration/RouteAccessSpec.ts);User= any logged-in user;Admin= Super Admin in a two-factor-verified admin session (SuperAdminGuard);Owner/Member= role in the space in the path. - Money is an integer in minor units (
"amount": 45000is IDR 45,000; USD 3.20 is320). Never a decimal, never a string. A transaction'samountis signed (BRD §6.3). Exchange rates are strings of at most 12 decimals, because they are not money. - Bodies, queries, and responses are always named (
…Body,…Query,…Response). A route with nothing to return declares204or202, and has no body (ApiModuleStandard.md §1.4). - Ids are UUIDs. A create may send its own
id; sending it again changes nothing (BR-48). - Dates:
createdAtof a transaction is its date and time (BR rule 5). All timestamps are ISO 8601 UTC. - Lists return
{ items, nextCursor }withlimit(1–100, default 20) andcursor. Filters are flat; arrays are repeated keys. - Related records come back as summaries:
{ "id": "…", "name": "…" }. - Errors have one shape (
type,title,status,detail,code,requestId, optionalerrors[]). The tables list thecodevalues a client must handle; every endpoint can also returnUnauthenticated(401, unless Public),RateLimited(429), andInternalError(500), which are not repeated.
2. Public site (Apps/Site)
Prototype: CrestPublicSite.dc.html. The home, install, privacy, and terms pages are static and call nothing. Three pages call the API.
| Page | Endpoint | Auth | Requirements | Journeys | Status |
|---|---|---|---|---|---|
| Request access | POST /access-requests | Public | FR-REQ-1 to 4, FR-REQ-6, BR-14, BR-44 | UJ-01, UJ-02 | Built |
| Activate your login | POST /auth/activate | Public | FR-AUTH-2, FR-AUTH-3, BR-10, FR-APP-7 | UJ-03 | Built |
| Reset your password (ask) | POST /auth/password/forgot | Public | FR-AUTH-7, FR-APP-7 | UJ-05 | Built |
| Reset your password (set) | POST /auth/password/reset | Public | FR-AUTH-7, FR-APP-7 | UJ-05 | Built |
POST /access-requests — Built
Also used by the app's Request access screen (§5.1), so the site and the app send the same body.
Request:
json
{
"id": "0190f0e2-6c6e-7c2a-9f3b-2a1f4e6d9a10",
"fullName": "Dewi Septaria",
"email": "dewi@example.com",
"country": "ID",
"message": "Rina recommended Crest. I want to track our household spending.",
"acceptedTerms": true,
"confirmedAdult": true,
"formOpenedAt": "2026-10-06T08:01:12Z"
}website is the hidden honeypot and must be left out or empty; formOpenedAt is when the form was shown (§9.7: a submission within seconds is treated as a bot).
Response 202 Accepted, the same for a new, a duplicate, and a recently rejected email (FR-REQ-4, BR-14); no body that says which it was. Errors: ValidationFailed, RateLimited (3 per hour and 10 per day per IP hash, §9.7).
POST /auth/activate — Built
json
{ "token": "…", "password": "at least 10 characters" }Response 204. Errors: NotFound (404: the link is unknown, used, or expired after 72 hours, all the same answer so a probe learns nothing; the site's set-password.js already shows its "link expired" text for it), ValidationFailed (a password under 10 characters). The common-password list of Technical Design §8.1 is not checked yet. The page's "The two passwords don't match" check is client-side only, so the confirm field is not sent.
POST /auth/password/forgot — Built
json
{ "email": "dewi@example.com" }Response 202 with no body, always, whether or not the email has a login. Sends a 1-hour link to /reset-password?token=… only to an Active user, over SMTP: Brevo in production, Mailpit while developing (read the message at http://localhost:8025). The site has no page with an email field for this call yet; the reset page only takes the token.
POST /auth/password/reset — Built
json
{ "token": "…", "password": "…" }Response 204. Revokes every session of the user. Errors: as activate. A deactivated user's link does not work.
3. Super Admin portal (Apps/Admin)
Prototype: CrestAdminPortal.dc.html (Login, Overview, Access requests, Users, Audit log, Settings). Every endpoint is under /v1/admin/… and has SuperAdminGuard, except login, which is how the session starts.
3.1 Login and two-factor
Requirements: FR-ADM-1, FR-ADM-2, FR-ADM-2a, NFR-11
The prototype's login has four stages: password, six-digit code (or backup code), first-time setup with a QR code, and backup codes.
| Stage | Endpoint | Auth | Status |
|---|---|---|---|
| Password | POST /auth/login with client: "admin" | Public | M3 |
| Code or backup code | POST /auth/totp/verify | Admin session, not yet verified | M3 |
| First time: QR and key | POST /auth/totp/setup | Admin session, not yet verified | M3 |
| First time: confirm the first code, receive backup codes | POST /auth/totp/confirm | same | M3 |
| Log out, 30 minutes idle | POST /auth/logout | User | M3 |
| Keep the session alive | POST /auth/refresh (cookie __Host-crest_admin) | Public (cookie) | M3 |
json
// POST /auth/login
{ "email": "reizkian@crest.app", "password": "…", "client": "Admin" }
// 200: the access token is in the body, the refresh token is in the cookie
{ "accessToken": "…", "expiresIn": 900, "twoFactor": "Required" }twoFactor is "Required" (enter a code), "SetupRequired" (first login of a new Super Admin, FR-ADM-16), or "NotRequired" (app client). Errors: InvalidCredentials (401, the same for a wrong email, a wrong password, an unactivated user, and a deactivated user), RateLimited (growing delay per email).
json
// POST /auth/totp/verify
{ "code": "123456" } // or { "backupCode": "XXXX-XXXX" }
// 200
{ "accessToken": "…", "expiresIn": 900 }
// POST /auth/totp/setup → 200
{ "otpauthUri": "otpauth://totp/Crest:…?secret=…", "secret": "JBSWY3DPEHPK3PXP" }
// POST /auth/totp/confirm { "code": "123456" } → 200
{ "backupCodes": ["ABCD-1234", "…"] } // shown once; "Download .txt" is made by the browserErrors: InvalidTotpCode, InvalidBackupCode. A used backup code stops working (FR-ADM-2a).
3.2 Overview
Requirements: FR-ADM-15
GET /admin/overview · Admin · M3
json
{
"pendingRequests": { "count": 3, "today": 1, "oldestDays": 4 },
"activeUsers": { "count": 4, "signedInToday": 2 },
"invitedUsers": { "count": 2, "bounced": 1 },
"newUsers": { "thisMonth": 3, "thisWeek": 1 },
"pendingTop": [ { "id": "…", "fullName": "Dina Pratama", "email": "dina@example.com", "createdAt": "…" } ],
"recentActivity": [ { "id": "…", "action": "RequestApprove", "actor": { "id": "…", "name": "…" }, "target": "…", "createdAt": "…" } ]
}The four cards and the two lists are one call, so the page opens with one request. pendingTop is the 3 newest pending requests; recentActivity is the 5 newest audit entries (the same shape as §3.5).
3.3 Access requests
Requirements: FR-ADM-4 to FR-ADM-6a, FR-ADM-13, FR-ADM-18
| Screen element | Endpoint | Requirements | Status |
|---|---|---|---|
| Tabs Pending / Approved / Rejected with counts, list, newest first | GET /admin/access-requests?status=Pending&limit=&cursor= | FR-ADM-4 | Built (the counts are not in the response yet, see §7) |
| Detail panel | the list item, no separate call | FR-ADM-4 | — |
| Approve (also re-approve a rejected one) | POST /admin/access-requests/{id}/approve | FR-ADM-5, FR-ADM-6a | M3 |
| Reject, with an internal note | POST /admin/access-requests/{id}/reject | FR-ADM-6 | M3 |
| Select several, Approve selected / Reject selected | POST /admin/access-requests/approve and /reject with ids | FR-ADM-18 | M3 |
List item (today's AccessRequestResponse, plus the three fields the detail panel needs):
json
{
"id": "…", "fullName": "Dina Pratama", "email": "dina@example.com", "country": "ID",
"message": "…", "status": "Approved", "rejectionNote": null, "createdAt": "…",
"decidedAt": "2026-09-21T09:30:00Z",
"decidedBy": { "id": "…", "name": "Reizkian Yesaya" },
"user": { "id": "…", "name": "Dina Pratama", "status": "Invited" }
}user is null until approval; the panel shows "User is invited. Open user" from it. Approve returns the updated request, so the screen needs no second call. Reject body: { "note": "No referral and a vague reason." } (optional; never emailed). Bulk bodies: { "ids": ["…"], "note": "…" } and the response is { "succeeded": ["…"], "failed": [{ "id": "…", "code": "Conflict" }] }, because one stale row must not undo the others.
Errors: NotFound, Conflict (already approved; reject on a non-pending request). Approve sends the activation email, and a Reject sends the polite no-reason email (FR-REQ-5). Both are written to the audit log in the same transaction.
3.4 Users
Requirements: FR-ADM-7 to FR-ADM-12, FR-ADM-16, FR-ADM-17
| Screen element | Endpoint | Requirements | Status |
|---|---|---|---|
| List with search, status tabs, sortable columns | GET /admin/users?search=&status=&sort=&direction=&limit=&cursor= | FR-ADM-8 | M3 |
| Detail panel | the list item | FR-ADM-8 | — |
| + New user | POST /admin/users | FR-ADM-7, FR-ADM-7a | M3 |
| Edit name or email | PATCH /admin/users/{id} | FR-ADM-12 | M3 |
| Resend activation link | POST /admin/users/{id}/activation-link | FR-AUTH-3, FR-ADM-10 | M3 |
| Send password-reset email | POST /admin/users/{id}/password-reset | FR-ADM-10 | M3 |
| Deactivate / Reactivate | POST /admin/users/{id}/deactivate, /reactivate | FR-ADM-9, FR-AUTH-9, BR-11, BR-13 | M3 |
| Make / remove Super Admin | PUT /admin/users/{id}/super-admin | FR-ADM-16, BR-13 | M3 |
| Delete permanently (type the email) | DELETE /admin/users/{id} | FR-ADM-11, FR-AUTH-8, BR-11 | M3 |
List item. The panel's rows (Created, Last login, Activation link, Email change) come straight from these fields; there is nothing about spaces or money (FR-ADM-17):
json
{
"id": "…", "fullName": "Maria Santos", "email": "maria@example.ph",
"status": "Invited", "isSuperAdmin": false,
"createdAt": "…", "lastLoginAt": null,
"activationLink": { "sentAt": "…", "expiresAt": "…", "state": "Sent" },
"pendingEmail": null
}activationLink.state is Sent, Expired, or Bounced ("Invitation email bounced" in the list); it is null for an Active user. status is Invited, Active, or Deactivated. Sort keys: name, createdAt, lastLoginAt.
Bodies:
json
// POST /admin/users → 201 the user, or 200 the approved request when the email had a pending one (FR-ADM-7a)
{ "fullName": "Lina Chen", "email": "lina@example.tw" }
// PATCH /admin/users/{id} (an omitted field is unchanged)
{ "fullName": "Lina C.", "email": "lina@example.com" }
// PUT /admin/users/{id}/super-admin
{ "isSuperAdmin": true }
// DELETE /admin/users/{id}
{ "confirmEmail": "budi@example.com" }DELETE takes a body because the typed confirmation is checked on the server too (the portal's check is only a convenience). The PATCH email rules follow §8.6 of the Technical Design: an Invited user's email changes at once and a new link is sent; an Active user's change waits for the new address to confirm, so the response has pendingEmail set and email unchanged.
Errors: EmailTaken (BR-9, the portal shows "Already a user: Maria · Invited" with an Open link, so this error carries existingUserId), NotFound, LastSuperAdmin (BR-13), CannotChangeSelf (the prototype's "This is you" note: no self deactivate, delete, or role change), ConfirmationMismatch, Conflict (wrong status for the action).
One more endpoint is public: POST /auth/email-change/confirm with { "token": "…" } (the link sent to the new address) · M3 · FR-ADM-12 · UJ-14. It needs a small page on the public site that the prototype does not draw yet.
3.5 Audit log
Requirements: FR-ADM-14, NFR-12
GET /admin/audit-log?type=&range=&limit=&cursor= · Admin · M3
| Query | Values | Screen |
|---|---|---|
type | Requests, Users, Roles, Deletions, SignIns | the type chips (All = omit) |
range | Today, Last7Days, Last30Days (All = omit) | the range control |
json
{
"items": [
{
"id": "…", "createdAt": "2026-09-23T16:40:00Z",
"actor": { "id": "…", "name": "Reizkian Yesaya" },
"type": "Users", "action": "UserPasswordResetSent",
"target": { "name": "Ayu Lestari", "email": "ayu@example.com" }
}
],
"nextCursor": null
}actor is null for the setup script ("Setup script" in the prototype). action is a stable code (RequestApprove, UserDeactivate, UserSuperAdminGrant, AdminTwoFactorReset); the portal turns it into the sentence it shows. target carries the name and email as they were when the action happened, because the user may be deleted later (the table keeps no foreign key for this reason). Read only: there is no update or delete (NFR-12).
3.6 Settings (appearance)
The portal's Settings page says its mode and color are "the same as your Crest app. Changing them here changes them there too." That is the signed-in user's theme, so it uses GET and PATCH /me (§5.2); there is no admin endpoint. The color theme (the accent) is not in the data model today, see §7.
4. Auth shared by the portal and the app
| Endpoint | Body | Response | Used by | Requirements | Journeys | Status |
|---|---|---|---|---|---|---|
POST /auth/login | email, password, client (Mobile or Admin) | accessToken, expiresIn, twoFactor; refresh cookie | App, portal | FR-AUTH-4, FR-AUTH-5, BR-20 | UJ-04, UJ-52 | M3 |
POST /auth/refresh | none (cookie); native apps send refreshToken | new accessToken, rotated cookie | App, portal | FR-AUTH-9 | UJ-04, UJ-07 | M3 |
POST /auth/logout | none | 204, revokes this session | App, portal | — | UJ-52 | M3 |
PUT /auth/pin | pin (4–6 digits) | 204 | App | FR-AUTH-6 | UJ-04, UJ-16, UJ-37 | M3 |
DELETE /auth/pin | none | 204 | App | FR-AUTH-6 | UJ-37 | M3 |
POST /auth/pin/verify | pin | 204, or InvalidPin and the attempts left | App | FR-AUTH-6, §8.4 | UJ-04 | M3 |
/auth/pin/verify is needed because the prototype says a wrong PIN on a connected device counts toward 5 attempts and then logs out; the device also checks its own slow hash offline, so this call happens only when online. A forced logout (deactivated, deleted, revoked session) shows up as 401 Unauthenticated on any call, and on sync/pull in particular (BR-50).
5. The Crest app (Apps/Mobile, prototype CrestPrototypeV3.dc.html)
The app is local-first. In local mode it calls no endpoint at all (FR-MOD-2, BR-46): the Welcome, Onboarding, Home, Activity, Stats, Budgets, Financial accounts, Categories, Recurring, Goals, Exchange rates, Backup, Export, and Erase screens read and write the database on the device. Their rules live in the app and in Packages/MoneyTestVectors. The API enters in connected mode, in the three groups below.
5.1 Before connecting: the Connect, Request access, and Forgot password screens
| Screen | Endpoint | Requirements | Journeys | Status |
|---|---|---|---|---|
| Connect to Crest › Log in | POST /auth/login (client: "mobile") | FR-MOD-9, FR-AUTH-4 | UJ-52, UJ-04 | M3 |
| Request access (inside the app) | POST /access-requests (§2) | FR-REQ-1a | UJ-01 | Built |
| Forgot password? | opens the public site's reset page | FR-AUTH-7 | UJ-05 | — |
| Version check at start | GET /app-config (and the static app-config.json in local mode) | FR-APP-5 | UJ-39 | Built |
The prototype's Unlock and Forgot PIN screens are device-only in local mode; in connected mode POST /auth/pin/verify (§4) applies.
5.2 Profile and settings (More › settings)
| Endpoint | Body or response | Requirements | Journeys | Status |
|---|---|---|---|---|
GET /me | id, fullName, email, baseCurrency, language, timeZone, theme, dailyReminderTime, isSuperAdmin, hasPin, status | FR-SET-1 to 3 | UJ-37 | M3 |
PATCH /me | any of fullName, baseCurrency, language, timeZone, theme, dailyReminderTime | FR-SET-1 to 3, FR-NOT-1 | UJ-16, UJ-37 | M3 |
DELETE /me | { "confirm": "DELETE" } (the prototype asks the person to type DELETE) | FR-AUTH-8, BR-11, BR-34 | UJ-08 | M3 |
GET /me/exchange-rates | { items: [{ currency, rateToBase, updatedAt }] } (not paged: one row per currency) | FR-FIN-6, FR-FIN-7, BR-15 | UJ-35 | M4 |
PUT /me/exchange-rates/{currency} | { "rateToBase": "15850.5" } | FR-FIN-6, FR-TRX-2d | UJ-22, UJ-35 | M4 |
PATCH /me with a new baseCurrency also changes the Personal space's currency (a database trigger does this). The client shows the FR-SET-4 warning first; the API does not block it (BR-23).
5.3 Sync and first upload
Requirements: FR-MOD-9 to FR-MOD-12, BR-21, BR-47, BR-48
| Step on screen | Endpoint | Status |
|---|---|---|
| After login: "Upload this device's data" or "Start fresh" | GET /me/sync-state | M4 |
| Upload | POST /sync/import (chunks) | M4 |
| Every day use | POST /sync/push, then GET /sync/pull | M4 |
| The "Waiting to sync" label on a row | local outbox, no endpoint | — |
json
// GET /me/sync-state
{ "personalSpaceId": "…", "personalSpaceEmpty": true }
// POST /sync/import (one chunk; each chunk is one transaction)
{
"spaceId": "…",
"settings": { "baseCurrency": "IDR", "language": "id", "timeZone": "Asia/Jakarta", "budgetStartDay": 25 },
"rows": {
"categories": [], "financialAccounts": [], "transactions": [], "budgets": [], "savingsGoals": []
}
}
// 200
{ "accepted": 120, "rejected": [ { "table": "Transactions", "id": "…", "code": "CurrencyMismatch" } ] }
// 409 Conflict when the Personal space is no longer emptyjson
// POST /sync/push
{ "changes": [
{ "table": "Transactions", "op": "Upsert", "id": "…", "baseUpdatedAt": null,
"row": { "spaceId": "…", "financialAccountId": "…", "kind": "Expense", "amount": -45000,
"categoryId": "…", "note": "Lunch", "createdAt": "2026-09-29T05:12:00Z" } },
{ "table": "Transactions", "op": "Delete", "id": "…" } ] }
// 200, one result per change, in order
{ "results": [ { "id": "…", "status": "Applied" }, { "id": "…", "status": "Rejected", "code": "CategoryWrongSpace" } ] }
// GET /sync/pull?cursor=…&limit=500
{ "spaces": [ { "spaceId": "…", "changes": [ { "table": "Transactions", "id": "…", "deleted": false, "row": { } } ] } ],
"nextCursor": "…", "hasMore": false }table is one of Spaces, SpaceMembers, FinancialAccounts, Categories, Transactions, Transfers, Budgets, SavingsGoals, GoalContributions, Recurrences. In row, related records use ids (not summaries): the device keeps its own copy and builds the screen from it, so the "summaries, never ids" rule in the API standard is for REST lists, not for sync rows. Rejection codes: AmountSignInvalid, CategoryWrongSpace (BR-31), CurrencyMismatch and CurrencyLocked (BR-1, BR-26), ForbiddenRow (BR-36), NotAMember.
5.4 Spaces, members, and invitations
Milestone: M5
Requirements: FR-SPC-1 to FR-SPC-17
These cover the prototype's Switch space list, + New shared space, the invitation banner on Home ("Ayu invited you to Bali trip"), Members, and Space settings. Data inside a space (accounts, transactions, budgets) arrives by sync; these REST endpoints are for the actions that need an immediate server answer.
| Screen element | Endpoint | Auth | Requirements | Journeys |
|---|---|---|---|---|
| Space switcher, with balances computed on the device | GET /spaces | User | FR-SPC-11 | UJ-30, UJ-46 |
| Create space (name, currency, budget start day, invite emails) | POST /spaces | User | FR-SPC-2, FR-SPC-3, FR-SPC-12 | UJ-42 |
| Rename, currency, budget start day, time zone | PATCH /spaces/{id} | Owner | FR-SPC-12, FR-SET-3, FR-SET-4 | UJ-42 |
| Delete space (type the name) | DELETE /spaces/{id} | Owner | FR-SPC-10 | UJ-49 |
| Members list | GET /spaces/{id}/members | Member | FR-SPC-6 | UJ-43 |
| Change role | PATCH /spaces/{id}/members/{userId} { "role": "Owner" } | Owner | FR-SPC-6 | UJ-49 |
| My "Include in my totals" and "Notify me" | PATCH /spaces/{id}/members/me { "includeInTotals": true, "notifyNewTransaction": false } | Member | FR-SPC-13, FR-SPC-16 | UJ-31, UJ-44 |
Remove a member or leave (userId or me) | DELETE /spaces/{id}/members/{userId} | Owner, or the member themselves | FR-SPC-8, FR-SPC-9 | UJ-48 |
| Invite by email | POST /spaces/{id}/invitations { "email": "…", "role": "Member" } | Owner | FR-SPC-4 | UJ-43 |
| Pending invitations, cancel | GET /spaces/{id}/invitations, DELETE /invitations/{id} | Owner | FR-SPC-5 | UJ-43 |
| My invitations, the banner on Home | GET /me/invitations | User | FR-SPC-4, FR-NOT-4 | UJ-43 |
| Join / Not now | POST /invitations/{id}/accept, /decline | User (the invitee) | FR-SPC-4, BR-39 | UJ-43 |
Invite response is always 202 with no sign of whether the email has a login (FR-SPC-4). GET /me/invitations items carry space: { id, name } and invitedBy: { id, name }, which is all the banner shows. Cross-space transfers (FR-SPC-14) are POST /transfers, PATCH /transfers/{id}, DELETE /transfers/{id} and write both legs in one database function, so they cannot go through sync one leg at a time:
json
// POST /transfers
{ "id": "…", "from": { "financialAccountId": "…", "amount": 500000 },
"to": { "financialAccountId": "…" },
"rate": null, "toAmount": null,
"fee": { "amount": 2500 }, "note": "Top up the kitchen", "createdAt": "…" }For a cross-currency transfer send rate or toAmount (either; the server computes the other, FR-TRX-2b). Errors: NotAMember (404 for a space the caller cannot see), LastOwner (FR-SPC-9), InvitationExpired, AlreadyMember.
5.5 REST lists the app may also call
Technical Design §9.1 lists REST CRUD for financial accounts, transactions, categories, budgets, and goals next to sync. With the app local-first, sync carries all of them and the REST endpoints are not needed for any screen in the prototype. Build them only when a client other than the app needs them. This keeps a long list of endpoints out of M4 and M5. The one list that stays REST is the shared-space "Added by" feed, if a later phase wants it without a local copy.
5.6 Notifications and the rest
| Screen | Endpoint | Status |
|---|---|---|
| Notification list (budget alerts, invitations, removed from a space) | GET /notifications, POST /notifications/{id}/read | P2 (FR-NOT-3) |
| Recurring entries made on the due date | worker job, no endpoint (FR-TRX-5b) | P2 |
| Export CSV, Backup file, Erase this device | on the device, no endpoint | — |
6. Endpoint index
| Count | Group | Status |
|---|---|---|
| 7 | Health, app-config, POST /access-requests, GET /admin/access-requests, activate, forgot, reset | Built |
| 3 | Auth: login, refresh, logout | M3 |
| 6 | Auth: PIN (3), TOTP (3) | M3 |
| 1 | Auth: email-change confirm | M3 |
| 1 | GET /admin/overview | M3 |
| 5 | Admin access requests: approve, reject, bulk approve, bulk reject, plus counts (§7) | M3 |
| 9 | Admin users: list, create, edit, resend link, password reset, deactivate, reactivate, super-admin, delete | M3 |
| 1 | GET /admin/audit-log | M3 |
| 5 | Me: get, patch, delete, exchange rates (2) | M3, M4 |
| 4 | Sync: sync-state, import, push, pull | M4 |
| 12 | Spaces, members, invitations | M5 |
| 3 | Transfers | M5 |
7. Where the prototypes and the documents disagree
Each of these needs a decision before the endpoint is built. None is a mistake in the endpoint list; they are screens or fields the written requirements do not cover.
| # | Prototype shows | Documents say | Suggested decision |
|---|---|---|---|
| 1 | The request form has "How do you know me?" (required) and an "About yourself" (required, at least 10 characters) | FR-REQ-1: name, email, country, and an optional message. The table access_request has no know column | Add know (text, required) and keep message; or fold both into message. Then the DTO, the migration, the admin detail panel, and FR-REQ-1 all change together |
| 2 | The admin Requests tabs show a count per tab | The built list returns no counts | Return counts: { pending, approved, rejected } from GET /admin/access-requests, or reuse the Overview call |
| 3 | The admin list distinguishes an activation link that bounced | No column records an email bounce | Add activation_email_failed_at on the user, set by the Brevo bounce webhook (§11.3), or drop the "bounced" state from the prototype |
| 4 | The admin Settings has a color theme (accent) | app_user.theme holds light, dark, or system only | Keep the accent on the device (localStorage), as the prototype already does; no endpoint |
| 5 | "Part of this is held for something else": money inside a financial account that belongs to someone else, shown as "Held for others" and kept out of net worth; a "Bali trip" shared space whose fund sits in Ayu's BCA | Not in the BRD, the journeys, or the data model. FR-SPC-17 says money in a private financial account is shared by creating a new financial account in the shared space | Decide whether this is a new requirement (needs a table or a flag, and a journey) or a prototype idea to remove. No endpoint is planned for it |
| 6 | Financial account type Other (investments, pay later) | FR-FIN-1: cash, bank, e-wallet, credit card only | Add the type to the requirement and the type enum, or map "Other" onto one of the four |
| 7 | Subcategories in onboarding, Categories, and Stats | FR-CAT-3 is "Could"; the table has parent_id already | Fine for sync; set the milestone for FR-CAT-3 |
| 8 | Savings goals "Already saved" (a starting amount) with no linked financial account | FR-BUD-4 to 6: linked account, or manual contributions | The "already saved" amount is one goal_contribution row created with the goal |
| 9 | The portal's Approve selected / Reject selected | FR-ADM-18 is "Could" | Built into the endpoint list above because the screen is drawn |
When one of these is decided, change the requirement first, then this document, then the code (BRD wins over everything else).