Skip to content

feat: M-Pesa (Safaricom Daraja) payment method for Spree - #1

Merged
willymwai merged 24 commits into
mainfrom
feat/mpesa-payment
Jun 30, 2026
Merged

feat: M-Pesa (Safaricom Daraja) payment method for Spree#1
willymwai merged 24 commits into
mainfrom
feat/mpesa-payment

Conversation

@Elias-3817

@Elias-3817 Elias-3817 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What this adds

A Spree payment method for Lipa na M-Pesa (STK Push) via the Safaricom Daraja API, built to work with both the Rails storefront and a headless checkout.

This is a full Spree extension, so the diff is ~90 files, but most of it is generated scaffolding

  • 53 files are the generated spec/dummy/ Rails test app (standard Spree extension harness),
  • The actual code is 12 files:
    • app/models/spree/payment_method/mpesa.rb, the payment method (authorize via STK push)
    • app/models/spree/mpesa_source.rb, the payment source
    • app/controllers/spree/mpesa_callbacks_controller.rb, Daraja callback handler
    • app/serializers/spree/api/v2/{storefront,platform}/mpesa_source_serializer.rb
    • app/views/spree/admin/payment_methods/configuration_guides/_mpesa.html.erb, admin setup guide
    • app/views/spree/checkout/payment/_mpesa.html.erb, Rails storefront field
    • lib/spree_mpesa/{engine,configuration,daraja_client}.rb + lib/spree_mpesa.rb
    • lib/generators/spree_mpesa/install/install_generator.rb
  • 10 specs under spec/ (non-dummy) cover the above.

How it works

  • Customer selects M-Pesa at checkout and provides their phone number.
  • Authorization runs through Spree's real gateway invocation, so the payment stays pending while the STK push is sent. The order completes only once the Daraja callback confirms payment, not optimistically on send.

Verification

  • Full gem spec suite green; RuboCop clean; ~95% coverage.
  • Daraja API stubbed at the HTTP boundary (WebMock), no live calls in tests.
  • STK push verified end-to-end against a live Daraja shortcode through the headless storefront.
image image

Copilot AI review requested due to automatic review settings June 29, 2026 17:17
@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

This PR introduces a Spree Commerce extension that adds a Safaricom Daraja (M-Pesa STK Push) payment method, including the gateway client, source model, callback handling, admin configuration guidance, and an automated test harness (with a dummy app) to validate the full Spree payment pipeline.

Changes:

  • Add Spree::PaymentMethod::Mpesa, Spree::MpesaSource, Daraja HTTP client, and callback controller/route for payment confirmation.
  • Add admin/storefront UI pieces (checkout phone field, admin configuration guide) plus API serializers.
  • Add RSpec coverage (unit + request + integration) and a Rails dummy app with CI workflow.

Reviewed changes

Copilot reviewed 80 out of 90 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
spree_mpesa.gemspec Defines gem metadata and runtime/dev dependencies.
Gemfile Bundler entrypoint using the gemspec.
Rakefile Adds RSpec rake task and Spree extension rake hooks.
README.md Documents setup, payment flow, callback behavior, and testing.
LICENSE Adds MIT license.
.ruby-version Pins Ruby version for development/CI.
.rubocop.yml Configures RuboCop rules for the repo.
.rspec Configures RSpec defaults.
.gitignore Ignores build artifacts and dummy app runtime files.
.github/workflows/ci.yml Runs RuboCop + RSpec on GitHub Actions.
lib/spree_mpesa.rb Gem entrypoint requiring core components.
lib/spree_mpesa/engine.rb Rails engine registration and payment method registration with Spree.
lib/spree_mpesa/daraja_client.rb Daraja OAuth + STK Push + query client implementation.
lib/spree_mpesa/configuration.rb Default configuration/preferences scaffolding.
lib/generators/spree_mpesa/install/install_generator.rb Install generator to copy migrations and optionally run them.
db/migrate/20260622120000_create_spree_mpesa_sources.rb Creates spree_mpesa_sources table and indexes.
config/routes.rb Adds Spree engine route for Daraja callback.
config/initializers/spree_mpesa.rb Extends Spree permitted source params for M-Pesa.
config/initializers/filter_parameter_logging.rb Filters M-Pesa credentials and identifiers from logs.
config/initializers/rack_attack.rb Optional rate limiting for the callback endpoint.
app/models/spree/payment_method/mpesa.rb Implements Spree gateway hooks (authorize/capture/void) and STK push initiation.
app/models/spree/mpesa_source.rb Defines M-Pesa source model, normalization, and gateway profile accessors.
app/controllers/spree/mpesa_callbacks_controller.rb Receives callbacks and confirms/settles payments via query API.
app/views/spree/checkout/payment/_mpesa.html.erb Storefront checkout phone-number field for M-Pesa.
app/views/spree/admin/payment_methods/configuration_guides/_mpesa.html.erb Admin UI configuration guide for merchants.
app/serializers/spree/api/v2/storefront/mpesa_source_serializer.rb Storefront serialization for M-Pesa source.
app/serializers/spree/api/v2/platform/mpesa_source_serializer.rb Platform serialization for M-Pesa source (more fields).
spec/spec_helper.rb Base RSpec configuration.
spec/rails_helper.rb Rails + Spree dev tools + WebMock test setup.
spec/support/disable_spree_events.rb Disables Spree events for deterministic test runs.
spec/factories/spree_mpesa.rb Factories for mpesa payment method and source.
spec/spree_mpesa/daraja_client_spec.rb Unit tests for Daraja client request/response behavior.
spec/models/spree/payment_method/mpesa_spec.rb Gateway behavior specs for authorize/supports/test_mode.
spec/models/spree/mpesa_source_spec.rb Source normalization and status predicate specs.
spec/requests/spree/mpesa_callbacks_spec.rb Request specs validating callback settlement behavior.
spec/integration/mpesa_payment_event_spec.rb Integration coverage for serialization/event behavior.
spec/integration/mpesa_headless_payment_spec.rb Integration coverage for headless payments via metadata.
spec/dummy/.ruby-version Ruby version pin for the dummy app.
spec/dummy/Gemfile Dummy app Gemfile (Rails + sqlite + dev/test tooling).
spec/dummy/README.md Dummy app placeholder README.
spec/dummy/Rakefile Loads dummy app rake tasks.
spec/dummy/config.ru Rack boot file for dummy app.
spec/dummy/config/boot.rb Bundler bootstrapping for dummy app.
spec/dummy/config/application.rb Dummy Rails application configuration and requires the extension.
spec/dummy/config/environment.rb Initializes the dummy Rails app.
spec/dummy/config/routes.rb Mounts Spree in the dummy app.
spec/dummy/config/database.yml sqlite DB config for dummy app environments.
spec/dummy/config/storage.yml ActiveStorage config for dummy app.
spec/dummy/config/cable.yml ActionCable config for dummy app.
spec/dummy/config/ci.rb CI script definition for dummy app.
spec/dummy/config/puma.rb Puma configuration for dummy app.
spec/dummy/config/credentials.yml.enc Dummy app encrypted credentials blob.
spec/dummy/config/master.key Dummy app Rails credentials master key.
spec/dummy/config/locales/en.yml Dummy app locale scaffold.
spec/dummy/config/initializers/spree.rb Dummy app Spree initializer scaffold.
spec/dummy/config/initializers/inflections.rb Dummy app inflections scaffold.
spec/dummy/config/initializers/filter_parameter_logging.rb Dummy app parameter filtering scaffold.
spec/dummy/config/initializers/content_security_policy.rb Dummy app CSP scaffold.
spec/dummy/config/environments/development.rb Dummy app development env config.
spec/dummy/config/environments/test.rb Dummy app test env config.
spec/dummy/config/environments/production.rb Dummy app production env config.
spec/dummy/db/seeds.rb Loads Spree seeds in dummy app.
spec/dummy/bin/setup Dummy app setup script.
spec/dummy/bin/rails Dummy app rails wrapper.
spec/dummy/bin/rake Dummy app rake wrapper.
spec/dummy/bin/dev Dummy app dev launcher.
spec/dummy/bin/ci Dummy app CI runner.
spec/dummy/public/robots.txt Dummy app robots file.
spec/dummy/public/icon.svg Dummy app icon.
spec/dummy/public/400.html Dummy app error page.
spec/dummy/public/404.html Dummy app error page.
spec/dummy/public/406-unsupported-browser.html Dummy app error page.
spec/dummy/public/422.html Dummy app error page.
spec/dummy/public/500.html Dummy app error page.
spec/dummy/app/controllers/application_controller.rb Dummy app controller base (browser gating).
spec/dummy/lib/spree/authentication_helpers.rb Dummy auth helper stubs for Spree UI.
spec/dummy/app/assets/stylesheets/application.css Dummy app stylesheet scaffold.
spec/dummy/app/assets/config/manifest.js Dummy app asset manifest scaffold.
spec/dummy/app/helpers/application_helper.rb Dummy app helper scaffold.
spec/dummy/app/jobs/application_job.rb Dummy app job scaffold.
spec/dummy/app/mailers/application_mailer.rb Dummy app mailer scaffold.
spec/dummy/app/models/application_record.rb Dummy app AR base.
spec/dummy/app/models/spree/dummy_model.rb Dummy Spree model scaffold.
spec/dummy/app/views/layouts/application.html.erb Dummy app layout scaffold.
spec/dummy/app/views/layouts/mailer.html.erb Dummy app mailer layout scaffold.
spec/dummy/app/views/layouts/mailer.text.erb Dummy app mailer layout scaffold.
spec/dummy/app/views/pwa/manifest.json.erb Dummy app PWA manifest scaffold.
spec/dummy/app/views/pwa/service-worker.js Dummy app service worker scaffold.

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

Comment thread config/initializers/spree_mpesa.rb Outdated
Comment thread app/models/spree/payment_method/mpesa.rb Outdated
Comment thread app/controllers/spree/mpesa_callbacks_controller.rb Outdated
Comment thread spec/dummy/config/master.key Outdated
Comment thread spec/dummy/config/credentials.yml.enc Outdated
@Elias-3817
Elias-3817 force-pushed the feat/mpesa-payment branch from 5ea50d9 to 48688d3 Compare June 30, 2026 03:02
Add the gem identity (gemspec), the Rails-engine plug that auto-registers the M-Pesa payment method, the default configuration holder, and the install generator. The gem version is a literal in the gemspec; releases are cut via git tags. No payment behaviour yet.
Wrap OAuth token retrieval, STK Push initiation, and STK Push status query behind one HTTParty client, with sandbox/production base URLs and the base64 Password (shortcode+passkey+timestamp) construction.
Bridge Spree checkout to the Daraja client: authorize fires an STK push and records the checkout_request_id; MpesaSource stores the phone (with 254 normalization) and live status. Return Spree::PaymentResponse (Spree 5.5 native; ActiveMerchant is no longer present). MpesaSource also answers gateway_customer_profile_id/gateway_payment_profile_id (nil) so Spree's payment-source serialization does not raise on commit.
Receive Safaricom's STK result at POST /mpesa/callback and complete the order only when an independent STK status-query confirms paid AND the amount matches; otherwise leave it pending. Inherit ApplicationController so the public callback is not blocked by Spree's admin guard.
Phone-entry partial for checkout, a plain-language admin guide for obtaining Daraja keys, storefront/platform JSON serializers (storefront hides internal IDs), and the permitted source attributes so the phone field saves.
Filter credentials and phone numbers out of logs; throttle the public callback endpoint to 60 req/min/IP (production / opt-in).
Real-record RSpec suite (no ORM mocks; Safaricom stubbed via WebMock) covering the Daraja client, phone normalization, authorize, the strict callback, and payment.created serialization of the M-Pesa source. Commit the dummy app and db/schema.rb as the test DB source of truth. 32 examples, 0 failures.
Load the committed schema.rb via db:test:prepare (avoids Spree #1580 migration copying), then lint and test on a clean Ruby 4.0.1 env.
The Spree v3 Store API direct-payment endpoint accepts only
payment_method_id, amount and metadata - it cannot carry a payment
source or source_attributes. A headless storefront therefore cannot
attach an MpesaSource at payment-create time, and the default
source_required? validation rejected the payment outright.

Make M-Pesa source-optional and resolve the customer phone from the
payment metadata bag (metadata['phone']) before falling back to the
order bill/ship address. authorize() now builds the MpesaSource on the
fly from whichever phone it finds, so the headless checkout can pass an
explicit M-Pesa number and still trigger the STK push.

Adds an integration spec covering the three resolution paths
(sourceless save, metadata phone, address fallback) with the Daraja
OAuth and STK endpoints stubbed.
When Spree processes a payment it calls authorize(amount, source,
gateway_options) with no :originator. Two assumptions broke the headless flow:

- The payment was read from options[:originator], which Spree never sets. The
  payment is exposed as options[:payment_id], and that value is the payment
  *number*, resolve it via Spree::Payment.find_by(number:).
- amount arrives in cents, so payment.amount = amount set it to 1000 for KSh10
  and failed the order-total validation. Stop overwriting the already-correct
  payment.amount and pass it (major units) to the STK push.

Rewrites the headless spec to drive payment.authorize! through the real Spree
pipeline (real gateway_options; Daraja stubbed at the HTTP boundary) so these
paths are covered as they actually execute.
Replace the terse configuration notes with a task-oriented guide: a one-time
setup context line, a requirements list, numbered Go Live steps, and a support
callout. Shortcode-agnostic (Paybill or Till) so it covers future Till support.
Ships app/assets/images/payment_icons/mpesa.svg so Spree's
payment_icon_name (mpesa) resolves to the Lipa na M-Pesa logo in the
admin and Rails storefront instead of the storecredit fallback. The
viewBox is tightened to the wordmark bounds so it renders cleanly at
small icon sizes.
@Elias-3817
Elias-3817 force-pushed the feat/mpesa-payment branch from 48688d3 to 9618633 Compare June 30, 2026 03:07
Copilot AI review requested due to automatic review settings June 30, 2026 03:07

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 80 out of 91 changed files in this pull request and generated 6 comments.

Comment thread config/initializers/rack_attack.rb
Comment thread app/models/spree/payment_method/mpesa.rb Outdated
Comment thread app/controllers/spree/mpesa_callbacks_controller.rb
Comment thread lib/spree_mpesa/daraja_client.rb
Comment thread spec/dummy/config/master.key Outdated
Comment thread spec/dummy/config/credentials.yml.enc Outdated
The initializer pushed merchant_request_id, checkout_request_id,
mpesa_receipt_number and status into Spree's permitted source attributes,
letting a checkout request mass-assign the source status to completed and
bypass payment. Only the customer-supplied phone is permitted now; the
gateway identifiers and status are set exclusively by the STK push and the
Safaricom callback. Adds a guard spec.
ensure_source reused one source per phone via find_or_initialize_by, so a
repeat customer's checkout overwrote the prior payment's checkout_request_id
and amount, leaving the earlier payment unresolvable and letting the callback
complete the wrong payment. It now creates a fresh source per payment and
drops the leading source-phone term that, as a truthy empty string,
suppressed the address fallback. Adds per-payment and fallback specs.
The callback inherited the host ApplicationController, whose before_actions
(browser gating, auth, locale redirects) can block Safaricom's server-to-server
request. It now inherits ActionController::Base with CSRF disabled for confirm.
… runs

The callback throttle never ran because the middleware was never added to the
stack. Insert it from an engine initializer (the stack is frozen by
after_initialize), gated to production or ENABLE_RACK_ATTACK.

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 79 out of 90 changed files in this pull request and generated 3 comments.

Comment thread db/migrate/20260622120000_create_spree_mpesa_sources.rb
Comment thread config/initializers/rack_attack.rb
Comment thread spree_mpesa.gemspec
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 30, 2026 04:40

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 79 out of 90 changed files in this pull request and generated 11 comments.

Comment thread lib/spree_mpesa/daraja_client.rb
Comment thread lib/spree_mpesa/daraja_client.rb Outdated
Comment thread lib/spree_mpesa/daraja_client.rb Outdated
Comment thread app/models/spree/payment_method/mpesa.rb Outdated
Comment thread app/models/spree/payment_method/mpesa.rb
Comment thread app/models/spree/payment_method/mpesa.rb Outdated
Comment thread app/models/spree/payment_method/mpesa.rb
Comment thread spec/dummy/.ruby-version Outdated
Comment thread app/models/spree/mpesa_source.rb
Comment thread app/models/spree/mpesa_source.rb Outdated
…scues

M-Pesa STK pushes cannot be cancelled via Daraja, so voiding an in-flight
payment marked it void while the customer still paid; the later callback
then could not complete it (invalid transition swallowed), losing the
payment. Voiding is now refused (can_void? false, void returns failure,
actions lists only capture).

initiate_stk_push now fails fast when no callback URL can be resolved
instead of sending Daraja a blank one. The Daraja client rescues only
transport/parse errors and returns a generic message (no logging), and
authorize rescues only ActiveRecord errors, so unexpected bugs surface to
the error tracker instead of being swallowed.
Copilot AI review requested due to automatic review settings June 30, 2026 05:48

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 79 out of 90 changed files in this pull request and generated 3 comments.

Comment thread app/models/spree/payment_method/mpesa.rb Outdated
Comment thread lib/spree_mpesa/engine.rb Outdated
Comment thread spec/dummy/Gemfile
…e Rack::Attack

configured_callback_base built the fallback callback URL from
default_url_options[:host] but dropped [:port]. On any non-standard port
(dev/staging on 3000) this produced https://host/mpesa/callback, so Daraja
would POST to the wrong URL. It now appends the port unless it is the default
80 or 443.

The rack_attack initializer also added Rack::Attack unconditionally. If the
host app already mounts Rack::Attack, the callback request ran the throttle
chain twice. It now skips insertion when the middleware is already present.

Adds specs covering the ported, default-port, and host-less branches of
configured_callback_base.

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 79 out of 90 changed files in this pull request and generated 3 comments.

Comment thread app/models/spree/payment_method/mpesa.rb Outdated
Comment thread app/controllers/spree/mpesa_callbacks_controller.rb Outdated
Comment thread app/controllers/spree/mpesa_callbacks_controller.rb Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 30, 2026 06:42

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 79 out of 90 changed files in this pull request and generated 5 comments.

Comment thread config/initializers/rack_attack.rb
Comment thread spree_mpesa.gemspec
Comment thread app/controllers/spree/mpesa_callbacks_controller.rb
Comment thread app/controllers/spree/mpesa_callbacks_controller.rb
Comment thread app/models/spree/payment_method/mpesa.rb Outdated
…acks

source.update(...) returns false on failure and keeps executing, so a DB write
failure in the STK-push path returned a success PaymentResponse with no IDs
stored, and in the callback path returned 200 OK to Safaricom who then stopped
retrying. Switching to update! raises ActiveRecord::RecordInvalid on failure:
in authorize it routes through the existing ActiveRecordError rescue into a
failure response; in the callbacks controller it propagates to a 500 which
causes Safaricom to retry the callback until the write succeeds."
@willymwai
willymwai merged commit 82c75dd into main Jun 30, 2026
1 check passed
@willymwai
willymwai deleted the feat/mpesa-payment branch June 30, 2026 07:00
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