STR Forms is a multi-entity currency exchange ERP system built for local deployment (single Hetzner VPS). It handles transaction processing (5 types: exchange, expense, deposit, withdrawal, transfer), debt tracking, and financial reporting across multiple legal entities within a single organization. The system serves 4 user roles (admin, accountant, cashier, viewer) with role-based access control and entity-level data isolation.
Primary use case: Cashiers process currency exchanges at exchange bureaus. Accountants track expenses and generate P&L reports. Admins manage users, entities, wallets, and periods. Viewers (typically business owners) have read-only access to all reports.
Scale expectations: ~$1M daily transaction volume across 3 entities, 5-10 concurrent users. Single-tenant, single-instance deployment.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Client (React SPA) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ React 18.3 │ │ React Query │ │ React Router│ │ i18next (EN/RU/ES) │ │
│ │ TypeScript │ │ 5.60 │ │ 6.28 │ │ Catppuccin Theme │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │ │ │ │
│ └─────────────────┴─────────────────┘ │
│ │ │
│ Vite 7.x (dev) │
│ Vite build (prod) │
└────────────────────────────────────┬────────────────────────────────────────┘
│ HTTP + Cookies
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Backend (Hono API Server) │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Hono 4.x + Node.js Runtime │ │
│ │ │ │
│ │ Middleware Chain (top to bottom): │ │
│ │ ┌─────────────┬─────────────┬─────────────┬─────────────┬────────────┐│ │
│ │ │ requestLogger│ authMiddleware │ entityMiddleware │ zValidator │ Handler ││ │
│ │ └─────────────┴─────────────┴─────────────┴─────────────┴────────────┘│ │
│ │ │ │
│ │ Routes: /auth, /transactions, /debt, /reports, /admin, /logs │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ PostgreSQL │ │ Typst │ │ Audit/Logs DB │ │
│ │ 16.x │ │ (PDF) │ │ (PostgreSQL) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
- Client sends request with
session_idcookie andX-Entity-Idheader - requestLogger generates requestId, logs request start
- authMiddleware validates session, attaches user to context
- entityMiddleware validates user's entity access, attaches entity
- zValidator parses and validates request against Zod schema
- Handler calls service layer, returns response
- Error handler catches exceptions, formats error response
- requestLogger logs response with duration
| Component | Technology | Version | Purpose |
|---|---|---|---|
| Runtime | Node.js | 22.x | JavaScript runtime |
| Framework | Hono | 4.6.x | REST API framework, middleware composition |
| Validation | Zod | 3.23.x | Schema validation, type inference |
| Validator | @hono/zod-validator | 0.4.x | Hono integration for Zod |
| Database | PostgreSQL | 16.x | Relational data store |
| Driver | pg | 8.13.x | Native PostgreSQL driver |
| Auth | bcryptjs | 3.0.x | Password hashing |
| Typst | CLI | Server-side PDF generation | |
| Logging | Custom | - | JSON structured logging |
| Component | Technology | Version | Purpose |
|---|---|---|---|
| Framework | React | 18.3.x | UI component library |
| State | TanStack Query | 5.60.x | Server state, caching, mutations |
| Routing | React Router | 6.28.x | SPA routing |
| i18n | i18next | 25.7.x | Internationalization |
| i18n React | react-i18next | 16.4.x | React bindings for i18next |
| Lang Detect | i18next-browser-languagedetector | 8.2.x | Auto-detect browser language |
| Build | Vite | 7.2.x | Dev server, production build |
| Component | Technology | Version | Purpose |
|---|---|---|---|
| Language | TypeScript | 5.6.x | Type safety, IDE support |
| Runner | tsx | 4.19.x | Fast TS execution (no compile) |
| Lint | ESLint | 9.x | Code quality |
| Test | Vitest | 4.0.x | Unit/integration tests |
| Nix | Flake | - | Reproducible dev environment |
Key tables:
users- User accounts with rolessessions- Cookie sessions (never expire)legal_entities- Companies (FNK, STR, REV)user_entity_access- User ↔ Entity many-to-manycurrencies- EUR, USD, USDT, etc.wallets- Cash drawers, bank accounts, crypto walletstransactions- All 5 transaction types with amounts, rates, profitdebt_payments- Partial payments for open transactionsexpense_categories- OPEX categories with i18n keysaccounting_periods- Monthly periods for period closingaudit_logs- User action history (immutable)app_logs- Application debug logs (admin viewable)daily_balance_snapshots- Daily opening/closing balances (Check column)
Design patterns:
- UUID primary keys with
gen_random_uuid() - Timestamps with
created_atandupdated_atcolumns - Soft deletes via
is_activeflag - Optimistic locking with
versioncolumn - Foreign keys with
legal_entity_idfor multi-entity isolation - Enums for fixed value sets (roles, transaction types, etc.)
The system serves multiple legal entities within one installation. Each entity has:
- Own wallets and transactions
- Own accounting periods
- Own expense categories
Implementation: Shared tables with legal_entity_id foreign key. Middleware enforces entity context on every request.
// All entity-scoped queries must include entityId
async function getTransactions(entityId: string, filters: Filters) {
return db
.select()
.from(transactions)
.where(eq(transactions.legalEntityId, entityId))
// ...
}Why this approach:
- Single database schema = simple queries
- Cross-entity P&L reports via GROUP BY entity
- Easy to add new entities (no schema changes)
- Indexes on
legal_entity_idensure performance
User access: Users have many-to-many relationship with entities. Admin has implicit access to all entities. Other roles require explicit access grants.
Four roles with hierarchical permissions:
| Role | Key Capabilities |
|---|---|
| admin | Full CRUD everything, user management, delete transactions |
| accountant | Transaction CRUD, all reports, close/reopen periods, audit logs |
| cashier | Create/edit own transactions, basic reports, debt payments |
| viewer | Read-only access to all reports only |
Implementation: Role capabilities defined as const object, checked via middleware.
const ROLE_CAPABILITIES = {
admin: { 'transactions.delete': true, 'periods.close': true, ... },
accountant: { 'transactions.delete': false, 'periods.close': true, ... },
cashier: { 'transactions.delete': false, 'periods.close': false, ... },
viewer: { 'transactions.delete': false, 'periods.close': false, ... },
};Cashier restriction: Cashiers can only edit their own transactions. Enforced in service layer.
if (user.role === 'cashier' && transaction.employeeId !== user.id) {
throw new ForbiddenError('Cashiers can only edit their own transactions');
}Two mechanisms working together:
A. Optimistic Locking for Transaction Edits
All mutable tables have a version column. When updating, the application checks the version and increments it atomically. If no rows are affected, concurrent modification occurred and the frontend must refresh and retry.
Frontend handles 409 Conflict by refreshing data and notifying user.
B. Database Transactions for Balance Operations
All balance-affecting operations wrapped in ACID transaction:
async function createExchangeTransaction(data, context) {
return db.transaction(async (tx) => {
// 1. Create transaction record
const transaction = await tx.insert(transactions).values({...}).returning();
// 2. Update source wallet (deduct)
await tx.update(wallets)
.set({ balance: sql`balance - ${data.amountOut}` })
.where(eq(wallets.id, data.sourceWalletId));
// 3. Validate balance (prevent negative)
if (balanceIsNegative) throw new InsufficientBalanceError(...);
// 4. Update destination wallet (add)
await tx.update(wallets)
.set({ balance: sql`balance + ${data.amountIn}` })
.where(eq(wallets.id, data.destWalletId));
// 5. Create audit log
await tx.insert(auditLogs).values({...});
});
}Period Locking: Closed periods block all transaction modifications. Checked in service layer before any operation.
Key insight: Rates vary per transaction. Cashiers set final rate based on oracle reference + spread.
Data model:
amount_in,currency_in- Amount received from clientamount_out,currency_out- Amount given to clientexchange_rate- Applied rate (calculated or cashier-entered)oracle_rate- Reference rate from external oracle (optional)profit_amount,profit_currency- Calculated profit
Flow:
- Cashier selects currency pair (EUR → USD)
- System displays oracle reference rate (if available)
- Cashier enters amounts (1000 EUR in, 1085 USD out)
- System calculates effective rate (1.085)
- Cashier can override rate directly
- Transaction saved with all rates for audit
- Profit calculated and stored
Monthly periods that can be closed (no modifications allowed) or open.
Why: Financial compliance - once a period is closed, transactions cannot be modified.
Roles:
- Admin: Close, reopen, delete periods
- Accountant: Close, reopen periods
- Cashier/Viewer: View only
Implementation: Period check before every transaction operation:
async function checkPeriodOpen(date: Date, entityId: string) {
const period = await db.query.accountingPeriods.findFirst({
where: and(
eq(periods.entityId, entityId),
lte(periods.startDate, date),
gte(periods.endDate, date),
),
});
if (period?.status === 'closed') {
throw new PeriodClosedError(`Cannot modify transactions in closed period: ${period.name}`);
}
}Open transactions can have partial payments recorded against them.
Flow:
- Transaction created with
status: 'open' - Cashier records partial payments via
POST /api/debt/payments - System tracks payments against original transaction
- Close debt via
POST /api/debt/:transactionId/close
Expected payment date: Optional date for aging reports.
Cookie-based sessions with server-side storage:
- Session ID stored in HttpOnly cookie
- Sessions table in PostgreSQL (never expire per business req)
- Session lookup on every request
- Immediate revocation possible (DELETE from sessions table)
// Session table
interface Session {
id: string; // UUID, in cookie
userId: string; // FK to users
createdAt: Date;
lastAccessedAt: Date; // Updated each request
userAgent: string; // Audit
ipAddress: string; // Audit
}Logout: DELETE session from DB + clear cookie.
All requests:
- Authentication:
session_idcookie - Entity context:
X-Entity-Idheader
Error response:
{
"error": "Human-readable message",
"code": "MACHINE_CODE",
"fields": { "fieldName": "error" } // validation only
}Schemas defined in src/shared/schemas/ are used by:
- Server:
@hono/zod-validatorfor request validation - Future client: TypeScript types, form validation
// src/shared/schemas/transaction.ts
export const createTransactionSchema = z.discriminatedUnion('type', [
exchangeSchema,
expenseSchema,
depositSchema,
withdrawalSchema,
transferSchema,
]);
// src/server/routes/transactions.ts
transactions.post('/', zValidator('json', createTransactionSchema), async (c) => {
const data = c.req.valid('json'); // Typed as CreateTransactionInput
// ...
});Custom error classes for domain errors:
export class AppError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 400,
public fields?: Record<string, string>
) { super(message); }
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super('NOT_FOUND', `${resource} not found`, 404);
}
}
export class PeriodClosedError extends AppError {
constructor(periodName: string) {
super('PERIOD_CLOSED', `Cannot modify in closed period: ${periodName}`, 400);
}
}Hono's functional middleware composes cleanly:
app.use('*', requestLogger); // 1. Log request
app.use('/api/*', authMiddleware); // 2. Authenticate
app.use('/api/transactions/*', entityMiddleware); // 3. Entity context
transactions.post('/',
zValidator('json', createTransactionSchema), // 4. Validate
canCreateTransactions, // 5. Role check
async (c) => { /* handler */ }
);Server State: TanStack Query (React Query)
- Caching, background refetching, invalidation
- Optimistic updates for mutations
- Query keys include entityId and all filter params
Client State: React Context
AuthContext: User, login, logoutEntityContext: Selected entityThemeContext: Light/dark mode
// Good pattern: Custom hooks encapsulate query logic
export function useTransactions(filters: TransactionFilters) {
return useQuery({
queryKey: ['transactions', filters.entityId, filters],
queryFn: () => api.transactions.list(filters),
enabled: !!filters.entityId,
});
}Three languages: English (default), Russian, Spanish.
// Namespace organization
locales/
en/
common.json // Shared strings
transactions.json
reports.json
admin.json
categories.json
ru/
...
es/
...Usage:
const { t } = useTranslation('transactions');
<h1>{t('title')}</h1>
<button>{t('common:save')}</button> // Cross-namespacePDF translations: Duplicated in src/server/lib/pdf.ts since server doesn't bundle i18next.
Light/dark mode using Catppuccin color palette.
:root {
--color-bg: #f5f5f5; /* Latte base */
--color-text: #4c4f69;
--color-primary: #1e66f5;
}
[data-theme="dark"] {
--color-bg: #1e1e2e; /* Mocha base */
--color-text: #cdd6f4;
--color-primary: #89b4fa;
}| Report | Access | Description |
|---|---|---|
| Cash Flow | viewer+ | All transactions, summary/daily views |
| Balances | viewer+ | Current wallet balances + daily snapshots |
| Cashier | viewer+ | Employee transaction summary (own for cashier) |
| Debt | viewer+ | Open transactions with aging |
| Profit | accountant+ | Exchange profit by currency pair |
| Expenses | accountant+ | Expenses by category |
| P&L | accountant+ | Profit & Loss statement |
Server-side PDF generation using Typst:
Request → API Route → Format Data → Generate Typst → Compile PDF → Response
↓
typst compile (CLI)
↓
Binary PDF Buffer
Fonts: IBM Plex Sans (body), 3270 Nerd Font (monospace numbers)
Format: Landscape A4, multi-language support, consistent headers/footers
-
Application Logs - stdout/file, JSON structured
- debug, info, warn, error levels
- Child loggers for modules (auth, db, transactions)
-
Audit Logs - Database, immutable
- User action history (who did what when)
- Required for financial compliance
- Actions: login, transaction.create/update, period.close, etc.
-
App Logs - Database, admin-viewable
- Application debugging
- Full-text search, filtering by level/context/date
- Admin UI for viewing
Every request logged with:
- requestId (UUID)
- method, path, query params
- userId, entityId
- status, duration
Admin-only endpoint returns:
- System metrics (uptime, memory, requests/min)
- Database health
- Version info
Transactions older than 90 days are hidden by default:
includeArchivedquery param to see all- Partial index for recent transactions optimization
- Not a delete - all data preserved
The 90-day window uses a PostgreSQL partial index to optimize queries for recent data only.
Development Environment: Uses Nix Flakes for reproducible development:
nix developenters devshell with all tools- Auto-decrypts SOPS secrets
- Sets environment variables
- Includes Typst, PostgreSQL client, Node.js, etc.
Production Deployment: Currently uses NixOS configuration on Hetzner VPS:
flake.nixdefines server packages and services- Systemd units for application and PostgreSQL
- Traefik for reverse proxy with automatic HTTPS
- SOPS + age for secrets management
The current NixOS deployment is NOT production-ready and needs to be converted to standard industry tooling:
| Aspect | Current (NixOS) | Target (Terraform + Ansible) |
|---|---|---|
| Infrastructure | NixOS configuration | Terraform Hetzner provider |
| Provisioning | Nix expressions | Ansible playbooks |
| Secrets | SOPS + age (devshell) | SOPS + age + Ansible vault |
| Reproducibility | Nix guarantees | Terraform state + Ansible idempotency |
| Team familiarity | Low (niche) | High (standard tools) |
| Documentation | Sparse | Extensive |
Why migration is needed:
-
Team Operability: Few engineers know NixOS well. Terraform + Ansible are standard skills.
-
Documentation Gap: NixOS deployment lacks clear documentation (see
deploy/README.md). -
Deployment Script Issues:
deploy/deploy.shhas issues and doesn't follow best practices. -
No Terraform State: Infrastructure not tracked in version control or Terraform state.
-
Secrets Handling: SOPS works but needs Ansible integration for secret distribution.
Migration Scope:
Phase 1: Terraform Infrastructure
├── Hetzner VPS (compute)
├── Firewall rules
├── volumes (data)
└── SSH key management
Phase 2: Ansible Configuration
├── User setup + SSH
├── PostgreSQL installation
├── Docker runtime
├── Traefik container
├── Application deployment
└── Secrets distribution (SOPS)
Phase 3: Pipeline Integration
├── CI/CD for Terraform
├── CI/CD for Ansible
└── Secrets rotation flow
Key Files Involved:
flake.nix→terraform/(main.tf, variables.tf, etc.)deploy/→ansible/(playbooks, roles, inventory)secrets/deploy.yaml→ integrate with Ansible vaultdeploy/deploy.sh→ replace with Terraform + Ansible workflows
- Container: Docker, multi-stage build
- Reverse Proxy: Traefik (handles HTTPS, routing)
- Server: Hetzner VPS (local deployment)
- Secrets: SOPS + age encryption
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
DATABASE_URL=postgresql://...
SESSION_SECRET=...
npm run dev # Concurrent client + server
npm run db:setup # Migrate + seed admin
./scripts/dev-run.sh # Full stack with Docker PostgreSQL- Version bump: Every code change bumps patch version in
package.json - No console.log: Use
loggerfromsrc/server/lib/logger.ts - Import extensions: Use
.jssuffix (ESM + NodeNext) - TypeScript strict: No
anyunless absolutely necessary - Zod validation: All routes must validate input
- AppError: All errors throw AppError subclasses
src/
├── server/ # Hono API
│ ├── routes/ # Route handlers by domain
│ ├── middleware/ # Auth, entity, error, validation
│ ├── services/ # Business logic
│ └── lib/ # DB client, logger, PDF
├── client/ # React SPA
│ ├── pages/ # Route components
│ ├── components/ # Reusable UI
│ ├── contexts/ # React Context providers
│ └── locales/ # i18n translations
├── shared/ # Shared code
│ └── schemas/ # Zod schemas
└── db/ # Database
└── migrations/ # SQL migrations
Always use strings for currency amounts, never numbers:
// ✅ Correct
amount: z.string().regex(/^\d+(\.\d{1,8})?$/)
// Avoid floating point precision issues
// ❌ Wrong
amount: z.number()
// 1000.00 + 0.01 may equal 1000.00999999Formatting for display:
// 2 decimals for fiat, 6 for crypto
formatAmount('1000.50', 'EUR') // "1,000.50 EUR"
formatAmount('0.123456', 'BTC') // "0.123456 BTC"Use helpers from src/server/lib/db.ts:
// Single row or null
const user = await queryOne<User>('SELECT * FROM users WHERE id = $1', [id]);
// All rows
const users = await queryAll<User>('SELECT * FROM users WHERE role = $1', [role]);
// Transaction wrapper
const newUser = await transaction(async (client) => {
const user = await client.query('INSERT INTO users ... RETURNING *');
await client.query('INSERT INTO audit_logs ...');
return user.rows[0];
});| File | Purpose |
|---|---|
src/server/index.ts |
App entry, middleware registration |
src/server/middleware/auth.ts |
Session validation, role guards |
src/server/middleware/error.ts |
AppError classes, error handler |
src/server/routes/auth.ts |
Login, logout, session management |
src/server/routes/transactions.ts |
Transaction CRUD (5 types) |
src/server/services/transaction.service.ts |
Transaction business logic |
| File | Purpose |
|---|---|
src/client/lib/api.ts |
All API calls, types, error handling |
src/client/contexts/AuthContext.tsx |
User authentication state |
src/client/pages/TransactionsPage.tsx |
Transaction list + filters |
src/client/pages/NewTransactionPage.tsx |
Complex form (5 types) |
| File | Purpose |
|---|---|
src/shared/schemas/transaction.ts |
Transaction schemas (discriminated union) |
src/shared/schemas/common.ts |
Pagination, date range, currency |
| File | Purpose |
|---|---|
src/db/migrations/0001_initial_schema.sql |
Core tables, enums, indexes |
src/db/migrate.ts |
Migration runner |
# Enter nix devshell (auto-decrypts secrets)
nix develop
# Or manual setup
npm install
docker compose -f compose.dev.yml up -d
npm run db:setup
npm run devnpm run typecheck # No type errors
npm run lint # No lint errors
npm run test # Tests pass# Create migration
# Write SQL in src/db/migrations/XXXX_new.sql
# Reset database (destructive!)
npm run db:reset
# Generate test data
npm run populate-db
# View logs
npm run logs:view # or GET /api/logs via API-
Oracle integration: How does the system fetch external exchange rates? Is there an existing API?
-
Report customization: Are there plans for customizable report templates or date presets?
-
Data export: Beyond PDF/CSV, are there requirements for other export formats (Excel, etc.)?
-
User sessions: The "never expire" requirement creates session table growth. Is periodic cleanup desired?
-
Multi-instance: Is horizontal scaling a future consideration? (would need shared session store)
-
Currency precision: Currently 8 decimal places for crypto. Is this sufficient for all use cases?
-
Audit retention: Audit logs grow indefinitely. Archival policy needed?
-
Terraform/Ansible migration: Priority and timeline for migrating from NixOS to standard IaC tooling? Who will own the migration?
- ADRs:
docs/adr/- 19 architecture decision records - API Spec:
docs/API.md- Complete endpoint documentation - Setup:
AGENTS.md- Developer setup and commands - Frontend:
src/client/AGENTS.md- React patterns - Backend:
src/server/AGENTS.md- Hono patterns - Database:
src/db/AGENTS.md- SQL migration patterns - Deployment (needs migration):
deploy/,flake.nix- NixOS-based, needs Terraform/Ansible