feat: M-Pesa (Safaricom Daraja) payment method for Spree - #1
Conversation
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
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.
5ea50d9 to
48688d3
Compare
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.
48688d3 to
9618633
Compare
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.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…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.
This reverts commit 195c8cb.
…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.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…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."
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
spec/dummy/Rails test app (standard Spree extension harness),app/models/spree/payment_method/mpesa.rb, the payment method (authorize via STK push)app/models/spree/mpesa_source.rb, the payment sourceapp/controllers/spree/mpesa_callbacks_controller.rb, Daraja callback handlerapp/serializers/spree/api/v2/{storefront,platform}/mpesa_source_serializer.rbapp/views/spree/admin/payment_methods/configuration_guides/_mpesa.html.erb, admin setup guideapp/views/spree/checkout/payment/_mpesa.html.erb, Rails storefront fieldlib/spree_mpesa/{engine,configuration,daraja_client}.rb+lib/spree_mpesa.rblib/generators/spree_mpesa/install/install_generator.rbspec/(non-dummy) cover the above.How it works
Verification