Budget: Design

Design Document: Budget Application

Overview

This document outlines the design for a serverless budget application that implements event sourcing for transaction management. The system uses AWS Lambda, DynamoDB, and TypeScript to provide a scalable, maintainable financial tracking platform.

The architecture follows a library-based approach where core business logic is implemented as a reusable TypeScript library, consumed directly by HTTP handlers to avoid network overhead. The system supports multiple users with multiple budgets, using event sourcing to maintain transaction history and enable state reconstruction.

Architecture

High-Level Architecture

graph TB
    subgraph "AWS Infrastructure"
        CF[CloudFront] --> APIGW[API Gateway]
        APIGW --> AUTH[Lambda Authorizer]
        APIGW --> HTTP[HTTP Handler Lambda]
        HTTP --> CORE[Core Business Library]
        CORE --> DDB[DynamoDB]
        S3[S3 Static Assets] --> CF
        COG[Cognito User Pool] --> AUTH
    end

    subgraph "Core Library Components"
        CORE --> ENTITIES[Entity Management]
        CORE --> EVENTS[Event Sourcing]
        CORE --> STATE[State Calculation]
        CORE --> PLANS[Plan Execution]
    end

Component Responsibilities

CloudFront + S3: Serves static assets (CSS, JavaScript, favicon) with caching and TLS termination.

API Gateway: Routes HTTP requests and handles CORS. Uses custom Lambda authorizer for authentication.

Lambda Authorizer: Validates JWT tokens from HttpOnly cookies, extracts user ID from Cognito subject claim.

HTTP Handler Lambda: Processes web requests, uses core library for business logic, renders HTML responses using templates.

Core Business Library: Implements all business logic including entity management, event sourcing, state calculation, and plan execution.

DynamoDB: Single table design storing all entities, events, and metadata with appropriate access patterns.

Cognito: Manages user authentication with JWT tokens stored as secure cookies.

Components and Interfaces

Core Library Structure

export interface BudgetCore {
  // Entity operations
  accounts: AccountService;
  pots: PotService;
  categories: CategoryService;
  goals: GoalService;
  plans: PlanService;

  // Transaction operations
  transactions: TransactionService;
  events: EventService;

  // State management
  state: StateService;
  snapshots: SnapshotService;
}

// Service interfaces - all methods require userId and budgetId for authorization
export interface AccountService {
  create(userId: string, budgetId: string, metadata: AccountMetadata): Promise<string>;
  update(userId: string, budgetId: string, accountId: string, metadata: Partial<AccountMetadata>): Promise<void>;
  get(userId: string, budgetId: string, accountId: string): Promise<Account>;
  list(userId: string, budgetId: string): Promise<Account[]>;
  listActive(userId: string, budgetId: string): Promise<Account[]>;
  setActive(userId: string, budgetId: string, accountId: string): Promise<void>;
  setInactive(userId: string, budgetId: string, accountId: string): Promise<void>;
}

export interface PotService {
  create(userId: string, budgetId: string, accountId: string, metadata: PotMetadata): Promise<string>;
  update(userId: string, budgetId: string, potId: string, metadata: Partial<PotMetadata>): Promise<void>;
  get(userId: string, budgetId: string, potId: string): Promise<Pot>;
  list(userId: string, budgetId: string): Promise<Pot[]>;
  listActive(userId: string, budgetId: string): Promise<Pot[]>;
  setActive(userId: string, budgetId: string, potId: string): Promise<void>;
  setInactive(userId: string, budgetId: string, potId: string): Promise<void>;
}

export interface CategoryService {
  create(userId: string, budgetId: string, metadata: CategoryMetadata): Promise<string>;
  update(userId: string, budgetId: string, categoryId: string, metadata: Partial<CategoryMetadata>): Promise<void>;
  get(userId: string, budgetId: string, categoryId: string): Promise<Category>;
  list(userId: string, budgetId: string): Promise<Category[]>;
  listActive(userId: string, budgetId: string): Promise<Category[]>;
  setActive(userId: string, budgetId: string, categoryId: string): Promise<void>;
  setInactive(userId: string, budgetId: string, categoryId: string): Promise<void>;
}

export interface GoalService {
  create(userId: string, budgetId: string, potId: string, metadata: GoalMetadata): Promise<string>;
  update(userId: string, budgetId: string, goalId: string, metadata: Partial<GoalMetadata>): Promise<void>;
  get(userId: string, budgetId: string, goalId: string): Promise<Goal>;
  list(userId: string, budgetId: string): Promise<Goal[]>;
  markAchieved(userId: string, budgetId: string, goalId: string): Promise<void>;
  unmarkAchieved(userId: string, budgetId: string, goalId: string): Promise<void>;
}

export interface PlanService {
  create(userId: string, budgetId: string, metadata: PlanMetadata, schedule: PlanSchedule): Promise<string>;
  update(userId: string, budgetId: string, planId: string, metadata: Partial<PlanMetadata>): Promise<void>;
  get(userId: string, budgetId: string, planId: string): Promise<Plan>;
  list(userId: string, budgetId: string): Promise<Plan[]>;
  setInactive(userId: string, budgetId: string, planId: string): Promise<void>;
  realize(userId: string, budgetId: string, planId: string, upToDate: Date): Promise<string[]>;
  predict(userId: string, budgetId: string, planId: string, fromDate: Date, toDate: Date): Promise<Transaction[]>;
}

export interface TransactionService {
  create(userId: string, budgetId: string, transaction: TransactionData): Promise<string>;
  update(userId: string, budgetId: string, transactionId: string, updates: Partial<TransactionData>): Promise<void>;
  delete(userId: string, budgetId: string, transactionId: string): Promise<void>;
  get(userId: string, budgetId: string, transactionId: string): Promise<Transaction>;
  list(userId: string, budgetId: string, filters: TransactionFilters): Promise<Transaction[]>;
}

export interface EventService {
  list(userId: string, budgetId: string, fromEventId?: string): Promise<Event[]>;
  getLastEventId(userId: string, budgetId: string): Promise<string | null>;
}

export interface StateService {
  calculateBalances(userId: string, budgetId: string, asOfDate?: Date): Promise<BalanceState>;
  getAccountBalance(userId: string, budgetId: string, accountId: string, asOfDate?: Date): Promise<number>;
  getPotBalance(userId: string, budgetId: string, potId: string, asOfDate?: Date): Promise<number>;
}

export interface SnapshotService {
  create(userId: string, budgetId: string, date: Date, state: BalanceState): Promise<void>;
  get(userId: string, budgetId: string, date: Date): Promise<BalanceState | null>;
  invalidate(userId: string, budgetId: string, fromDate: Date): Promise<void>;
}

HTTP Handler Interface

// HTTP handlers use core library directly
export class BudgetHttpHandler {
  constructor(private core: BudgetCore) {}

  async handleAccountsPage(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
  async handleTransactionsPage(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
  async handleCreateTransaction(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
  // ... other handlers
}

Data Models

DynamoDB Single Table Design

The system uses a single DynamoDB table with the following key structure:

interface DynamoDBRecord {
  PK: string;    // Partition Key
  SK: string;    // Sort Key
  GSI1PK?: string; // Global Secondary Index 1 Partition Key
  GSI1SK?: string; // Global Secondary Index 1 Sort Key
  EntityType: string;
  // ... entity-specific attributes
}

Security-First Key Design

All entity keys include the userId to ensure authorization is enforced at the database level:

Security Benefits:

Key Structure Pattern:

Key Patterns

Users and Budgets:

Timeless Entities (userId included for security):

Metadata (Versioned, userId included for security):

Events (userId included for security):

Transactions (Reconstructible, userId included for security):

Snapshots (userId included for security):

Entity Definitions

interface Account {
  id: string;
  budgetId: string;
  metadata: AccountMetadata;
  balance?: number; // Calculated, not stored
  isActive: boolean; // Current status only - does not affect historical processing
}

interface AccountMetadata {
  name: string;
  description?: string;
  notes?: string;
  version: number;
  timestamp: Date;
}

interface Pot {
  id: string;
  accountId: string;
  budgetId: string;
  metadata: PotMetadata;
  balance?: number; // Calculated, not stored
  isActive: boolean; // Current status only - does not affect historical processing
}

interface Category {
  id: string;
  budgetId: string;
  metadata: CategoryMetadata;
  isActive: boolean; // Current status only - does not affect historical processing
}

interface Transaction {
  id: string;
  budgetId: string;
  amount: number; // Always positive
  date: Date;
  changeType: 'income' | 'expense' | 'transfer';
  source?: string; // Account or Pot ID
  destination?: string; // Account or Pot ID
  categoryId?: string;
  metadata: TransactionMetadata;
  planId?: string;
  planVersion?: number;
}

interface Event {
  id: string; // ULID
  budgetId: string;
  timestamp: Date;
  eventType: 'create' | 'update' | 'delete';
  transactionId: string;
  transactionData?: TransactionData; // For create/update events
}

interface Plan {
  id: string;
  budgetId: string;
  metadata: PlanMetadata;
  schedule: PlanSchedule;
  lastRealizedDate?: Date;
  isActive: boolean; // Current status only - does not affect historical processing
}

interface PlanSchedule {
  type: 'one-time' | 'monthly' | 'weekly';
  startDate: Date;
  endDate?: Date;
  dayOfMonth?: number; // For monthly
  dayOfWeek?: number; // For weekly (0 = Sunday)
  weekendAdjustment?: 'friday-before' | 'monday-after' | 'none';
}

interface Goal {
  id: string;
  potId: string;
  budgetId: string;
  metadata: GoalMetadata;
  isAchieved: boolean;
  achievedDate?: Date;
}

Active Status Temporal Behavior

The isActive property on entities has specific temporal semantics that are important for system correctness:

Current State Only: The isActive property represents the current status of an entity and is not versioned or tied to specific dates.

New Operations: Active status affects:

Historical Processing: Active status does NOT affect:

Editing Existing Transactions: When editing transactions that reference inactive entities:

Reversibility: Entities can be reactivated at any time, which immediately:


## Correctness Properties

*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*

Based on the requirements analysis, the following properties must hold for the system to be considered correct:

### Property 1: Entity Creation Consistency
*For any* entity creation request (account, pot, plan, category, goal), the system should generate a unique UUID, store the entity with proper metadata structure, and return the generated UUID to the caller.
**Validates: Requirements 4.1, 4.2, 5.1, 5.2, 6.1, 6.2, 7.1, 7.2, 8.1, 8.2**

### Property 2: User Data Isolation
*For any* user and any budget operation, the system should only allow access to data owned by that user and prevent access to other users' data.
**Validates: Requirements 2.1, 2.3, 2.5, 2.6**

### Property 3: Referential Integrity
*For any* entity creation or reference operation, the system should verify that referenced entities exist and prevent deletion of entities that are referenced by others.
**Validates: Requirements 3.2, 3.9, 3.10, 12.5**

### Property 4: Event Sourcing Consistency
*For any* event stream, processing the events in order should produce identical transaction states, and events should maintain strict chronological ordering with unique ULID identifiers.
**Validates: Requirements 3.3, 3.4, 9.1, 9.6, 10.8, 11.4**

### Property 5: Balance Calculation Accuracy
*For any* account or pot, the calculated balance should equal the sum of all transactions affecting that entity, applied in date order (with creation timestamp as tiebreaker for same dates).
**Validates: Requirements 4.4, 4.5, 5.4, 11.1, 11.2, 11.3**

### Property 6: Pot Balance Constraints
*For any* pot transaction that would result in negative balance, the pot balance should be set to zero and the remaining amount should be deducted from the parent account.
**Validates: Requirements 5.5, 5.6, 5.7**

### Property 7: Transaction Validation
*For any* transaction creation, the system should require positive amounts, valid dates, appropriate source/destination based on transaction type, and reject transactions where source equals destination.
**Validates: Requirements 10.2, 10.3, 10.4, 10.5, 10.6, 10.9, 10.10, 10.11**

### Property 8: Metadata Versioning
*For any* metadata update operation, the system should create a new version with timestamp while maintaining historical versions and using conditional updates based on current version.
**Validates: Requirements 3.8, 4.6, 7.5**

### Property 9: Plan Scheduling Accuracy
*For any* plan with scheduling rules, the system should calculate next execution dates correctly based on plan type (one-time, monthly, weekly) and apply weekend adjustments when specified.
**Validates: Requirements 6.4, 6.5, 6.6, 6.7, 6.8, 6.9, 6.10**

### Property 10: Plan Lifecycle Management
*For any* plan, the system should respect activation status, end dates, and last realized dates when determining whether to generate transactions.
**Validates: Requirements 6.12, 6.13, 6.14, 6.15, 6.16, 6.17**

### Property 11: Goal Achievement Logic
*For any* goal, the system should correctly determine achievement status based on pot balance vs target amount, handle manual achievement marking, and maintain achievement status regardless of subsequent balance changes when manually set.
**Validates: Requirements 8.8, 8.10, 8.11, 8.12**

### Property 12: Snapshot Invalidation
*For any* transaction change that affects historical data, the system should invalidate all snapshots from the affected date forward to ensure state consistency.
**Validates: Requirements 11.8, 11.9**

### Property 13: Input Validation Completeness
*For any* user input, the system should validate all required fields, data types, formats, and business rules on the backend regardless of frontend validation.
**Validates: Requirements 12.1, 12.3, 12.4, 12.6, 12.7**

### Property 14: Error Handling Consistency
*For any* validation failure or error condition, the system should return appropriate error responses with clear descriptions and preserve user input where possible for correction.
**Validates: Requirements 12.2, 12.8, 12.10**

## Error Handling

### Validation Strategy

The system implements comprehensive backend validation regardless of frontend checks. All user input is validated for:

- **Data Types**: Numeric amounts, valid dates, proper UUID formats
- **Business Rules**: Positive amounts, valid entity references, required fields
- **Security**: User authorization, data isolation, input sanitization

### Error Response Format

```typescript
interface ErrorResponse {
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>; // Field-specific errors
    timestamp: Date;
    requestId: string;
  };
  preservedInput?: Record<string, any>; // For form re-population
}

Error Categories

Validation Errors (400): Invalid input data, missing required fields, business rule violations Authentication Errors (401): Missing or invalid JWT tokens Authorization Errors (403): User attempting to access other users’ data Not Found Errors (404): Referenced entities don’t exist Conflict Errors (409): Conditional update failures, referential integrity violations Server Errors (500): Unexpected system failures, database errors

Error Recovery

Testing Strategy

Dual Testing Approach

The system uses both unit tests and property-based tests to ensure comprehensive coverage:

Unit Tests: Verify specific examples, edge cases, and error conditions

Property-Based Tests: Verify universal properties across all inputs

Property-Based Testing Configuration

Framework: Use fast-check library for TypeScript property-based testing Test Iterations: Minimum 100 iterations per property test Test Tagging: Each property test references its design document property Tag Format: // Feature: budget-application, Property {number}: {property_text}

Core Testing Areas

Entity Management:

Event Sourcing:

Balance Calculations:

Plan Scheduling:

Security and Validation:

Test Data Generation

Smart Generators: Create realistic test data that respects business constraints

Edge Case Coverage: Ensure generators include boundary conditions

Integration Testing

End-to-End Scenarios: Test complete user workflows

Database Integration: Test with actual DynamoDB (local)

Continuous Testing

Automated Test Execution: All tests run on every code change Property Test Monitoring: Track property test failure rates and patterns Performance Testing: Monitor test execution time and resource usage Coverage Reporting: Ensure both unit and property tests contribute to coverage metrics

Edit Page Create Sub Page Delete Page