Skip to content

feat(checkout): M-Pesa (STK push) payment for the headless storefront#8

Merged
willymwai merged 6 commits into
devfrom
feat/mpesa-headless-checkout
Jun 30, 2026
Merged

feat(checkout): M-Pesa (STK push) payment for the headless storefront#8
willymwai merged 6 commits into
devfrom
feat/mpesa-headless-checkout

Conversation

@Elias-3817

Copy link
Copy Markdown

Objective

Add M-Pesa (Safaricom STK push) as a checkout option in the headless storefront so Kenyan merchants can accept Lipa na M-Pesa.

Background

The Spree v3 Store API direct-payment endpoint accepts only a payment method, amount, and metadata, it cannot carry a payment source. M-Pesa needs the customer's phone number, and confirmation is asynchronous (the customer approves an STK prompt on their phone). The storefront had no way to collect the number or to wait for confirmation, so M-Pesa could not be offered.

What this delivers

A dedicated "M-Pesa phone number" field shown when the M-Pesa method is selected. The number is normalized to Safaricom format and sent in the payment metadata bag, which the backend reads to trigger the STK push.
An await-confirmation step. Because M-Pesa settles asynchronously, the M-Pesa checkout routes to a "Check your phone" screen that polls the order's payment state and only advances to the confirmation page once payment is confirmed; it shows a failure or timeout state otherwise. Other payment methods are unchanged.
Also included (separate commit)
A tenant-resolution fix: in development the tenant config resolved outside the cache boundary, so a current-time read threw under Next 16 Cache Components during prefetch and broke server-side data fetching (blank listings, 404 product pages). Dev now uses the same cached resolver as production.

The v3 Store API direct-payment endpoint cannot carry a payment source, so
M-Pesa was unsupported. Add a dedicated M-Pesa phone-number field for the
M-Pesa method and send the number in the payment metadata bag, which the
backend reads to build the source and trigger the STK push.

M-Pesa confirmation is asynchronous, so route the M-Pesa checkout to a new
awaiting-payment screen that polls the order's payment state and only
advances to the thank-you page once payment is confirmed, instead of the
moment the STK is sent. Adds getOrderPaymentStatus and createDirectPayment
metadata test coverage.
In development the tenant lookup bypassed the \"use cache\" wrapper that
production uses, so buildTenantConfigFromRecord ran new Date() in a bare
server component. Under Next 16 Cache Components that throws during runtime
prefetch, breaking every server-side fetch (markets, products), blank
listings and 404 product pages. Always route resolveTenantConfigByHost
through the cached resolver so the timestamp is read inside a cache
boundary, matching production.
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds M‑Pesa (Safaricom STK push) support to the headless storefront checkout by collecting a phone number for the M‑Pesa payment method, sending it via Spree’s direct-payment metadata, and introducing an “awaiting payment” screen to handle asynchronous confirmation. It also adjusts tenant resolution in development to use the same cached resolver path as production to avoid Next 16 cache-boundary issues.

Changes:

  • Introduce M‑Pesa utilities (method detection + Kenyan phone normalization/validation) and render an M‑Pesa phone field in the checkout payment section.
  • Extend direct payments to accept metadata, add getOrderPaymentStatus, and add an /awaiting-payment/[id] polling route for async settlement.
  • Remove the development-only tenant-cache bypass so dev uses the cached resolver flow.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/lib/utils/mpesa.ts Adds M‑Pesa method detection and Kenyan phone normalization/validation helpers.
src/lib/tenant/olitt.ts Routes dev tenant resolution through the cached resolver (aligns with production cache boundary).
src/lib/data/payment.ts Adds metadata support for direct payments and introduces getOrderPaymentStatus helper for polling.
src/lib/data/tests/payment.test.ts Adds unit tests covering direct payment metadata and order payment-status polling logic.
src/components/checkout/PaymentSection.tsx Adds M‑Pesa phone input UI and forwards normalized phone via direct-payment metadata; flags direct payments that require confirmation.
src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx Redirects M‑Pesa direct-pay flow to the awaiting-payment route.
src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx New client page that polls payment status and redirects on completion/failure/timeout.
messages/{en,fr,es,de,pl}.json Adds translated strings for M‑Pesa phone field and awaiting-payment UX.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx Outdated
Comment thread src/lib/utils/mpesa.ts
Comment thread src/components/checkout/PaymentSection.tsx Outdated
Renders the Lipa na M-Pesa wordmark beside the M-Pesa payment option in
both the multi-method and single-method header rows, gated by
isMpesaMethod. Uses next/image with the unoptimized prop because Next
rejects SVG optimization (HTTP 400) and a vector icon gains nothing from
it; the asset is served from public/payment-icons/mpesa.svg with its
viewBox tightened to the wordmark bounds so it renders cleanly at small
icon sizes.
The awaiting-payment poll awaited getOrderPaymentStatus with no error
handling, so a single transient failure threw out of the loop and left the
customer stuck on the spinner forever (the timeout check lives inside the
dead loop). The fetch is now wrapped and a failure is treated as pending so
polling continues until completion or timeout.

normalizeKenyanPhone rejected numbers that carry both the country code and
the trunk 0 (e.g. \"+254 0712 345 678\"), returning an empty string and
blocking a valid Safaricom number. It now strips the redundant 0.

The M-Pesa phone input set error styling but not aria-invalid, so assistive
tech never announced the field as errored; it now reflects the error state.

Adds unit coverage for the M-Pesa phone helpers, including the new
country-code-plus-trunk-0 case.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 2 comments.

Comment thread src/lib/data/payment.ts
Comment thread src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx Outdated
On poll timeout the screen sent the customer to /order-placed, a success
page that says 'Thanks for your order' and 'a confirmation email has been
sent'. For an unconfirmed M-Pesa payment that is misleading, since the
payment may still be pending or may never arrive.

The timeout screen now states the truth: the order is placed and the M-Pesa
payment can take a few minutes to confirm. The button is changed from a
misleading 'Check order status' link to the success page into a plain
'Continue Shopping' link to the store home, which works for both guest and
logged-in shoppers (the account order page is login-gated and would trap
guests). Copy avoids promising a payment confirmation email because no such
email is sent. Updated across all five locales."
The M-Pesa payment block and the awaiting-payment timeout screen used
hand-rolled markup instead of the shared design system. The phone input
carried a raw label, a hand-tuned hint paragraph, and an error paragraph
in text-red-700, which is off the design palette. The timeout screen used
a raw button styled with inline gray classes.

Replace these with the existing base components so the checkout matches
the convention already used by the account forms:
- label, hint, and error now use Field, FieldLabel, FieldDescription, and
  FieldError. FieldError renders text-destructive with role="alert", so
  the error colour comes from the design tokens and is announced to
  assistive tech.
- the timeout action now uses Button.
@willymwai willymwai merged commit cbdb276 into dev Jun 30, 2026
@willymwai willymwai deleted the feat/mpesa-headless-checkout branch June 30, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants