Skip to content

Instantly share code, notes, and snippets.

@bogorad
Created January 9, 2026 10:46
Show Gist options
  • Select an option

  • Save bogorad/7cb00ddcb73c2fe2647a8971d8a8800f to your computer and use it in GitHub Desktop.

Select an option

Save bogorad/7cb00ddcb73c2fe2647a8971d8a8800f to your computer and use it in GitHub Desktop.
Developer Introduction to STR Forms

DEV-intro.md - Developer Introduction to STR Forms

Executive Summary

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.


System Architecture Overview

┌─────────────────────────────────────────────────────────────────────────────┐
│                              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)      │   │
│  └─────────────┘          └─────────────┘          └─────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘

Request Flow

  1. Client sends request with session_id cookie and X-Entity-Id header
  2. requestLogger generates requestId, logs request start
  3. authMiddleware validates session, attaches user to context
  4. entityMiddleware validates user's entity access, attaches entity
  5. zValidator parses and validates request against Zod schema
  6. Handler calls service layer, returns response
  7. Error handler catches exceptions, formats error response
  8. requestLogger logs response with duration

Tech Stack Details

Backend

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
PDF Typst CLI Server-side PDF generation
Logging Custom - JSON structured logging

Frontend

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

Dev/Tooling

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

Database Schema (PostgreSQL)

Key tables:

  • users - User accounts with roles
  • sessions - Cookie sessions (never expire)
  • legal_entities - Companies (FNK, STR, REV)
  • user_entity_access - User ↔ Entity many-to-many
  • currencies - EUR, USD, USDT, etc.
  • wallets - Cash drawers, bank accounts, crypto wallets
  • transactions - All 5 transaction types with amounts, rates, profit
  • debt_payments - Partial payments for open transactions
  • expense_categories - OPEX categories with i18n keys
  • accounting_periods - Monthly periods for period closing
  • audit_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_at and updated_at columns
  • Soft deletes via is_active flag
  • Optimistic locking with version column
  • Foreign keys with legal_entity_id for multi-entity isolation
  • Enums for fixed value sets (roles, transaction types, etc.)

Core Architectural Concepts

1. Multi-Entity Architecture (ADR-009)

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_id ensure performance

User access: Users have many-to-many relationship with entities. Admin has implicit access to all entities. Other roles require explicit access grants.

2. Role-Based Access Control (ADR-013)

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');
}

3. Concurrency Control (ADR-012)

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.

4. Exchange Rate Management (ADR-010)

Key insight: Rates vary per transaction. Cashiers set final rate based on oracle reference + spread.

Data model:

  • amount_in, currency_in - Amount received from client
  • amount_out, currency_out - Amount given to client
  • exchange_rate - Applied rate (calculated or cashier-entered)
  • oracle_rate - Reference rate from external oracle (optional)
  • profit_amount, profit_currency - Calculated profit

Flow:

  1. Cashier selects currency pair (EUR → USD)
  2. System displays oracle reference rate (if available)
  3. Cashier enters amounts (1000 EUR in, 1085 USD out)
  4. System calculates effective rate (1.085)
  5. Cashier can override rate directly
  6. Transaction saved with all rates for audit
  7. Profit calculated and stored

5. Accounting Periods

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}`);
  }
}

6. Debt Tracking

Open transactions can have partial payments recorded against them.

Flow:

  1. Transaction created with status: 'open'
  2. Cashier records partial payments via POST /api/debt/payments
  3. System tracks payments against original transaction
  4. Close debt via POST /api/debt/:transactionId/close

Expected payment date: Optional date for aging reports.

7. Authentication (ADR-003)

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.


API Design Patterns

Request/Response Format

All requests:

  • Authentication: session_id cookie
  • Entity context: X-Entity-Id header

Error response:

{
  "error": "Human-readable message",
  "code": "MACHINE_CODE",
  "fields": { "fieldName": "error" }  // validation only
}

Zod Schema Sharing

Schemas defined in src/shared/schemas/ are used by:

  1. Server: @hono/zod-validator for request validation
  2. 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
  // ...
});

AppError Hierarchy

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);
  }
}

Middleware Composition

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 */ }
);

Frontend Architecture

State Management

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, logout
  • EntityContext: Selected entity
  • ThemeContext: 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,
  });
}

i18next Integration

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-namespace

PDF translations: Duplicated in src/server/lib/pdf.ts since server doesn't bundle i18next.

Catppuccin Theme

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;
}

Reporting System

Report Types

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

PDF Export (ADR-017)

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


Logging & Monitoring (ADR-011)

Three Log Types

  1. Application Logs - stdout/file, JSON structured

    • debug, info, warn, error levels
    • Child loggers for modules (auth, db, transactions)
  2. Audit Logs - Database, immutable

    • User action history (who did what when)
    • Required for financial compliance
    • Actions: login, transaction.create/update, period.close, etc.
  3. App Logs - Database, admin-viewable

    • Application debugging
    • Full-text search, filtering by level/context/date
    • Admin UI for viewing

Request Logging

Every request logged with:

  • requestId (UUID)
  • method, path, query params
  • userId, entityId
  • status, duration

Status Dashboard

Admin-only endpoint returns:

  • System metrics (uptime, memory, requests/min)
  • Database health
  • Version info

Data Retention

90-Day Window (ADR-008)

Transactions older than 90 days are hidden by default:

  • includeArchived query 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.


Deployment

Current State: NixOS-Based Infrastructure

Development Environment: Uses Nix Flakes for reproducible development:

  • nix develop enters 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.nix defines server packages and services
  • Systemd units for application and PostgreSQL
  • Traefik for reverse proxy with automatic HTTPS
  • SOPS + age for secrets management

⚠️ Migration Required: NixOS → Terraform/Ansible

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:

  1. Team Operability: Few engineers know NixOS well. Terraform + Ansible are standard skills.

  2. Documentation Gap: NixOS deployment lacks clear documentation (see deploy/README.md).

  3. Deployment Script Issues: deploy/deploy.sh has issues and doesn't follow best practices.

  4. No Terraform State: Infrastructure not tracked in version control or Terraform state.

  5. 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.nixterraform/ (main.tf, variables.tf, etc.)
  • deploy/ansible/ (playbooks, roles, inventory)
  • secrets/deploy.yaml → integrate with Ansible vault
  • deploy/deploy.sh → replace with Terraform + Ansible workflows

Production Stack

  • Container: Docker, multi-stage build
  • Reverse Proxy: Traefik (handles HTTPS, routing)
  • Server: Hetzner VPS (local deployment)
  • Secrets: SOPS + age encryption

Environment Variables

NODE_ENV=production
PORT=3000
LOG_LEVEL=info
DATABASE_URL=postgresql://...
SESSION_SECRET=...

Development

npm run dev           # Concurrent client + server
npm run db:setup      # Migrate + seed admin
./scripts/dev-run.sh  # Full stack with Docker PostgreSQL

Code Conventions

Critical Rules

  1. Version bump: Every code change bumps patch version in package.json
  2. No console.log: Use logger from src/server/lib/logger.ts
  3. Import extensions: Use .js suffix (ESM + NodeNext)
  4. TypeScript strict: No any unless absolutely necessary
  5. Zod validation: All routes must validate input
  6. AppError: All errors throw AppError subclasses

Directory Structure

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

Common Patterns

Currency Amounts

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

Formatting for display:

// 2 decimals for fiat, 6 for crypto
formatAmount('1000.50', 'EUR')   // "1,000.50 EUR"
formatAmount('0.123456', 'BTC')  // "0.123456 BTC"

Database Queries

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];
});

Key Files Reference

Server

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

Client

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)

Shared

File Purpose
src/shared/schemas/transaction.ts Transaction schemas (discriminated union)
src/shared/schemas/common.ts Pagination, date range, currency

Database

File Purpose
src/db/migrations/0001_initial_schema.sql Core tables, enums, indexes
src/db/migrate.ts Migration runner

Development Workflow

Quick Start

# 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 dev

Before PR

npm run typecheck    # No type errors
npm run lint         # No lint errors  
npm run test         # Tests pass

Common Tasks

# 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

Questions for Future Clarification

  1. Oracle integration: How does the system fetch external exchange rates? Is there an existing API?

  2. Report customization: Are there plans for customizable report templates or date presets?

  3. Data export: Beyond PDF/CSV, are there requirements for other export formats (Excel, etc.)?

  4. User sessions: The "never expire" requirement creates session table growth. Is periodic cleanup desired?

  5. Multi-instance: Is horizontal scaling a future consideration? (would need shared session store)

  6. Currency precision: Currently 8 decimal places for crypto. Is this sufficient for all use cases?

  7. Audit retention: Audit logs grow indefinitely. Archival policy needed?

  8. Terraform/Ansible migration: Priority and timeline for migrating from NixOS to standard IaC tooling? Who will own the migration?


References

  • 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment