feat(checkout): M-Pesa (STK push) payment for the headless storefront#8
Conversation
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 reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
There was a problem hiding this comment.
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.
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.
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.
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.