Build & Test
Case Study: Multi-Account Banking USSD App
This case study demonstrates how to build a production-ready banking USSD application using the Tech231 Platform DSL. The app supports:
- Account Creation with PIN and phone validation
- Balance Check via HTTP API integration
- Airtime Purchase with amount validation
- Multiple Accounts per user with account switching
Application Structure
Codeapp "MultiAccountBanking" { shortcode = "*231#" version = "1.0.0" start = "Welcome" }
Welcome & Authentication
Codemenu "Welcome" { text = "Welcome to MyBank!" option "1" { label = "Login" goto = "EnterPin" actions = [ { name = "capture-phone" type = "set" value = "{{session.msisdn}}" target = "normalized_phone" } ] } option "2" { label = "Create Account" goto = "RegisterConfirm" actions = [ { name = "capture-phone" type = "set" value = "{{session.msisdn}}" target = "normalized_phone" } ] } option "0" { label = "Exit" goto = "Goodbye" } } menu "EnterPin" { text = "Enter your 4-digit PIN:" option "*" { label = "Any input" goto = "Authenticating" actions = [ { name = "validate-pin" type = "validate" source = "{{session.input}}" pattern = "^[0-9]{4}$" target = "pin" }, { name = "hash-pin" type = "crypto" mode = "hash" input = "{{pin}}" purpose = "PinHash" target = "hashed_pin" } ] error_target = "InvalidPin" } } menu "InvalidPin" { text = "Invalid PIN. Must be 4 digits." option "0" { label = "Try Again" goto = "EnterPin" } }
Authentication via HTTP
Codemenu "Authenticating" { text = "Authenticating..." option "*" { label = "Auto" goto = "SelectAccount" actions = [ { name = "auth-request" type = "http" client = "banking-api" method = "POST" url = "https://api.mybank.com/auth/login" body = { phone = "{{normalized_phone}}" pinHash = "{{hashed_pin}}" } response_map = { auth_token = "$.token" user_id = "$.userId" accounts = "$.accounts" } target = "auth_response" timeout = 30 }, { name = "store-token" type = "set" value = "{{auth_token}}" target = "session_token" } ] error_target = "AuthFailed" } } menu "AuthFailed" { text = "Login failed. Please check your credentials." option "1" { label = "Try Again" goto = "EnterPhone" } option "0" { label = "Main Menu" goto = "Welcome" } }
Multi-Account Selection
When a user has multiple accounts, display them for selection:
Codemenu "SelectAccount" { text = "Select an account to continue:" option "1" { label = "Savings" goto = "MainMenu" actions = [ { name = "set-account" type = "set" value = "savings_1234" target = "current_account" }, { name = "set-account-type" type = "set" value = "Savings" target = "account_type" } ] } option "2" { label = "Checking" goto = "MainMenu" actions = [ { name = "set-account" type = "set" value = "checking_5678" target = "current_account" }, { name = "set-account-type" type = "set" value = "Checking" target = "account_type" } ] } option "3" { label = "Add Account" goto = "LinkNewAccount" } option "0" { label = "Logout" goto = "Welcome" actions = [ { name = "clear-session" type = "delete" target = "session_token" } ] } }
Main Banking Menu
Codemenu "MainMenu" { text = "{{account_type}} Account\nSelect a service:" option "1" { label = "Check Balance" goto = "FetchingBalance" } option "2" { label = "Transfer" goto = "TransferMenu" } option "3" { label = "Buy Airtime" goto = "AirtimeMenu" } option "4" { label = "Mini Statement" goto = "FetchingStatement" } option "5" { label = "Switch Account" goto = "SelectAccount" } option "0" { label = "Logout" goto = "Welcome" actions = [ { name = "clear-token" type = "delete" target = "session_token" }, { name = "clear-account" type = "delete" target = "current_account" } ] } }
Balance Check with HTTP Integration
Codemenu "FetchingBalance" { text = "Fetching balance..." option "*" { label = "Auto" goto = "ShowBalance" actions = [ { name = "get-balance" type = "http" client = "banking-api" method = "GET" url = "https://api.mybank.com/accounts/{{current_account}}/balance" headers = { Authorization = "Bearer {{session_token}}" } response_map = { balance = "$.availableBalance" currency = "$.currency" } target = "balance_response" timeout = 15 }, { name = "format-balance" type = "string" op = "format" inputs = ["{{currency}}", "{{balance}}"] format = "%s %.2f" target = "formatted_balance" } ] error_target = "BalanceError" } } menu "ShowBalance" { text = "{{account_type}} Balance:\n{{formatted_balance}}" option "0" { label = "Back" goto = "MainMenu" } } menu "BalanceError" { text = "Unable to fetch balance. Please try again." option "0" { label = "Back" goto = "MainMenu" } }
Airtime Purchase with Validation
Codemenu "AirtimeMenu" { text = "Buy Airtime" option "1" { label = "Self" goto = "AirtimeAmount" actions = [ { name = "set-recipient" type = "set" value = "{{session.msisdn}}" target = "airtime_recipient" } ] } option "2" { label = "Others" goto = "AirtimeRecipient" } option "0" { label = "Back" goto = "MainMenu" } } menu "AirtimeRecipient" { text = "Enter recipient phone number:" option "*" { label = "Any" goto = "AirtimeAmount" actions = [ { name = "validate-recipient" type = "validate" source = "{{session.input}}" pattern = "^(\\+?231)?[0-9]{9,10}$" target = "airtime_recipient" } ] error_target = "InvalidRecipient" } } menu "InvalidRecipient" { text = "Invalid phone number." option "0" { label = "Try Again" goto = "AirtimeRecipient" } } menu "AirtimeAmount" { text = "Enter amount (5-500 LRD):" option "*" { label = "Any" goto = "ConfirmAirtime" actions = [ { name = "trim-input" type = "string" op = "trim" inputs = ["{{session.input}}"] target = "raw_amount" }, { name = "validate-amount" type = "validate" source = "{{raw_amount}}" type = "range" min = 5 max = 500 target = "airtime_amount" }, { name = "check-balance" type = "conditional" condition = { lhs = "{{balance}}" operator = ">=" rhs = "{{airtime_amount}}" } true_target = "ConfirmAirtime" false_target = "InsufficientFunds" } ] error_target = "InvalidAmount" } } menu "InvalidAmount" { text = "Invalid amount. Enter 5-500 LRD." option "0" { label = "Try Again" goto = "AirtimeAmount" } } menu "InsufficientFunds" { text = "Insufficient balance for this purchase." option "0" { label = "Back" goto = "MainMenu" } } menu "ConfirmAirtime" { text = "Confirm Airtime Purchase:\nRecipient: {{airtime_recipient}}\nAmount: {{airtime_amount}} LRD" option "1" { label = "Confirm" goto = "ProcessingAirtime" } option "0" { label = "Cancel" goto = "MainMenu" } } menu "ProcessingAirtime" { text = "Processing..." option "*" { label = "Auto" goto = "AirtimeSuccess" actions = [ { name = "purchase-airtime" type = "http" client = "banking-api" method = "POST" url = "https://api.mybank.com/airtime/purchase" headers = { Authorization = "Bearer {{session_token}}" } body = { accountId = "{{current_account}}" recipient = "{{airtime_recipient}}" amount = "{{airtime_amount}}" } response_map = { txn_id = "$.transactionId" new_balance = "$.newBalance" } target = "airtime_response" timeout = 30 }, { name = "update-balance" type = "set" value = "{{new_balance}}" target = "balance" } ] error_target = "AirtimeError" transactional = true } } menu "AirtimeSuccess" { text = "Airtime purchased!\nRef: {{txn_id}}\nNew Balance: {{new_balance}} LRD" option "0" { label = "Main Menu" goto = "MainMenu" } } menu "AirtimeError" { text = "Airtime purchase failed. Please try again." option "0" { label = "Back" goto = "MainMenu" } }
Account Creation Flow
The phone number is automatically captured from the USSD session via {{session.msisdn}}, so users don't need to enter it manually.
Codemenu "RegisterConfirm" { text = "Create account for {{session.msisdn}}?" option "1" { label = "Yes" goto = "RegisterName" actions = [ { name = "set-reg-phone" type = "set" value = "{{session.msisdn}}" target = "reg_phone" } ] } option "0" { label = "Cancel" goto = "Welcome" } } menu "RegisterName" { text = "Enter your full name:" option "*" { label = "Any" goto = "RegisterPin" actions = [ { name = "validate-name" type = "validate" source = "{{session.input}}" pattern = "^[A-Za-z ]{2,50}$" target = "reg_name" }, { name = "title-case" type = "string" op = "title" inputs = ["{{reg_name}}"] target = "reg_name" } ] error_target = "InvalidName" } } menu "InvalidName" { text = "Invalid name. Use letters only (2-50 chars)." option "0" { label = "Try Again" goto = "RegisterName" } } menu "RegisterPin" { text = "Create a 4-digit PIN:" option "*" { label = "Any" goto = "ConfirmPin" actions = [ { name = "validate-new-pin" type = "validate" source = "{{session.input}}" pattern = "^[0-9]{4}$" target = "new_pin" } ] error_target = "InvalidNewPin" } } menu "InvalidNewPin" { text = "PIN must be 4 digits." option "0" { label = "Try Again" goto = "RegisterPin" } } menu "ConfirmPin" { text = "Confirm your PIN:" option "*" { label = "Any" goto = "CreatingAccount" actions = [ { name = "check-pin-match" type = "conditional" condition = { lhs = "{{session.input}}" operator = "==" rhs = "{{new_pin}}" } true_target = "CreatingAccount" false_target = "PinMismatch" } ] error_target = "PinMismatch" } } menu "PinMismatch" { text = "PINs do not match." option "0" { label = "Try Again" goto = "RegisterPin" } } menu "CreatingAccount" { text = "Creating your account..." option "*" { label = "Auto" goto = "AccountCreated" actions = [ { name = "hash-new-pin" type = "crypto" mode = "hash" input = "{{new_pin}}" purpose = "PinHash" target = "hashed_new_pin" }, { name = "create-account" type = "http" client = "banking-api" method = "POST" url = "https://api.mybank.com/accounts/create" body = { phone = "{{reg_phone}}" name = "{{reg_name}}" pinHash = "{{hashed_new_pin}}" } response_map = { new_account_id = "$.accountId" account_number = "$.accountNumber" } target = "create_response" timeout = 30 } ] error_target = "CreateError" transactional = true } } menu "AccountCreated" { text = "Account created!\nAccount: {{account_number}}" option "1" { label = "Login" goto = "EnterPhone" } option "0" { label = "Exit" goto = "Goodbye" } } menu "CreateError" { text = "Account creation failed. Please try again later." option "0" { label = "Main Menu" goto = "Welcome" } }
Utility Menus
Codemenu "Goodbye" { text = "Thank you for using MyBank. Goodbye!" }
Key Patterns Demonstrated
Pattern 1: Input Validation Chain
Always validate user input before processing:
Codeactions = [ { type = "string", op = "trim", inputs = ["{{session.input}}"], target = "clean_input" }, { type = "validate", source = "{{clean_input}}", pattern = "^...$", target = "validated" } ] error_target = "ValidationError"
Pattern 2: Secure PIN Handling
Never store PINs in plain text:
Codeactions = [ { type = "validate", source = "{{session.input}}", pattern = "^[0-9]{4}$", target = "pin" }, { type = "crypto", mode = "hash", input = "{{pin}}", purpose = "PinHash", target = "hashed_pin" }, { type = "delete", target = "pin" } # Clear plain PIN immediately ]
Pattern 3: Multi-Account State
Store current account context in session:
Codeactions = [ { type = "set", value = "account_123", target = "current_account" }, { type = "set", value = "Savings", target = "account_type" } ]
Pattern 4: Transactional Operations
Use transactional = true for financial operations:
Codeoption "1" { goto = "Success" actions = [ { type = "http", method = "POST", url = "...", ... } ] error_target = "TransactionFailed" transactional = true # Rollback session vars on failure }
Pattern 5: Balance Check Before Transaction
Codeactions = [ { type = "validate", source = "{{amount}}", type = "range", min = 1, max = 10000, target = "amount" }, { type = "conditional", condition = { lhs = "{{balance}}", operator = ">=", rhs = "{{amount}}" }, true_target = "Proceed", false_target = "InsufficientFunds" } ]
Deployment
Deploy the complete application:
Codecurl -X POST https://api.tech231apps.net/api/v1/ussd/deploy \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d @banking-app.json
Related Documentation
- USSD DSL Guide — Core DSL syntax
- Action Chains — Chaining actions
- Actions Reference — All action types
Last modified on