<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[week 14 Building the Virtual Smartphone: Redis Pub/Sub and the Human-in-the-Loop Problem]]></title><description><![CDATA[week 14 Building the Virtual Smartphone: Redis Pub/Sub and the Human-in-the-Loop Problem]]></description><link>https://week-14.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>week 14 Building the Virtual Smartphone: Redis Pub/Sub and the Human-in-the-Loop Problem</title><link>https://week-14.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 02 Sep 2026 19:37:36 GMT</lastBuildDate><atom:link href="https://week-14.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building the Virtual Smartphone: Redis Pub/Sub and the Human-in-the-Loop Problem]]></title><description><![CDATA[Sim-Pesa Build Log — Week 14 of 16

Welcome to another build log of Sim-Pesa. If you're new here, this is a project I've been building for 14 weeks now with the aim of leveling up my software developm]]></description><link>https://week-14.hashnode.dev/building-the-virtual-smartphone-redis-pub-sub-and-the-human-in-the-loop-problem</link><guid isPermaLink="true">https://week-14.hashnode.dev/building-the-virtual-smartphone-redis-pub-sub-and-the-human-in-the-loop-problem</guid><dc:creator><![CDATA[Paul Murithi Kirera]]></dc:creator><pubDate>Sun, 26 Apr 2026 05:44:33 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68f319caa305480f4cebccb8/17279bd1-f7a4-4bbf-8af0-f20c748d1e37.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Sim-Pesa Build Log — Week 14 of 16</em></p>
<hr />
<p>Welcome to another build log of Sim-Pesa. If you're new here, this is a project I've been building for 14 weeks now with the aim of leveling up my software development skills. It's a local transaction appliance designed to help developers test their M-Pesa payment integrations. It's built to run completely offline, removing the friction associated with the cloud Daraja API. Be sure to check out other articles in the series to follow what I've covered in previous weeks.</p>
<p>Last week, we built the React dashboard, bridging the gap between the backend and the frontend. The backend that we spent 12 weeks building — viewed through terminal logs and structured JSON — now has a nice UI with real-time updates.</p>
<p>This week, the main goal was to implement a <strong>Virtual Smartphone</strong> feature. I thought it would be cool to go a level higher and simulate the STK Push request.</p>
<p>The idea: Build a component complete with the phone chrome and user input keyboard, directly into our dashboard. In a real M-Pesa online integration, when you initiate a payment, you see that popup that appears on your phone for you to enter the PIN? <strong>That's what we want to add to Sim-Pesa.</strong></p>
<hr />
<h2>The First Stumble: This Is an Event-Driven Problem</h2>
<p>It's an easy component, right? We just add it as a modal that pops up after you initiate an STK Push request. <strong>How hard can it be?</strong></p>
<p>It turns out, like everything else in our system, this too is an <strong>event-driven problem</strong>. Here's why.</p>
<p>Here's my current architecture at a very high level:</p>
<ol>
<li><p>The user initiates an STK Push request</p>
</li>
<li><p>Auth middleware intercepts the request and checks Redis if the token is valid</p>
</li>
<li><p>If the token exists, the request is allowed to our Ingestion API</p>
</li>
<li><p>The Ingestion API is a thin layer that does minimal validation, DB insert, and enqueues the payment job, returning a response in &lt;50ms</p>
</li>
<li><p>The worker reads jobs from the Redis Queue and starts processing the payment job</p>
</li>
</ol>
<p>So far, so good. No real surprises there. <strong>The issue starts in the Worker service when it starts processing the job.</strong></p>
<h3>The Two-Phase Problem</h3>
<p>Due to the human factor of manually typing a PIN, it wasn't feasible to hold DB locks throughout that time. So I split the processing into <strong>2 phases</strong> or separate database transactions (you can read more about this in my <a href="https://simpesa.hashnode.dev/building-a-financial-vault-row-level-locking-and-the-two-lock-dance">Week 6 blog</a>):</p>
<p><strong>Phase 1 (Intent):</strong></p>
<ul>
<li><p>Locks rows and does minimal validation (balance check)</p>
</li>
<li><p>Moves the transaction to <code>PROCESSING</code> stage</p>
</li>
<li><p>Releases locks and triggers PIN entry via the Virtual Smartphone</p>
</li>
</ul>
<p><strong>Phase 2 (Finalize):</strong></p>
<ul>
<li><p>After user has entered PIN, Phase 2 runs</p>
</li>
<li><p>Locks rows and rechecks balance again plus more validation</p>
</li>
<li><p>Moves the transaction to a terminal state such as <code>FAILED</code> or <code>SUCCESS</code></p>
</li>
</ul>
<p>Here's what the code looked like:</p>
<pre><code class="language-typescript">async processTransaction(transactionalData: CreateTransactionDTO) {
  const { checkout_id } = transactionalData;

  // Phase 1: Lock rows and validate balance
  await repo.lockRowsValidate(transactionalData);
  await this.publish(checkout_id);

  // Keep PROCESSING visible long enough for dashboard SSE subscribers
  await wait(PROCESSING_VISIBILITY_DELAY_MS);

  // TODO: STK Push logic

  // Phase 2: Complete the transaction
  await repo.finalizeTransaction(transactionalData);
  await this.publish(checkout_id);
}
</code></pre>
<h3>The Core Problem</h3>
<p>Now we can clearly see that the transaction processing happens <strong>deep in the Worker layer</strong>. But the PIN prompt is in the <strong>UI client</strong> — in our case, a React dashboard. When the PIN is submitted, we have to send a POST request with the PIN entered as the form data.</p>
<p>So our current flow looks like this:</p>
<pre><code class="language-plaintext">Worker: Phase1 → await PIN → Phase2

API: ↑ POST /pin arrives here
</code></pre>
<p>This is a <strong>classic problem</strong> where a long-running workflow spans <strong>two separate process boundaries</strong> (the Worker and the API). We need to manage the state of an asynchronous process across decoupled services that don't know about each other or share a memory space.</p>
<p><strong>The Worker is literally</strong> <code>await</code><strong>-ing something that will arrive through a completely different service.</strong> That's the crux of it.</p>
<hr />
<h2>The Solution: Continuation via Redis (A Shared Signal)</h2>
<p>After much research and trial and error, I learned that the <strong>key insight</strong> is:</p>
<blockquote>
<p>"You don't resume the worker. You let it finish, and you let the database act as the source of truth."</p>
</blockquote>
<p>And it makes sense. It's what we've been preaching for this project ever since Week 1: <strong>"The database is the single source of truth."</strong> In a system driven by ephemeral events all over, one thing remains constant and true: <strong>The Database.</strong></p>
<h3>What We Were Doing Wrong</h3>
<p>Our system was trying to <strong>hold the Worker hostage</strong> while it waits for a human to enter a PIN on the dashboard. In a distributed queue like BullMQ, <strong>a worker should never sit idle and</strong> <code>wait()</code> <strong>for external input.</strong></p>
<p>If the Worker crashes, restarts, or the user takes forever to enter the PIN, that job is stuck in uncertainty, consuming memory and worker threads.</p>
<h3>The Better Approach</h3>
<p>Instead of one massive <code>processTransaction()</code> job that pauses in the middle, we need to break it down into <strong>distinct non-blocking events</strong>. Our <code>transactions</code> table <code>status</code> column already provides a finite set of states the transaction can be in. That would be the ultimate source of truth here.</p>
<p>One of the options I stumbled on was using BullMQ's <code>moveToWaitingChildren</code>. This is a built-in mechanism where, after Phase 1, the parent job moves itself to a waiting state. The PIN submission creates or resolves a child/dependent job that unblocks it.</p>
<p>But honestly, it was so complicated for me that I decided to <strong>reuse my current stack idea from last week</strong>.</p>
<p>We already used <strong>Redis Pub/Sub</strong> last week to publish and subscribe to events. Turns out we can use that same idea for this problem.</p>
<hr />
<h2>The Idea: Redis-Based Promise (Pub/Sub as a One-Shot Signal)</h2>
<p>The idea is this:</p>
<p>After Phase 1, <strong>the Worker doesn't await</strong> <code>finalizeTransaction</code> <strong>directly. It awaits a Redis signal with a timeout.</strong></p>
<p>The API layer, upon receiving the PIN POST, validates the PIN and <strong>publishes that signal</strong>.</p>
<p>So our logic becomes:</p>
<pre><code class="language-typescript">async processTransaction(transactionalData: CreateTransactionDTO) {
  const { checkout_id } = transactionalData;

  // Phase 1: Lock, validate, publish PROCESSING state
  await repo.lockRowsValidate(transactionalData);
  await this.publish(checkout_id);
  await wait(PROCESSING_VISIBILITY_DELAY_MS);

  // We SUSPEND here --- wait for PIN signal with a timeout
  const pinResult = await this.waitForPin(checkout_id, PIN_TIMEOUT_MS);

  // Check for pinResult returned message and publish events
  if (pinResult === 'TIMEOUT') {
    await repo.failTransaction(transactionalData, 1037); // DS Timeout
    await this.publish(checkout_id);
    return;
  }

  // Other checks for user cancelled request, wrong PIN, etc.

  // Phase 2: PIN was correct --- finalize
  await repo.finalizeTransaction(transactionalData);
  await this.publish(checkout_id);
}
</code></pre>
<h3>The <code>waitForPin()</code> Function</h3>
<p>The <code>waitForPin()</code> function waits for a PIN verification result using a Redis Pub/Sub channel. It subscribes to a channel tied to the checkout session and resolves when either:</p>
<ul>
<li><p>A message is received (e.g., <code>CORRECT</code>, <code>WRONG_PIN</code>, etc.)</p>
</li>
<li><p>A timeout is reached</p>
</li>
</ul>
<p>It uses a <strong>separate Redis connection</strong> for the subscription, cleans up resources after completion, and ensures the promise <strong>always resolves</strong> and never hangs.</p>
<p>Here's a high-level overview of what the function does:</p>
<pre><code class="language-plaintext">function waitForPin(checkoutId, timeout):
  create Redis subscriber
  subscribe to "pin:&lt;checkoutId&gt;"
  
  start timeout timer:
    on timeout:
      unsubscribe + disconnect
      return "TIMEOUT"
    
    on message received:
      cancel timer
      unsubscribe + disconnect
      return message
</code></pre>
<hr />
<h2>The API Layer: Handling PIN Submission</h2>
<p>Then in our API layer, we create a new dynamic route using the endpoint: <code>/stkpush/pin/:checkout_id</code></p>
<p>The endpoint performs these actions:</p>
<h3>1. Fetches the Transaction to Get the Phone Number</h3>
<pre><code class="language-typescript">const txResponse = await service.getTransactionByCheckoutId(checkout_id);
const tx = txResponse.rows[0];
</code></pre>
<h3>2. Validates the PIN Against the Users Table</h3>
<pre><code class="language-typescript">const userResult = await service.getUserByMsisdn(tx.phone_number);
const user = userResult.rows[0];

if (user.pin === pin) {
  child.info("PIN submitted. Publishing pin signal");
  await service.publishPinSignal(checkout_id, "CORRECT");
} else {
  child.error("User submitted wrong PIN");
  await service.publishPinSignal(checkout_id, "WRONG_PIN");
}
</code></pre>
<h3>3. Signal the Waiting Worker</h3>
<p>Note that the channel has to be the same:</p>
<pre><code class="language-typescript">async publishPinSignal(checkout_id: string, signal: string) {
  await redisClient.publish(`pin:${checkout_id}`, signal);
}
</code></pre>
<h3>Handling Cancellations</h3>
<p>To handle the case where the user cancels the PIN prompt (simulating how a real user might cancel the STK Push prompt), we make a new endpoint that just publishes the <code>CANCELLED</code> signal:</p>
<pre><code class="language-typescript">stkRoute.post(
  "/cancel/:checkout_id",
  asyncHandler(cancelTransaction)
);

child.info("Request cancelled. Publishing CANCELLED signal");
await service.publishPinSignal(checkout_id, "CANCELLED");
</code></pre>
<hr />
<h2>How That Solves Our Problem</h2>
<p>With that new flow, our new high-level architecture looks like this:</p>
<pre><code class="language-plaintext">Worker: Phase1 → waitForPin() ← blocking on Redis SUB
                      ↓
API: POST /pin → publish('CORRECT') ──→ worker unblocks → Phase2
     or publish('CANCELLED') ──→ worker unblocks → FAILED

Timeout: after N seconds → 'TIMEOUT' ──→ worker unblocks → FAILED (1037)
</code></pre>
<p>The two services coordinate through <strong>Redis</strong>, which is already our shared backbone in the system. The Worker never holds a DB lock while waiting. It released the lock at the end of Phase 1, which is still the pattern our two-phase design was meant to support.</p>
<h3>Important Detail: BullMQ Job Timeout</h3>
<p>One important detail that came up here was <strong>BullMQ job timeout</strong>. BullMQ has a built-in mechanism that can fail a job if it takes too long without "checking in." If the code logic pauses for a long time, the job may be marked as <strong>stalled</strong>, which can lead to it being retried or failed.</p>
<p><strong>How it works:</strong></p>
<ol>
<li><p>When a worker picks up a job, it places a "lock" on it for a specific duration (default is 30 seconds)</p>
</li>
<li><p><strong>Automatic Renewal:</strong> Under normal conditions, BullMQ automatically renews this lock in the background while code is running</p>
</li>
<li><p><strong>The Problem:</strong> If the code logic is CPU-intensive and blocks the Node.js event loop for too long, the worker cannot renew the lock</p>
</li>
<li><p><strong>Result:</strong> BullMQ assumes the worker has crashed. The job is then marked as stalled and moved back to the "waiting" state to be processed again (or moved to "failed" if it exceeds the <code>maxStalledCount</code>)</p>
</li>
</ol>
<p>To ensure our long-running job runs safely, I adjusted the <code>lockDuration</code> with a timeout longer than our STK PIN timeout. Otherwise, BullMQ would kill the job mid-wait.</p>
<p>This design maps cleanly to our spec's "Stage 4: Simulation Interaction" section and keeps the two-phase transaction integrity intact. <strong>The Worker suspends without holding any locks, the API handles the user interaction, and Redis is the thin coordination layer between them.</strong></p>
<hr />
<h2>The React Frontend: The Virtual Smartphone</h2>
<p>After setting up everything on the backend, it was time to integrate that to our frontend. What we're aiming for: when a user clicks "Initiate STK Push," the request is sent to our Ingestion API, processed by the Worker, and after Phase 1 runs and the transaction moves to <code>PROCESSING</code>, the UI should react and show the Virtual Smartphone for PIN entry.</p>
<h3>The PinModal Component</h3>
<p>To do that, I created a new functional component called <code>PinModal</code> that takes in <code>PinModalProps</code>:</p>
<ul>
<li><p><code>checkoutId</code> — Represents a unique identifier for each transaction</p>
</li>
<li><p><code>amount</code> — Represents the transaction amount</p>
</li>
<li><p><code>onClose()</code> — Function that handles closing and opening of the smartphone modal</p>
</li>
</ul>
<pre><code class="language-typescript">const PinModal = ({ checkoutId, amount, onClose }: PinModalProps) =&gt; {}
</code></pre>
<h3>Handling PIN Submit</h3>
<p>On PIN submit, the <code>handleSubmit()</code> function runs, sending a request to the endpoint we set up earlier:</p>
<pre><code class="language-typescript">const handleSubmit = async () =&gt; {
  if (pin.length !== 4) return;

  setIsSubmitting(true);
  setHasError(false);

  try {
    const response = await fetch(getApiUrl(`/stkpush/pin/${checkoutId}`), {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ pin }),
    });

    // Handle onClose and catch errors
  }
};
</code></pre>
<h3>The Styling Challenge</h3>
<p>From there, all that remained was the styling. As expected, it was quite difficult styling the component to make it look and feel like an actual smartphone. I utilized CSS and JavaScript for this final polished look:</p>
<img alt="Virtual Smartphone UI showing STK Push PIN entry screen" style="display:block;margin:0 auto" />

<hr />
<h2>Auto-Approve: The Fast Path</h2>
<p>The final consideration is to enable <strong>auto-approve</strong> if the developer wants that fast and direct flow. If the auto-approve toggle on the dashboard is on, the dashboard automatically sends the default PIN to the backend without triggering the Virtual Smartphone modal.</p>
<p>I utilized a custom React hook:</p>
<pre><code class="language-typescript">export const useAutoApprovePin = ({
  autoApprove,
  pendingPinTx,
  setPendingPinTx,
}: UseAutoApprovePinParams) =&gt; {
  // Only run when auto-approve is enabled and a transaction exists
  if (!autoApprove || !pendingPinTx) return;

  // Send PIN automatically
  await fetch(`/stkpush/pin/${pendingPinTx.checkout_id}`, {
    method: "POST",
    body: JSON.stringify({ pin: "1234" }),
  });

  // Clear state after submission
  setPendingPinTx(null);
};
</code></pre>
<p>That React hook automatically submits a PIN when a transaction is pending and auto-approval is enabled. When triggered, it sends a request to the backend with a predefined PIN and clears the pending transaction state afterwards.</p>
<p>The high-level overview:</p>
<pre><code class="language-plaintext">on change of (autoApprove, pendingTransaction):
  if autoApprove is false OR no pending transaction:
    do nothing
  otherwise:
    send POST request with PIN to backend endpoint
    if successful:
      clear pending transaction
    if error:
      log error
</code></pre>
<p>Otherwise, the UI renders the Virtual Smartphone:</p>
<pre><code class="language-typescript">{!autoApprove &amp;&amp; pendingPinTx &amp;&amp; (
  &lt;PinModal
    checkoutId={pendingPinTx.checkout_id}
    amount={pendingPinTx.amount}
    onClose={() =&gt; setPendingPinTx(null)}
  /&gt;
)}
</code></pre>
<hr />
<h2>Final Insights: The Before and After</h2>
<h3>The Starting Point (Before)</h3>
<p>Before we started, the transaction flow in <code>apps/worker</code> was "synchronous" from the Worker's perspective. It would:</p>
<ol>
<li><p><strong>Phase 1:</strong> Check if the user has enough money and lock the rows in the database</p>
</li>
<li><p><strong>Phase 2:</strong> Immediately deduct the money and credit the merchant</p>
</li>
</ol>
<p><strong>The Problem:</strong> Real-world mobile money (like M-Pesa) doesn't work this way. When you initiate an STK Push, the system suspends the process while waiting for the user to physically type their PIN into their phone. Our system was missing this <strong>"human-in-the-loop"</strong> pause.</p>
<h3>Summary of the Final Flow</h3>
<ol>
<li><p><strong>API:</strong> Receives payment request → Queues job in BullMQ</p>
</li>
<li><p><strong>Worker:</strong> Starts Job → Runs Phase 1 (DB check) → Pauses (Starts Redis Subscriber)</p>
</li>
<li><p><strong>UI:</strong> Sees "PROCESSING" status via SSE → Pops up the PIN Modal</p>
</li>
<li><p><strong>User:</strong> Enters PIN → UI hits API <code>/stkpush/pin</code></p>
</li>
<li><p><strong>API:</strong> Publishes "CORRECT" to Redis</p>
</li>
<li><p><strong>Worker:</strong> Hears Redis signal → Resumes → Runs Phase 2 (Move money) → Job Complete</p>
</li>
</ol>
<hr />
<h2>What's Next: The Onboarding Wizard (Week 15)</h2>
<p>With that architecture, we've officially hit the <strong>Week 14 KPI</strong>. Next week, we build the <strong>Registration Flow</strong> — the "First Run" onboarding wizard.</p>
<p>The main goal of Sim-Pesa is to <strong>reduce friction</strong> for developers and other users testing their payment workflows. And to do that, we have to ensure that when the developer clones the repo and runs <code>docker compose up</code>, <strong>everything just works</strong> with zero or minimal manual configurations.</p>
<p>That's what Week 15 will be about. We're in the final stretch — only 2 weeks left.</p>
<p>Stay locked in for the next episode.</p>
<hr />
<p><em>Tags: Redis Pub/Sub · Event-Driven Architecture · React · Virtual Smartphone · STK Push Simulation · BuildInPublic</em></p>
]]></content:encoded></item></channel></rss>