Skip to main content

Architecture

This document covers the internal architecture of Wallet-Core, including the service layer, ledger, and data access patterns.

Layer Overview

Service Layer

Services Overview

ServiceFileResponsibility
SubscriberServicesubscriber_service.goUser registration, KYC, PIN management
CardServicecard_service.goCard linking, blocking, child cards
AgentServiceagent_service.goAgent registration, float, cashin
WalletServicewallet_service.goPayments, balances, transfers
ComplianceServicecompliance_service.goMonitoring rules, alerts
POSServicepos_service.goTerminal payments, verification
ProcessorServiceprocessor_service.goExternal processor integration
FeeServicefee_calculator.goFee calculation
AuditServiceaudit_service.goAudit log management
APIKeyServiceapikey_service.goAPI key CRUD
PEPAccessServicepep_access_service.goPrivileged access flows

Service Pattern

// Standard service structure
type SubscriberService struct {
repo *repository.SubscriberRepository
ledger *ledger.Service
audit *AuditService
}

func NewSubscriberService(
repo *repository.SubscriberRepository,
ledger *ledger.Service,
audit *AuditService,
) *SubscriberService {
return &SubscriberService{
repo: repo,
ledger: ledger,
audit: audit,
}
}

func (s *SubscriberService) Register(ctx context.Context, req *RegisterRequest) (*Subscriber, error) {
// Validate
if err := validateRequest(req); err != nil {
return nil, err
}

// Create subscriber
subscriber, err := s.repo.Create(ctx, req)
if err != nil {
return nil, err
}

// Create account
_, err = s.ledger.CreateAccount(ctx, subscriber.ID, "SLE")
if err != nil {
// Rollback or handle
}

// Audit
s.audit.Log(ctx, "subscriber_registered", subscriber.ID)

return subscriber, nil
}

Ledger

The ledger is the core transaction engine, handling all financial operations.

Core Operations

OperationDescription
ExecutePaymentAtomic payment between accounts
GetBalanceRetrieve account balance
CreateAccountCreate new account for user+currency
GetTransactionRetrieve transaction by ID
ListTransactionsQuery transaction history
GetAccountStatementGenerate account statements

Payment Execution

// ledger/service.go
func (l *Service) ExecutePayment(ctx context.Context, req *PaymentRequest) (*Transaction, error) {
// Check idempotency
existing, err := l.txRepo.GetByRequestID(ctx, req.RequestID)
if err == nil && existing != nil {
return existing, nil // Return existing for duplicate
}

// Start transaction
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()

// Get or create accounts
senderAcc, err := l.getOrCreateAccount(ctx, tx, req.UserID, req.Currency)
if err != nil {
return nil, err
}

recipientAcc, err := l.getOrCreateAccount(ctx, tx, req.RecipientID, req.Currency)
if err != nil {
return nil, err
}

// Validate balance
if senderAcc.Balance < req.Amount {
return nil, ErrInsufficientBalance
}

// Debit sender
if err := l.debit(ctx, tx, senderAcc.ID, req.Amount); err != nil {
return nil, err
}

// Credit recipient
if err := l.credit(ctx, tx, recipientAcc.ID, req.Amount); err != nil {
return nil, err
}

// Create transaction record
txn := &Transaction{
ID: generateID(),
RequestID: req.RequestID,
UserID: req.UserID,
RecipientID: req.RecipientID,
Amount: req.Amount,
Currency: req.Currency,
Status: "COMPLETED",
CreatedAt: time.Now(),
CompletedAt: time.Now(),
}

if err := l.txRepo.Create(ctx, tx, txn); err != nil {
return nil, err
}

// Audit log
if err := l.auditLog(ctx, tx, txn); err != nil {
return nil, err
}

// Commit
if err := tx.Commit(); err != nil {
return nil, err
}

return txn, nil
}

Repository Layer

Repositories provide data access and encapsulate SQL queries.

Repository Pattern

// repository/account_repository.go
type AccountRepository struct {
db *sql.DB
}

func (r *AccountRepository) GetByUserAndCurrency(
ctx context.Context,
userID, currency string,
) (*models.Account, error) {
query := `
SELECT id, user_id, currency, balance, created_at, updated_at
FROM accounts
WHERE user_id = $1 AND currency = $2
`

var acc models.Account
err := r.db.QueryRowContext(ctx, query, userID, currency).Scan(
&acc.ID, &acc.UserID, &acc.Currency,
&acc.Balance, &acc.CreatedAt, &acc.UpdatedAt,
)

if err == sql.ErrNoRows {
return nil, ErrAccountNotFound
}

return &acc, err
}

func (r *AccountRepository) UpdateBalance(
ctx context.Context,
tx *sql.Tx,
accountID string,
delta int64,
) error {
query := `
UPDATE accounts
SET balance = balance + $1, updated_at = NOW()
WHERE id = $2
`

_, err := tx.ExecContext(ctx, query, delta, accountID)
return err
}

Available Repositories

RepositoryTableOperations
AccountRepositoryaccountsCRUD, balance updates
TransactionRepositorytransactionsCreate, query, list
SubscriberRepositorysubscribersCRUD, lookup by phone
CardRepositorynfc_cardsLink, block, list
AgentRepositoryagentsCRUD, float management
AuditLogRepositoryaudit_logCreate, query
APIKeyRepositoryapi_keysCRUD
ComplianceAlertRepositorycompliance_alertsCreate, resolve
MonitoringRuleRepositorymonitoring_rulesCRUD

gRPC Server

The server layer maps gRPC requests to service calls:

// server/server.go
type WalletServer struct {
wallet.UnimplementedWalletServiceServer
ledger *ledger.Service
}

func (s *WalletServer) ExecutePayment(
ctx context.Context,
req *wallet.PaymentRequest,
) (*wallet.PaymentResponse, error) {
// Map proto to domain
payment := &ledger.PaymentRequest{
RequestID: req.RequestId,
UserID: req.UserId,
RecipientID: req.RecipientId,
Amount: req.Amount,
Currency: req.Currency,
Memo: req.Memo,
}

// Execute
txn, err := s.ledger.ExecutePayment(ctx, payment)
if err != nil {
return nil, mapToGRPCError(err)
}

// Map to proto response
return &wallet.PaymentResponse{
TransactionId: txn.ID,
Status: txn.Status,
NewBalance: txn.NewBalance,
}, nil
}

Database Migrations

Migrations are run at startup:

// database/database.go
func Migrate(db *sql.DB) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS accounts (...)`,
`CREATE TABLE IF NOT EXISTS transactions (...)`,
`CREATE TABLE IF NOT EXISTS subscribers (...)`,
// ... more tables
}

for _, m := range migrations {
if _, err := db.Exec(m); err != nil {
return err
}
}

return nil
}

Error Handling

Domain errors are defined and mapped:

// errors.go
var (
ErrAccountNotFound = errors.New("account not found")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrDuplicateRequest = errors.New("duplicate request")
ErrCardAlreadyLinked = errors.New("card already linked")
)

// Map to gRPC status codes
func mapToGRPCError(err error) error {
switch {
case errors.Is(err, ErrAccountNotFound):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, ErrInsufficientBalance):
return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, ErrDuplicateRequest):
return status.Error(codes.AlreadyExists, err.Error())
default:
return status.Error(codes.Internal, "internal error")
}
}

Next Steps