Skip to main content

Ledger

The Ledger is the core transaction processing engine in Wallet-Core, responsible for atomic payments, account management, and transaction integrity.

Core Principles

Atomicity

All operations complete entirely or not at all

Idempotency

Duplicate requests return existing results

Consistency

Balances always reflect actual state

Auditability

Every operation is logged for traceability

Payment Flow

Idempotency

Every payment request requires a unique request_id. If the same request_id is submitted again, the ledger returns the existing transaction without re-executing.

Implementation

func (l *Service) ExecutePayment(ctx context.Context, req *PaymentRequest) (*Transaction, error) {
    // Check for existing transaction with same request_id
    existing, err := l.txRepo.GetByRequestID(ctx, req.RequestID)
    if err == nil && existing != nil {
        // Return cached result
        return existing, nil
    }
    
    // Proceed with new transaction
    // ...
}

Client Usage

# First request
curl -X POST /api/v1/payments \
  -H "X-Request-ID: uuid-12345" \
  -d '{"user_id": "u1", "recipient": "u2", "amount": 1000}'
# Returns: transaction_id = txn_abc

# Duplicate request (same X-Request-ID)
curl -X POST /api/v1/payments \
  -H "X-Request-ID: uuid-12345" \
  -d '{"user_id": "u1", "recipient": "u2", "amount": 1000}'
# Returns: same transaction_id = txn_abc (no new transaction created)

Account Model

Accounts are created per user and currency combination.

Schema

CREATE TABLE accounts (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    currency TEXT NOT NULL,
    balance BIGINT NOT NULL DEFAULT 0,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(user_id, currency)
);

CREATE INDEX idx_accounts_user_id ON accounts(user_id);
CREATE INDEX idx_accounts_user_currency ON accounts(user_id, currency);

Lazy Creation

Accounts are created automatically when needed:
func (l *Service) getOrCreateAccount(ctx context.Context, tx *sql.Tx, userID, currency string) (*Account, error) {
    // Try to get existing
    acc, err := l.accRepo.GetByUserAndCurrency(ctx, userID, currency)
    if err == nil {
        return acc, nil
    }
    
    // Create new account
    newAcc := &Account{
        ID:        generateID(),
        UserID:    userID,
        Currency:  currency,
        Balance:   0,
        CreatedAt: time.Now(),
    }
    
    if err := l.accRepo.Create(ctx, tx, newAcc); err != nil {
        return nil, err
    }
    
    return newAcc, nil
}

Transaction Model

Schema

CREATE TABLE transactions (
    id TEXT PRIMARY KEY,
    request_id TEXT UNIQUE NOT NULL,
    user_id TEXT NOT NULL,
    recipient_id TEXT NOT NULL,
    amount BIGINT NOT NULL,
    currency TEXT NOT NULL,
    status TEXT NOT NULL,
    memo TEXT,
    metadata JSONB,
    created_at TIMESTAMP DEFAULT NOW(),
    completed_at TIMESTAMP
);

CREATE INDEX idx_transactions_user_id ON transactions(user_id);
CREATE INDEX idx_transactions_request_id ON transactions(request_id);
CREATE INDEX idx_transactions_created_at ON transactions(created_at);

Transaction Statuses

StatusDescription
PENDINGTransaction initiated, not yet processed
COMPLETEDSuccessfully executed
FAILEDProcessing failed
REVERSEDTransaction was reversed
EXPIREDPending transaction expired

Balance Operations

Get Balance

func (l *Service) GetBalance(ctx context.Context, userID, currency string) (*Balance, error) {
    acc, err := l.accRepo.GetByUserAndCurrency(ctx, userID, currency)
    if err != nil {
        if errors.Is(err, ErrAccountNotFound) {
            // Return zero balance for non-existent account
            return &Balance{
                UserID:   userID,
                Currency: currency,
                Balance:  0,
            }, nil
        }
        return nil, err
    }
    
    return &Balance{
        UserID:   acc.UserID,
        Currency: acc.Currency,
        Balance:  acc.Balance,
        AsOf:     time.Now(),
    }, nil
}

Debit/Credit

func (l *Service) debit(ctx context.Context, tx *sql.Tx, accountID string, amount int64) error {
    query := `
        UPDATE accounts 
        SET balance = balance - $1, updated_at = NOW()
        WHERE id = $2 AND balance >= $1
    `
    
    result, err := tx.ExecContext(ctx, query, amount, accountID)
    if err != nil {
        return err
    }
    
    rows, _ := result.RowsAffected()
    if rows == 0 {
        return ErrInsufficientBalance
    }
    
    return nil
}

func (l *Service) credit(ctx context.Context, tx *sql.Tx, accountID string, amount int64) error {
    query := `
        UPDATE accounts 
        SET balance = balance + $1, updated_at = NOW()
        WHERE id = $2
    `
    
    _, err := tx.ExecContext(ctx, query, amount, accountID)
    return err
}

Audit Logging

Every transaction creates an audit log entry:
func (l *Service) auditLog(ctx context.Context, tx *sql.Tx, txn *Transaction) error {
    entry := &AuditLog{
        ID:            generateID(),
        TransactionID: txn.ID,
        EventType:     "PAYMENT_EXECUTED",
        Actor:         txn.UserID,
        Details:       map[string]interface{}{
            "amount":     txn.Amount,
            "currency":   txn.Currency,
            "recipient":  txn.RecipientID,
        },
        Timestamp: time.Now(),
    }
    
    return l.auditRepo.Create(ctx, tx, entry)
}

Audit Log Schema

CREATE TABLE audit_log (
    id TEXT PRIMARY KEY,
    transaction_id TEXT,
    event_type TEXT NOT NULL,
    actor TEXT,
    details JSONB,
    timestamp TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_audit_log_transaction_id ON audit_log(transaction_id);
CREATE INDEX idx_audit_log_event_type ON audit_log(event_type);
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp);

Transaction Queries

List Transactions

func (l *Service) ListTransactions(ctx context.Context, req *ListRequest) (*TransactionList, error) {
    query := `
        SELECT id, request_id, user_id, recipient_id, amount, currency, status, memo, metadata, created_at, completed_at
        FROM transactions
        WHERE user_id = $1 OR recipient_id = $1
        ORDER BY created_at DESC
        LIMIT $2 OFFSET $3
    `
    
    rows, err := l.db.QueryContext(ctx, query, req.UserID, req.Limit, req.Offset)
    // ... process rows
}

Account Statement

func (l *Service) GetAccountStatement(ctx context.Context, req *StatementRequest) (*Statement, error) {
    query := `
        SELECT 
            id, 
            CASE WHEN user_id = $1 THEN -amount ELSE amount END as delta,
            created_at,
            memo
        FROM transactions
        WHERE (user_id = $1 OR recipient_id = $1)
          AND currency = $2
          AND created_at BETWEEN $3 AND $4
        ORDER BY created_at
    `
    
    // Execute and build statement with running balance
    // ...
}

Reconciliation

Wallet-Core includes VULT reconciliation for external processor integration:
// services/vult_reconciliation.go
func (s *VULTReconciliationService) Reconcile(ctx context.Context) (*ReconciliationResult, error) {
    // Fetch external transactions from VULT
    external, err := s.vult.GetTransactions(ctx, lastReconcileTime)
    if err != nil {
        return nil, err
    }
    
    // Compare with internal records
    internal, err := s.txRepo.GetByTimeRange(ctx, lastReconcileTime, time.Now())
    if err != nil {
        return nil, err
    }
    
    // Find discrepancies
    mismatches := findMismatches(external, internal)
    
    // Create alerts for mismatches
    for _, m := range mismatches {
        s.compliance.CreateAlert(ctx, "RECONCILIATION_MISMATCH", m)
    }
    
    return &ReconciliationResult{
        Processed:  len(external),
        Matched:    len(external) - len(mismatches),
        Mismatches: len(mismatches),
    }, nil
}

Error Handling

Common ledger errors:
ErrorCodeDescription
ErrInsufficientBalanceINSUFFICIENT_BALANCESender balance too low
ErrAccountNotFoundACCOUNT_NOT_FOUNDAccount doesn’t exist
ErrDuplicateRequestDUPLICATE_REQUESTRequest ID already used
ErrInvalidAmountINVALID_AMOUNTAmount must be positive
ErrCurrencyMismatchCURRENCY_MISMATCHSender/recipient currency mismatch

Next Steps