What you'll build

In this part you'll replace the hardcoded balance and meter data with real Supabase queries, and wire up the Africa's Talking Airtime API so that confirming an airtime purchase actually sends airtime to the user's phone.

By the end of this part:

Set up Supabase

Go to app.supabase.com and create a new project. Once it's ready, go to SQL Editor and run:

create table accounts (
  id          uuid primary key default gen_random_uuid(),
  phone       text unique not null,
  account_no  text unique not null,
  balance_ghs numeric(10,2) default 0,
  name        text not null
);

create table meters (
  id           uuid primary key default gen_random_uuid(),
  meter_no     text unique not null,
  account_id   uuid references accounts(id),
  balance_ghs  numeric(10,2) default 0
);

create table payments (
  id           uuid primary key default gen_random_uuid(),
  meter_no     text not null,
  amount_ghs   numeric(10,2) not null,
  reference    text not null,
  status       text default 'pending',
  created_at   timestamptz default now()
);

Seed a test account and meter:

insert into accounts (phone, account_no, balance_ghs, name)
values ('+233200000001', '12345', 45.00, 'Ama Mensah');

insert into meters (meter_no, account_id, balance_ghs)
select 'MTR001', id, 120.00 from accounts where account_no = '12345';

Copy your Project URL and service_role key from Settings → API and add them to .env:

SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your_service_role_key

Install the Supabase client:

npm install @supabase/supabase-js

Build the data service layer

BlocWeave prompt

Create src/services/db.ts for the GH Utilities USSD service. 1. Initialise a Supabase client using SUPABASE_URL and SUPABASE_SERVICE_KEY from env. 2. Export getAccountBalance(accountNo: string): Promise<{ name: string; balance: number } | null> — queries the accounts table by account_no, returns name and balance_ghs cast to number, or null if not found. 3. Export getMeterBalance(meterNo: string): Promise<{ balance: number } | null> — queries meters table by meter_no, returns balance_ghs, or null. 4. Export recordPayment(meterNo: string, amountGhs: number, reference: string): Promise — inserts a row into the payments table with status "pending".

Wire up the Airtime API

BlocWeave prompt

Create src/services/airtime.ts for the GH Utilities USSD service. Install africastalking npm package. Import and initialise with AT_USERNAME and AT_API_KEY from env. Export sendAirtime(phoneNumber: string, amount: string, currencyCode: string = "GHS"): Promise<{ success: boolean; errorMessage?: string }>. Call AT.AIRTIME.send({ recipients: [{ phoneNumber, amount, currencyCode }] }). Return { success: true } on success, { success: false, errorMessage: error.message } on failure. Log any errors but don't throw — USSD responses must always return something to AT.

Install the package first:

npm install africastalking
npm install -D @types/africastalking

Update the USSD handler to use real data

BlocWeave prompt

Update src/ussd/handler.ts to use the db and airtime services. At Level 2, balance check (steps[0]==="1", steps[1] is an account number): - Call getAccountBalance(steps[1]) - If null → CON "Account not found. Try again:\n0. Back" - If found → store { name, balance } in a module-level Map<sessionId, { name, balance }> so deeper levels can use it - Show "Account: [name]\nBalance: GHS [balance.toFixed(2)]\n\n1. View payment history\n0. Back" At Level 3, airtime confirm (steps[0]==="2", steps[2]==="1"): - Call sendAirtime(phoneNumber, steps[1]) where phoneNumber comes from the AT request body - If success → END "GHS [steps[1]] airtime sent to [phoneNumber]. Check your phone for the credit." - If failure → END "Airtime could not be processed. Please try again or contact support." At Level 2, meter lookup (steps[0]==="3", steps[1] is a meter number): - Call getMeterBalance(steps[1]) - If null → CON "Meter not found. Try again:\n0. Back" - If found → store { meterBalance } in session map keyed by sessionId - Show "Meter: [steps[1]]\nBalance due: GHS [balance.toFixed(2)]\n\n1. Pay GHS [balance.toFixed(2)]\n2. Pay different amount\n0. Back" At Level 4, payment confirm (steps[0]==="3", steps[2]==="1", steps[3]==="1"): - Generate reference "GHU-" + Math.random().toString(36).slice(2,8).toUpperCase() - Call recordPayment(steps[1], sessionData.meterBalance, reference) - END "Payment of GHS [amount] for meter [steps[1]] submitted.\nRef: [reference]\nSMS confirmation follows."

Test with real data

Restart the server. In the AT simulator, use phone +233200000001:

  1. Dial → 112345 → you should see "Ama Mensah" and a balance of 45.00
  2. Dial → 251 → you should receive an actual airtime credit on your sandbox phone
  3. Dial → 3MTR00111 → a row should appear in your Supabase payments table

Project structure after Part 3

src/
  services/
    db.ts       ← Supabase account + meter queries
    airtime.ts  ← AT Airtime API wrapper
  ussd/
    handler.ts  ← state machine using real data
  index.ts

What's next

In Part 4 we add SMS confirmation messages after every completed transaction and deploy the server to Railway so it runs 24/7 without ngrok.