Documentation Index
Fetch the complete documentation index at: https://docs.vultlocal.com/llms.txt
Use this file to discover all available pages before exploring further.
Wallet-Core 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
| Service | File | Responsibility |
|---|
SubscriberService | subscriber_service.go | User registration, KYC, PIN management |
CardService | card_service.go | Card linking, blocking, child cards |
AgentService | agent_service.go | Agent registration, float, cashin |
WalletService | wallet_service.go | Payments, balances, transfers |
ComplianceService | compliance_service.go | Monitoring rules, alerts |
POSService | pos_service.go | Terminal payments, verification |
ProcessorService | processor_service.go | External processor integration |
FeeService | fee_calculator.go | Fee calculation |
AuditService | audit_service.go | Audit log management |
APIKeyService | apikey_service.go | API key CRUD |
PEPAccessService | pep_access_service.go | Privileged 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
| Operation | Description |
|---|
ExecutePayment | Atomic payment between accounts |
GetBalance | Retrieve account balance |
CreateAccount | Create new account for user+currency |
GetTransaction | Retrieve transaction by ID |
ListTransactions | Query transaction history |
GetAccountStatement | Generate 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
| Repository | Table | Operations |
|---|
AccountRepository | accounts | CRUD, balance updates |
TransactionRepository | transactions | Create, query, list |
SubscriberRepository | subscribers | CRUD, lookup by phone |
CardRepository | nfc_cards | Link, block, list |
AgentRepository | agents | CRUD, float management |
AuditLogRepository | audit_log | Create, query |
APIKeyRepository | api_keys | CRUD |
ComplianceAlertRepository | compliance_alerts | Create, resolve |
MonitoringRuleRepository | monitoring_rules | CRUD |
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
Ledger
Detailed ledger documentation
Configuration
config.yaml reference