Every minute of a call that completes must debit the wallet exactly once: not zero times, not twice. A mutable balance column cannot guarantee this under concurrent calls, network retries, or a database under load. The correct design is an append-only ledger where balance is derived from SUM(amount), debits are idempotent via a unique idempotency_key, and top-up state is modelled explicitly as pending/confirmed/failed, so a failed M-Pesa STK push never partially credits anything.
This looks fine in a single-threaded test. Under concurrent calls it is not. Consider two calls completing simultaneously on the same workspace: both handlers read balance = 10000, both compute 10000 - 400 = 9600, and both write 9600. One debit vanished. The wallet now shows KES 9.60 instead of KES 9.20, and your audit trail has no record of why.
The standard fix, SELECT ... FOR UPDATE, eliminates the race but serialises all concurrent debit operations for the same workspace. Under load this creates lock contention that degrades database performance and increases call-completion latency. It also does nothing to protect against duplicate webhook delivery, which is an at-least-once guarantee that every event system provides.
The correct schema stores every financial event as an immutable row:
CREATE TABLE wallet_entries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id), idempotency_key TEXT NOT NULL, entry_type TEXT NOT NULL CHECK (entry_type IN ('debit', 'credit', 'reversal')), amount_kes_millicents BIGINT NOT NULL, call_id UUID REFERENCES calls(id), description TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (workspace_id, idempotency_key));
Balance is not stored; it is derived:
SELECT COALESCE(SUM(amount_kes_millicents), 0) AS balanceFROM wallet_entriesWHERE workspace_id = $1;
For O(1) balance reads without scanning the full ledger, maintain a materialised running total:
CREATE MATERIALIZED VIEW wallet_balances ASSELECT workspace_id, SUM(amount_kes_millicents) AS balance_kes_millicentsFROM wallet_entriesGROUP BY workspace_id;CREATE UNIQUE INDEX ON wallet_balances (workspace_id);
Refresh it after each insert, either with a trigger or in the same transaction:
CONCURRENTLY means readers are never blocked during the refresh. The view stays consistent with the ledger: it is not a cache that can drift, it is a deterministic projection.
All amounts in the ledger are stored as integer millicents (thousandths of a KES cent). KES 3.00/min expressed as a per-second rate is:
KES 3.00 / 60 seconds = KES 0.0500 per second
Storing this as a float accumulates rounding error over thousands of entries. Instead, compute in integer arithmetic:
// Sautikit bills per second from the moment the call connects.// KES 3.00/min = 300 KES cents per minute// Let's be precise:// KES 3.00 = 300 KES cents = 300_000 millicents per 60 seconds// per second = 300_000 / 60 = 5_000 millicents/sec (exact, no truncation)const ratePerSecondMillicents = 300_000 / 60 // = 5000func debitAmount(durationSeconds int) int64 { if durationSeconds <= 0 { return 0 } return int64(durationSeconds) * ratePerSecondMillicents}
A 1-second call costs 5 000 millicents (KES 0.05). A 25-second call costs 125 000 millicents (KES 1.25). A 61-second call is billed as 61 seconds; Sautikit bills per second from the moment the call connects, with no minimum. Write a test for the boundary:
Run this test after any change to the billing arithmetic. At KES 3.00/min the per-second rate is exactly 5 000 millicents, with no truncation error. Inbound calls have a rate of KES 0/min and are never debited.
Your call.completed webhook handler receives the call ID and duration. The debit must be idempotent: if the webhook fires twice (at-least-once delivery), the ledger must record only one entry.
The idempotency_key is the compound of the event type and call ID. Use INSERT ... ON CONFLICT DO NOTHING:
func (s *BillingService) HandleCallCompleted(ctx context.Context, callID string, durationSec int) error { debit := debitAmount(durationSec) // negative for a debit idempotencyKey := "call.completed:" + callID _, err := s.db.ExecContext(ctx, ` INSERT INTO wallet_entries (workspace_id, idempotency_key, entry_type, amount_kes_millicents, call_id, description) SELECT c.workspace_id, $2, 'debit', -$3, c.id, 'Call completed: ' || c.duration_seconds || 's' FROM calls c WHERE c.id = $1 ON CONFLICT (workspace_id, idempotency_key) DO NOTHING `, callID, idempotencyKey, debit) if err != nil { return fmt.Errorf("insert ledger entry: %w", err) } // Refresh the materialised view so balance reads are current. _, err = s.db.ExecContext(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY wallet_balances`) return err}
The ON CONFLICT DO NOTHING clause makes duplicate webhook delivery safe. The first delivery inserts the row; every subsequent delivery hits the unique constraint and silently does nothing. No distributed lock required.
The wallet's credit side introduces a second source of correctness risk: an M-Pesa STK push that times out or is declined by the user must never partially credit the wallet.
Model top-up state explicitly:
CREATE TABLE wallet_topups ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id), phone_e164 TEXT NOT NULL, -- must include + prefix amount_kes_minor INT NOT NULL, -- KES cents checkout_request_id TEXT UNIQUE, -- Daraja CheckoutRequestID mpesa_receipt TEXT, -- populated on confirmation state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'confirmed', 'failed')), initiated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), resolved_at TIMESTAMPTZ);
The top-up flow is two-step. First, initiate the STK push and record the CheckoutRequestID:
# Step 1: initiate via Sautikit wallet API (which calls Daraja on your behalf)curl -s -X POST https://api.sautikit.com/v1/topups \ -H "Authorization: Bearer $SAUTIKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone_e164": "+254712345678", "amount_kes": 2000 }'
Response:
{ "topup_id": "tpu_abc123", "checkout_request_id": "ws_CO_17062026123456789", "state": "pending", "message": "STK push sent. Prompt will appear on the registered phone."}
The phone_e164 field requires the + prefix. Daraja returns a CheckoutRequestID; store it in wallet_topups.checkout_request_id.
Sautikit polls Daraja's MpesaExpressQuery endpoint internally and fires a wallet.top_up webhook when the top-up is confirmed. If the STK push fails, the top-up moves to a failed state internally and no webhook is emitted. On confirmation, insert a credit entry:
func (s *BillingService) HandleTopupConfirmed(ctx context.Context, topupID string, amountKESMinor int) error { idempotencyKey := "wallet.top_up:" + topupID amountMillicents := int64(amountKESMinor) * 1000 // KES cents → millicents _, err := s.db.ExecContext(ctx, ` WITH topup AS ( UPDATE wallet_topups SET state = 'confirmed', resolved_at = NOW() WHERE id = $1 AND state = 'pending' RETURNING workspace_id ) INSERT INTO wallet_entries (workspace_id, idempotency_key, entry_type, amount_kes_millicents, description) SELECT workspace_id, $2, 'credit', $3, 'M-Pesa top-up confirmed: ' || $1 FROM topup ON CONFLICT (workspace_id, idempotency_key) DO NOTHING `, topupID, idempotencyKey, amountMillicents) return err}
The UPDATE wallet_topups ... WHERE state = 'pending' guard ensures that a duplicate wallet.top_up event does not credit twice. The ON CONFLICT covers any remaining edge case. A failed top-up never reaches this handler; it updates state = 'failed' and does not insert any ledger row.
Ledgers accumulate subtle bugs over time: a webhook that fired but hit a database timeout before inserting, a billing worker that crashed mid-run, a one-off script that deducted without using the idempotency guard. Detect drift with a nightly reconciliation query:
-- Calls that completed but have no corresponding ledger debitSELECT c.id, c.workspace_id, c.duration_seconds, c.completed_atFROM calls cLEFT JOIN wallet_entries we ON we.call_id = c.id AND we.entry_type = 'debit'WHERE c.status = 'completed' AND c.completed_at < NOW() - INTERVAL '10 minutes' -- allow processing lag AND we.id IS NULLORDER BY c.completed_at DESC;
Any row returned by this query represents a call that was completed but not debited. The remediation is to insert the missing debit manually; the idempotency key scheme means doing so is safe even if the entry already exists (it will just do nothing).
Run the reconciliation daily and alert if the count exceeds a threshold (zero expected under normal operation). The query is fast if wallet_entries has an index on (call_id).
Sautikit's own prepaid KES wallet follows this ledger model. The GET /v1/wallet/balance endpoint returns the current balance derived from the ledger, not a cached mutable field:
balance_minor is KES cents. 85000 = KES 850. The status field is healthy when balance is above the low-balance threshold, warning when it is within 20% of a configurable floor, and critical when it is below the floor.
The GET /v1/wallet/statements endpoint returns paginated ledger rows (one row per debit and credit) so you can reconcile your own records against Sautikit's at any time.