mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-01-11 18:26:50 +00:00
Merge pull request #13777 from rioug/13481-webhook-payment
Payment status change webhook
This commit is contained in:
16
app/components/webhook_endpoint_form_component.rb
Normal file
16
app/components/webhook_endpoint_form_component.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookEndpointFormComponent < ViewComponent::Base
|
||||
def initialize(webhooks:, webhook_type:)
|
||||
@webhooks = webhooks
|
||||
@webhook_type = webhook_type
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :webhooks, :webhook_type
|
||||
|
||||
def is_webhook_payment_status?
|
||||
webhook_type == "payment_status_changed"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
-# Create new endpoints
|
||||
- if webhooks.empty? # Only one allowed for now.
|
||||
%tr
|
||||
%td= t("components.webhook_endpoint_form.event_types.#{webhook_type}")
|
||||
%td
|
||||
= form_with(url: helpers.account_webhook_endpoints_path, id: "#{webhook_type}_webhook_endpoint") do |f|
|
||||
= f.url_field :'webhook_endpoint[url]', id: "#{webhook_type}_webhook_endpoint_url", placeholder: t('components.webhook_endpoint_form.url.create_placeholder'), required: true, size: 64
|
||||
= f.hidden_field :'webhook_endpoint[webhook_type]', id: "#{webhook_type}_webhook_endpoint_webhook_type", value: webhook_type
|
||||
%td.actions
|
||||
= button_tag t(:create), class: 'button primary tiny no-margin', form: "#{webhook_type}_webhook_endpoint"
|
||||
|
||||
-# Existing endpoints
|
||||
- webhooks.each do |webhook_endpoint|
|
||||
%tr
|
||||
%td= t("components.webhook_endpoint_form.event_types.#{webhook_type}")
|
||||
%td= webhook_endpoint.url
|
||||
%td.actions.endpoints-actions
|
||||
- if webhook_endpoint.persisted?
|
||||
= button_to helpers.account_webhook_endpoint_path(webhook_endpoint), method: :delete,
|
||||
class: "tiny alert no-margin",
|
||||
data: { confirm: I18n.t(:are_you_sure) } do
|
||||
= I18n.t(:delete)
|
||||
|
||||
- if is_webhook_payment_status?
|
||||
= form_tag helpers.webhook_endpoint_test_account_path(webhook_endpoint), class: "button_to", 'data-turbo': true do
|
||||
= button_tag type: "submit", class: "tiny alert no-margin", data: { confirm: I18n.t(:are_you_sure) } do
|
||||
= I18n.t("components.webhook_endpoint_form.test_endpoint")
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookEndpointsController < BaseController
|
||||
before_action :load_resource, only: :destroy
|
||||
before_action :load_resource, only: [:destroy, :test]
|
||||
|
||||
def create
|
||||
webhook_endpoint = spree_current_user.webhook_endpoints.new(webhook_endpoint_params)
|
||||
@@ -25,12 +25,30 @@ class WebhookEndpointsController < BaseController
|
||||
redirect_to redirect_path
|
||||
end
|
||||
|
||||
def test
|
||||
at = Time.zone.now
|
||||
test_payload = Payments::WebhookPayload.test_data.to_hash
|
||||
|
||||
WebhookDeliveryJob.perform_later(@webhook_endpoint.url, "payment.completed", test_payload, at:)
|
||||
|
||||
flash[:success] = t(".success")
|
||||
respond_with do |format|
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.update(
|
||||
:flashes, partial: "shared/flashes", locals: { flashes: flash }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_resource
|
||||
@webhook_endpoint = spree_current_user.webhook_endpoints.find(params[:id])
|
||||
end
|
||||
|
||||
def webhook_endpoint_params
|
||||
params.require(:webhook_endpoint).permit(:url)
|
||||
params.require(:webhook_endpoint).permit(:url, :webhook_type)
|
||||
end
|
||||
|
||||
def redirect_path
|
||||
|
||||
@@ -101,6 +101,24 @@ module Spree
|
||||
end
|
||||
|
||||
after_transition to: :completed, do: :set_captured_at
|
||||
after_transition do |payment, transition|
|
||||
# Catch any exceptions to prevent any rollback potentially
|
||||
# preventing payment from going through
|
||||
ActiveSupport::Notifications.instrument(
|
||||
"ofn.payment_transition", payment: payment, event: transition.to
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.fatal "ActiveSupport::Notification.instrument failed params: " \
|
||||
"<event_type:ofn.payment_transition> " \
|
||||
"<payment_id:#{payment.id}> " \
|
||||
"<event:#{transition.to}>"
|
||||
Alert.raise(
|
||||
e,
|
||||
metadata: {
|
||||
event_tye: "ofn.payment_transition", payment_id: payment.id, event: transition.to
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def money
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
|
||||
# Records a webhook url to send notifications to
|
||||
class WebhookEndpoint < ApplicationRecord
|
||||
WEBHOOK_TYPES = %w(order_cycle_opened payment_status_changed).freeze
|
||||
|
||||
validates :url, presence: true
|
||||
validates :webhook_type, presence: true, inclusion: { in: WEBHOOK_TYPES }
|
||||
|
||||
scope :order_cycle_opened, -> { where(webhook_type: "order_cycle_opened") }
|
||||
scope :payment_status, -> { where(webhook_type: "payment_status_changed") }
|
||||
end
|
||||
|
||||
@@ -11,10 +11,12 @@ module OrderCycles
|
||||
.merge(coordinator_name: order_cycle.coordinator.name)
|
||||
|
||||
# Endpoints for coordinator owner
|
||||
webhook_endpoints = order_cycle.coordinator.owner.webhook_endpoints
|
||||
webhook_endpoints = order_cycle.coordinator.owner.webhook_endpoints.order_cycle_opened
|
||||
|
||||
# Plus unique endpoints for distributor owners (ignore duplicates)
|
||||
webhook_endpoints |= order_cycle.distributors.map(&:owner).flat_map(&:webhook_endpoints)
|
||||
webhook_endpoints |= order_cycle.distributors.map(&:owner).flat_map { |owner|
|
||||
owner.webhook_endpoints.order_cycle_opened
|
||||
}
|
||||
|
||||
webhook_endpoints.each do |endpoint|
|
||||
WebhookDeliveryJob.perform_later(endpoint.url, event, webhook_payload, at:)
|
||||
|
||||
13
app/services/payments/status_changed_listener_service.rb
Normal file
13
app/services/payments/status_changed_listener_service.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Called by "ActiveSupport::Notifications" when an "ofn.payment_transition" occurs
|
||||
# Event originate from Spree::Payment event machine
|
||||
#
|
||||
module Payments
|
||||
class StatusChangedListenerService
|
||||
def call(_name, started, _finished, _unique_id, payload)
|
||||
event = "payment.#{payload[:event]}"
|
||||
Payments::WebhookService.create_webhook_job(payment: payload[:payment], event:, at: started)
|
||||
end
|
||||
end
|
||||
end
|
||||
84
app/services/payments/webhook_payload.rb
Normal file
84
app/services/payments/webhook_payload.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Payments
|
||||
class WebhookPayload
|
||||
def initialize(payment:, order:, enterprise:)
|
||||
@payment = payment
|
||||
@order = order
|
||||
@enterprise = enterprise
|
||||
end
|
||||
|
||||
def to_hash
|
||||
{
|
||||
payment: @payment.slice(:updated_at, :amount, :state),
|
||||
enterprise: @enterprise.slice(:abn, :acn, :name)
|
||||
.merge(address: @enterprise.address.slice(:address1, :address2, :city, :zipcode)),
|
||||
order: @order.slice(:total, :currency).merge(line_items: line_items)
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
def self.test_data
|
||||
new(payment: test_payment, order: test_order, enterprise: test_enterprise)
|
||||
end
|
||||
|
||||
def self.test_payment
|
||||
{
|
||||
updated_at: Time.zone.now,
|
||||
amount: 0.00,
|
||||
state: "completed"
|
||||
}
|
||||
end
|
||||
|
||||
def self.test_order
|
||||
order = Spree::Order.new(
|
||||
total: 0.00,
|
||||
currency: "AUD",
|
||||
)
|
||||
|
||||
tax_category = Spree::TaxCategory.new(name: "VAT")
|
||||
product = Spree::Product.new(name: "Test product")
|
||||
Spree::Variant.new(product:, display_name: "")
|
||||
order.line_items << Spree::LineItem.new(
|
||||
quantity: 1,
|
||||
price: 20.00,
|
||||
tax_category:,
|
||||
product:,
|
||||
unit_presentation: "1kg"
|
||||
)
|
||||
|
||||
order
|
||||
end
|
||||
|
||||
def self.test_enterprise
|
||||
enterprise = Enterprise.new(
|
||||
abn: "65797115831",
|
||||
acn: "",
|
||||
name: "TEST Enterprise",
|
||||
)
|
||||
enterprise.address = Spree::Address.new(
|
||||
address1: "1 testing street",
|
||||
address2: "",
|
||||
city: "TestCity",
|
||||
zipcode: "1234"
|
||||
)
|
||||
|
||||
enterprise
|
||||
end
|
||||
|
||||
private_class_method :test_payment, :test_order, :test_enterprise
|
||||
|
||||
private
|
||||
|
||||
def line_items
|
||||
@order.line_items.map do |li|
|
||||
li.slice(:quantity, :price)
|
||||
.merge(
|
||||
tax_category_name: li.tax_category&.name,
|
||||
product_name: li.product.name,
|
||||
name_to_display: li.display_name,
|
||||
unit_to_display: li.unit_presentation
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
30
app/services/payments/webhook_service.rb
Normal file
30
app/services/payments/webhook_service.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Create a webhook payload for a payment status event.
|
||||
# The payload will be delivered asynchronously.
|
||||
|
||||
module Payments
|
||||
class WebhookService
|
||||
def self.create_webhook_job(payment:, event:, at:)
|
||||
order = payment.order
|
||||
payload = WebhookPayload.new(payment:, order:, enterprise: order.distributor).to_hash
|
||||
|
||||
coordinator = payment.order.order_cycle.coordinator
|
||||
webhook_urls(coordinator).each do |url|
|
||||
WebhookDeliveryJob.perform_later(url, event, payload, at:)
|
||||
end
|
||||
end
|
||||
|
||||
def self.webhook_urls(coordinator)
|
||||
# url for coordinator owner
|
||||
webhook_urls = coordinator.owner.webhook_endpoints.payment_status.pluck(:url)
|
||||
|
||||
# plus url for coordinator manager (ignore duplicate)
|
||||
users_webhook_urls = coordinator.users.flat_map do |user|
|
||||
user.webhook_endpoints.payment_status.pluck(:url)
|
||||
end
|
||||
|
||||
webhook_urls | users_webhook_urls
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,24 +10,5 @@
|
||||
%th= t('.url.header')
|
||||
%th.actions
|
||||
%tbody
|
||||
-# Existing endpoints
|
||||
- @user.webhook_endpoints.each do |webhook_endpoint|
|
||||
%tr
|
||||
%td= t('.event_types.order_cycle_opened') # For now, we only support one type.
|
||||
%td= webhook_endpoint.url
|
||||
%td.actions
|
||||
- if webhook_endpoint.persisted?
|
||||
= button_to account_webhook_endpoint_path(webhook_endpoint), method: :delete,
|
||||
class: "tiny alert no-margin",
|
||||
data: { confirm: I18n.t(:are_you_sure)} do
|
||||
= I18n.t(:delete)
|
||||
|
||||
-# Create new
|
||||
- if @user.webhook_endpoints.empty? # Only one allowed for now.
|
||||
%tr
|
||||
%td= t('.event_types.order_cycle_opened') # For now, we only support one type.
|
||||
%td
|
||||
= form_for(@user.webhook_endpoints.build, url: account_webhook_endpoints_path, id: 'new_webhook_endpoint') do |f|
|
||||
= f.url_field :url, placeholder: t('.url.create_placeholder'), required: true, size: 64
|
||||
%td.actions
|
||||
= button_tag t(:create), class: 'button primary tiny no-margin', form: 'new_webhook_endpoint'
|
||||
= render WebhookEndpointFormComponent.new(webhooks: @user.webhook_endpoints.order_cycle_opened, webhook_type: "order_cycle_opened")
|
||||
= render WebhookEndpointFormComponent.new(webhooks: @user.webhook_endpoints.payment_status, webhook_type: "payment_status_changed")
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.saved_cards, .no_cards {
|
||||
.saved_cards,
|
||||
.no_cards {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
@@ -26,7 +27,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.authorised_shops{
|
||||
.authorised_shops {
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -39,7 +40,9 @@
|
||||
a {
|
||||
color: $clr-brick;
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: $clr-brick-med-bright;
|
||||
}
|
||||
}
|
||||
@@ -60,7 +63,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
i.ofn-i_059-producer, i.ofn-i_060-producer-reversed {
|
||||
i.ofn-i_059-producer,
|
||||
i.ofn-i_060-producer-reversed {
|
||||
font-size: 3rem;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
@@ -92,7 +96,8 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.transaction-group {}
|
||||
.transaction-group {
|
||||
}
|
||||
|
||||
table {
|
||||
border-radius: $radius-medium $radius-medium 0 0;
|
||||
@@ -161,6 +166,15 @@ table {
|
||||
//
|
||||
// Unfortunately we can't use Scss's interpolation
|
||||
// https://sass-lang.com/documentation/interpolation. We're using a too old version perhaps?
|
||||
right: calc(12px + 2*2px + 2*1px);
|
||||
right: calc(12px + 2 * 2px + 2 * 1px);
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook Endpoints
|
||||
td.endpoints-actions {
|
||||
display: flex;
|
||||
|
||||
form {
|
||||
padding-right: $padding-small;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,15 @@ module Openfoodnetwork
|
||||
Spree::Core::Engine.routes.default_url_options[:host] = ENV["SITE_URL"] if Rails.env == 'test'
|
||||
end
|
||||
|
||||
# We reload the routes here
|
||||
# so that the appended/prepended routes are available to the application.
|
||||
config.after_initialize do
|
||||
# We reload the routes here
|
||||
# so that the appended/prepended routes are available to the application.
|
||||
Rails.application.routes_reloader.reload!
|
||||
|
||||
# Subscribe to payment transition events
|
||||
ActiveSupport::Notifications.subscribe(
|
||||
"ofn.payment_transition", Payments::StatusChangedListenerService.new
|
||||
)
|
||||
end
|
||||
|
||||
initializer "spree.environment", before: :load_config_initializers do |app|
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
# serializer. Therefore, this setting should only be enabled after all replicas
|
||||
# have been successfully upgraded to Rails 7.1.
|
||||
#++
|
||||
# Rails.application.config.active_job.use_big_decimal_serializer = true
|
||||
Rails.application.config.active_job.use_big_decimal_serializer = true
|
||||
|
||||
###
|
||||
# Specify if an `ArgumentError` should be raised if `Rails.cache` `fetch` or
|
||||
|
||||
@@ -4088,6 +4088,8 @@ en:
|
||||
destroy:
|
||||
success: Webhook endpoint successfully deleted
|
||||
error: Webhook endpoint failed to delete
|
||||
test:
|
||||
success: Some test data will be sent to the webhook url
|
||||
|
||||
spree:
|
||||
order_updated: "Order Updated"
|
||||
@@ -4940,13 +4942,10 @@ en:
|
||||
webhook_endpoints:
|
||||
title: Webhook Endpoints
|
||||
description: Events in the system may trigger webhooks to external systems.
|
||||
event_types:
|
||||
order_cycle_opened: Order Cycle Opened
|
||||
event_type:
|
||||
header: Event type
|
||||
url:
|
||||
header: Endpoint URL
|
||||
create_placeholder: Enter the URL of the remote webhook endpoint
|
||||
developer_settings:
|
||||
title: Developer Settings
|
||||
form:
|
||||
@@ -5095,7 +5094,13 @@ en:
|
||||
add_tag_rule_modal:
|
||||
select_rule_type: "Select a rule type:"
|
||||
add_rule: "Add Rule"
|
||||
|
||||
webhook_endpoint_form:
|
||||
url:
|
||||
create_placeholder: Enter the URL of the remote webhook endpoint
|
||||
event_types:
|
||||
order_cycle_opened: Order Cycle Opened
|
||||
payment_status_changed: Post webhook on Payment status change
|
||||
test_endpoint: Test webhook endpoint
|
||||
|
||||
# Gem to prevent bot form submissions
|
||||
invisible_captcha:
|
||||
|
||||
@@ -34,6 +34,7 @@ Spree::Core::Engine.routes.draw do
|
||||
|
||||
resource :account, :controller => 'users' do
|
||||
resources :webhook_endpoints, only: [:create, :destroy], controller: '/webhook_endpoints'
|
||||
post '/webhook_endpoints/:id/test', to: "/webhook_endpoints#test", as: "webhook_endpoint_test"
|
||||
end
|
||||
|
||||
match '/admin/orders/bulk_management' => 'admin/orders#bulk_management', :as => "admin_bulk_order_management", via: :get
|
||||
|
||||
16
db/migrate/20251124043324_add_type_to_webhook_endpoints.rb
Normal file
16
db/migrate/20251124043324_add_type_to_webhook_endpoints.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddTypeToWebhookEndpoints < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
# Using "order_cycle_opened" as default will update existing record
|
||||
change_table(:webhook_endpoints, bulk: true) do |t|
|
||||
t.column :webhook_type, :string, limit: 255, null: false, default: "order_cycle_opened"
|
||||
end
|
||||
# Drop the default value
|
||||
change_column_default :webhook_endpoints, :webhook_type, nil
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :webhook_endpoints, :webhook_type
|
||||
end
|
||||
end
|
||||
@@ -1143,6 +1143,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_26_005628) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id", default: 0, null: false
|
||||
t.string "webhook_type", limit: 255, null: false
|
||||
t.index ["user_id"], name: "index_webhook_endpoints_on_user_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@ RSpec.describe WebhookEndpointsController do
|
||||
describe "#create" do
|
||||
it "creates a webhook_endpoint" do
|
||||
expect {
|
||||
spree_post :create, { url: "https://url" }
|
||||
spree_post :create, { url: "https://url", webhook_type: "order_cycle_opened" }
|
||||
}.to change {
|
||||
user.webhook_endpoints.count
|
||||
}.by(1)
|
||||
|
||||
expect(flash[:success]).to be_present
|
||||
expect(flash[:error]).to be_blank
|
||||
expect(user.webhook_endpoints.first.url).to eq "https://url"
|
||||
webhook = user.webhook_endpoints.first
|
||||
expect(webhook.url).to eq "https://url"
|
||||
expect(webhook.webhook_type).to eq "order_cycle_opened"
|
||||
end
|
||||
|
||||
it "shows error if parameters not specified" do
|
||||
@@ -33,17 +35,20 @@ RSpec.describe WebhookEndpointsController do
|
||||
end
|
||||
|
||||
it "redirects back to referrer" do
|
||||
spree_post :create, { url: "https://url" }
|
||||
spree_post :create, { url: "https://url", webhook_type: "order_cycle_opened" }
|
||||
|
||||
expect(response).to redirect_to "/account#/developer_settings"
|
||||
end
|
||||
end
|
||||
|
||||
describe "#destroy" do
|
||||
let!(:webhook_endpoint) { user.webhook_endpoints.create(url: "https://url") }
|
||||
let!(:webhook_endpoint) {
|
||||
user.webhook_endpoints.create(url: "https://url", webhook_type: "order_cycle_opened")
|
||||
}
|
||||
|
||||
it "destroys a webhook_endpoint" do
|
||||
webhook_endpoint2 = user.webhook_endpoints.create!(url: "https://url2")
|
||||
webhook_endpoint2 = user.webhook_endpoints.create!(url: "https://url2",
|
||||
webhook_type: "order_cycle_opened")
|
||||
|
||||
expect {
|
||||
spree_delete :destroy, { id: webhook_endpoint.id }
|
||||
@@ -64,4 +69,22 @@ RSpec.describe WebhookEndpointsController do
|
||||
expect(response).to redirect_to "/account#/developer_settings"
|
||||
end
|
||||
end
|
||||
|
||||
describe "#test" do
|
||||
let(:webhook_endpoint) {
|
||||
user.webhook_endpoints.create(url: "https://url", webhook_type: "payment_status_changed" )
|
||||
}
|
||||
|
||||
subject { spree_post :test, id: webhook_endpoint.id, format: :turbo_stream }
|
||||
|
||||
it "enqueus a webhook job" do
|
||||
expect { subject }.to enqueue_job(WebhookDeliveryJob).exactly(1).times
|
||||
end
|
||||
|
||||
it "shows a success mesage" do
|
||||
subject
|
||||
|
||||
expect(flash[:success]).to eq "Some test data will be sent to the webhook url"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,6 +35,12 @@ module Spree
|
||||
}
|
||||
|
||||
before do
|
||||
# mock the call with "ofn.payment_transition" so we don't call the related listener
|
||||
# and services
|
||||
allow(ActiveSupport::Notifications).to receive(:instrument).and_call_original
|
||||
allow(ActiveSupport::Notifications).to receive(:instrument)
|
||||
.with("ofn.payment_transition", any_args).and_return(nil)
|
||||
|
||||
allow(order).to receive_message_chain(:line_items, :empty?).and_return(false)
|
||||
allow(order).to receive_messages total: 100
|
||||
stub_request(:get, "https://api.stripe.com/v1/payment_intents/12345").
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Spree::Payment do
|
||||
before do
|
||||
# mock the call with "ofn.payment_transition" so we don't call the related listener and services
|
||||
allow(ActiveSupport::Notifications).to receive(:instrument).and_call_original
|
||||
allow(ActiveSupport::Notifications).to receive(:instrument)
|
||||
.with("ofn.payment_transition", any_args).and_return(nil)
|
||||
end
|
||||
|
||||
context 'original specs from Spree' do
|
||||
before { Stripe.api_key = "sk_test_12345" }
|
||||
let(:order) { create(:order) }
|
||||
@@ -1064,4 +1071,17 @@ RSpec.describe Spree::Payment do
|
||||
expect(payment.captured_at).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe "payment transition" do
|
||||
it "notifies of payment status change" do
|
||||
payment = create(:payment)
|
||||
|
||||
allow(ActiveSupport::Notifications).to receive(:instrument).and_call_original
|
||||
expect(ActiveSupport::Notifications).to receive(:instrument).with(
|
||||
"ofn.payment_transition", payment: payment, event: "processing"
|
||||
)
|
||||
|
||||
payment.started_processing!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,5 +5,9 @@ require 'spec_helper'
|
||||
RSpec.describe WebhookEndpoint do
|
||||
describe "validations" do
|
||||
it { is_expected.to validate_presence_of(:url) }
|
||||
it {
|
||||
is_expected.to validate_inclusion_of(:webhook_type)
|
||||
.in_array(%w(order_cycle_opened payment_status_changed))
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ RSpec.describe OrderCycles::WebhookService do
|
||||
# The co-ordinating enterprise has a non-owner user with an endpoint.
|
||||
# They shouldn't receive a notification.
|
||||
coordinator_user = create(:user, enterprises: [coordinator])
|
||||
coordinator_user.webhook_endpoints.create!(url: "http://coordinator_user_url")
|
||||
coordinator_user.webhook_endpoints.order_cycle_opened.create!(url: "http://coordinator_user_url")
|
||||
|
||||
expect{ subject }
|
||||
.not_to enqueue_job(WebhookDeliveryJob).with("http://coordinator_user_url", any_args)
|
||||
@@ -30,7 +30,7 @@ RSpec.describe OrderCycles::WebhookService do
|
||||
|
||||
context "coordinator owner has endpoint configured" do
|
||||
before do
|
||||
coordinator.owner.webhook_endpoints.create! url: "http://coordinator_owner_url"
|
||||
coordinator.owner.webhook_endpoints.order_cycle_opened.create!(url: "http://coordinator_owner_url")
|
||||
end
|
||||
|
||||
it "creates webhook payload for order cycle coordinator" do
|
||||
@@ -77,7 +77,7 @@ RSpec.describe OrderCycles::WebhookService do
|
||||
let(:two_distributors) {
|
||||
(1..2).map do |i|
|
||||
user = create(:user)
|
||||
user.webhook_endpoints.create!(url: "http://distributor#{i}_owner_url")
|
||||
user.webhook_endpoints.order_cycle_opened.create!(url: "http://distributor#{i}_owner_url")
|
||||
create(:distributor_enterprise, owner: user)
|
||||
end
|
||||
}
|
||||
@@ -109,7 +109,7 @@ RSpec.describe OrderCycles::WebhookService do
|
||||
}
|
||||
|
||||
it "creates only one webhook payload for the user's endpoint" do
|
||||
user.webhook_endpoints.create! url: "http://coordinator_owner_url"
|
||||
user.webhook_endpoints.order_cycle_opened.create!(url: "http://coordinator_owner_url")
|
||||
|
||||
expect{ subject }
|
||||
.to enqueue_job(WebhookDeliveryJob).with("http://coordinator_owner_url", any_args)
|
||||
@@ -128,7 +128,7 @@ RSpec.describe OrderCycles::WebhookService do
|
||||
}
|
||||
let(:supplier) {
|
||||
user = create(:user)
|
||||
user.webhook_endpoints.create!(url: "http://supplier_owner_url")
|
||||
user.webhook_endpoints.order_cycle_opened.create!(url: "http://supplier_owner_url")
|
||||
create(:supplier_enterprise, owner: user)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Payments::StatusChangedListenerService do
|
||||
let(:name) { "ofn.payment_transition" }
|
||||
let(:started) { Time.zone.parse("2025-11-28 09:00:00") }
|
||||
let(:finished) { Time.zone.parse("2025-11-28 09:00:02") }
|
||||
let(:unique_id) { "d3a7ac9f635755fcff2c" }
|
||||
let(:payload) { { payment:, event: "completed" } }
|
||||
let(:payment) { build(:payment) }
|
||||
|
||||
subject { described_class.new }
|
||||
|
||||
describe "#call" do
|
||||
it "calls Payments::WebhookService" do
|
||||
expect(Payments::WebhookService).to receive(:create_webhook_job).with(
|
||||
payment:, event: "payment.completed", at: started
|
||||
)
|
||||
|
||||
subject.call(name, started, finished, unique_id, payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
95
spec/services/payments/webhook_payload_spec.rb
Normal file
95
spec/services/payments/webhook_payload_spec.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Payments::WebhookPayload do
|
||||
describe "#to_hash" do
|
||||
let(:order) { create(:completed_order_with_totals, order_cycle: ) }
|
||||
let(:order_cycle) { create(:simple_order_cycle) }
|
||||
let(:payment) { create(:payment, :completed, amount: order.total, order:) }
|
||||
let(:tax_category) { create(:tax_category) }
|
||||
|
||||
subject { described_class.new(payment:, order:, enterprise: order.distributor) }
|
||||
|
||||
it "returns a hash with the relevant data" do
|
||||
order.line_items.update_all(tax_category_id: tax_category.id)
|
||||
|
||||
enterprise = order.distributor
|
||||
line_items = order.line_items.map do |li|
|
||||
{
|
||||
quantity: li.quantity,
|
||||
price: li.price,
|
||||
tax_category_name: li.tax_category&.name,
|
||||
product_name: li.product.name,
|
||||
name_to_display: li.display_name,
|
||||
unit_to_display: li.unit_presentation
|
||||
}
|
||||
end
|
||||
|
||||
payload = {
|
||||
payment: {
|
||||
updated_at: payment.updated_at,
|
||||
amount: payment.amount,
|
||||
state: payment.state
|
||||
},
|
||||
enterprise: {
|
||||
abn: enterprise.abn,
|
||||
acn: enterprise.acn,
|
||||
name: enterprise.name,
|
||||
address: {
|
||||
address1: enterprise.address.address1,
|
||||
address2: enterprise.address.address2,
|
||||
city: enterprise.address.city,
|
||||
zipcode: enterprise.address.zipcode
|
||||
}
|
||||
},
|
||||
order: {
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
line_items: line_items
|
||||
}
|
||||
}.with_indifferent_access
|
||||
|
||||
expect(subject.to_hash).to eq(payload)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".test_data" do
|
||||
it "returns a hash with test data" do
|
||||
test_payload = {
|
||||
payment: {
|
||||
updated_at: kind_of(Time),
|
||||
amount: 0.00,
|
||||
state: "completed"
|
||||
},
|
||||
enterprise: {
|
||||
abn: "65797115831",
|
||||
acn: "",
|
||||
name: "TEST Enterprise",
|
||||
address: {
|
||||
address1: "1 testing street",
|
||||
address2: "",
|
||||
city: "TestCity",
|
||||
zipcode: "1234"
|
||||
}
|
||||
},
|
||||
order: {
|
||||
total: 0.00,
|
||||
currency: "AUD",
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price: 20.00.to_d,
|
||||
tax_category_name: "VAT",
|
||||
product_name: "Test product",
|
||||
name_to_display: nil,
|
||||
unit_to_display: "1kg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}.with_indifferent_access
|
||||
|
||||
expect(described_class.test_data.to_hash).to match(test_payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
116
spec/services/payments/webhook_service_spec.rb
Normal file
116
spec/services/payments/webhook_service_spec.rb
Normal file
@@ -0,0 +1,116 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Payments::WebhookService do
|
||||
let(:order) { create(:completed_order_with_totals, order_cycle: ) }
|
||||
let(:order_cycle) { create(:simple_order_cycle) }
|
||||
let(:payment) { create(:payment, :completed, amount: order.total, order:) }
|
||||
let(:tax_category) { create(:tax_category) }
|
||||
let(:at) { Time.zone.parse("2025-11-26 09:00:02") }
|
||||
|
||||
subject { described_class.create_webhook_job(payment: payment, event: "payment.completed", at:) }
|
||||
|
||||
describe "creating payloads" do
|
||||
context "with order cycle coordinator owner webhook endpoints configured" do
|
||||
before do
|
||||
order.order_cycle.coordinator.owner.webhook_endpoints.payment_status.create!(
|
||||
url: "http://coordinator.payment.url"
|
||||
)
|
||||
end
|
||||
|
||||
it "calls endpoint for the owner if the order cycle coordinator" do
|
||||
expect{ subject }
|
||||
.to enqueue_job(WebhookDeliveryJob).exactly(1).times
|
||||
.with("http://coordinator.payment.url", "payment.completed", any_args)
|
||||
end
|
||||
|
||||
it "creates webhook payload with payment details" do
|
||||
order.line_items.update_all(tax_category_id: tax_category.id)
|
||||
|
||||
enterprise = order.distributor
|
||||
line_items = order.line_items.map do |li|
|
||||
{
|
||||
quantity: li.quantity,
|
||||
price: li.price,
|
||||
tax_category_name: li.tax_category&.name,
|
||||
product_name: li.product.name,
|
||||
name_to_display: li.display_name,
|
||||
unit_to_display: li.unit_presentation
|
||||
}
|
||||
end
|
||||
|
||||
data = {
|
||||
payment: {
|
||||
updated_at: payment.updated_at,
|
||||
amount: payment.amount,
|
||||
state: payment.state
|
||||
},
|
||||
enterprise: {
|
||||
abn: enterprise.abn,
|
||||
acn: enterprise.acn,
|
||||
name: enterprise.name,
|
||||
address: {
|
||||
address1: enterprise.address.address1,
|
||||
address2: enterprise.address.address2,
|
||||
city: enterprise.address.city,
|
||||
zipcode: enterprise.address.zipcode
|
||||
}
|
||||
},
|
||||
order: {
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
line_items: line_items
|
||||
}
|
||||
}
|
||||
|
||||
expect{ subject }
|
||||
.to enqueue_job(WebhookDeliveryJob).exactly(1).times
|
||||
.with("http://coordinator.payment.url", "payment.completed", hash_including(data), at:)
|
||||
end
|
||||
|
||||
context "with coordinator manager with webhook endpoint configured" do
|
||||
let(:user1) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
|
||||
before do
|
||||
coordinator = order.order_cycle.coordinator
|
||||
coordinator.users << user1
|
||||
coordinator.users << user2
|
||||
end
|
||||
|
||||
it "calls endpoint for all user managing the order cycle coordinator" do
|
||||
user1.webhook_endpoints.payment_status.create!(url: "http://user1.payment.url")
|
||||
user2.webhook_endpoints.payment_status.create!(url: "http://user2.payment.url")
|
||||
|
||||
expect{ subject }
|
||||
.to enqueue_job(WebhookDeliveryJob)
|
||||
.with("http://coordinator.payment.url", "payment.completed", any_args)
|
||||
.and enqueue_job(WebhookDeliveryJob)
|
||||
.with("http://user1.payment.url", "payment.completed", any_args)
|
||||
.and enqueue_job(WebhookDeliveryJob)
|
||||
.with("http://user2.payment.url", "payment.completed", any_args)
|
||||
end
|
||||
|
||||
context "wiht duplicate webhook endpoints configured" do
|
||||
it "calls each unique configured endpoint" do
|
||||
user1.webhook_endpoints.payment_status.create!(url: "http://coordinator.payment.url")
|
||||
user2.webhook_endpoints.payment_status.create!(url: "http://user2.payment.url")
|
||||
|
||||
expect{ subject }
|
||||
.to enqueue_job(WebhookDeliveryJob)
|
||||
.with("http://coordinator.payment.url", "payment.completed", any_args)
|
||||
.and enqueue_job(WebhookDeliveryJob)
|
||||
.with("http://user2.payment.url", "payment.completed", any_args)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with no webhook configured" do
|
||||
it "does not call endpoint" do
|
||||
expect{ subject }.not_to enqueue_job(WebhookDeliveryJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -39,15 +39,33 @@ RSpec.describe "Developer Settings" do
|
||||
describe "Webhook Endpoints" do
|
||||
it "creates a new webhook endpoint and deletes it" do
|
||||
within "#webhook_endpoints" do
|
||||
fill_in "webhook_endpoint_url", with: "https://url"
|
||||
within(:table_row, ["Order Cycle Opened"]) do
|
||||
fill_in "order_cycle_opened_webhook_endpoint_url", with: "https://url"
|
||||
|
||||
click_button I18n.t(:create)
|
||||
expect(page.document).to have_content I18n.t('webhook_endpoints.create.success')
|
||||
expect(page).to have_content "https://url"
|
||||
click_button "Create"
|
||||
expect(page.document).to have_content "Webhook endpoint successfully created"
|
||||
expect(page).to have_content "https://url"
|
||||
|
||||
click_button I18n.t(:delete)
|
||||
expect(page.document).to have_content I18n.t('webhook_endpoints.destroy.success')
|
||||
accept_confirm do
|
||||
click_button "Delete"
|
||||
end
|
||||
end
|
||||
expect(page.document).to have_content "Webhook endpoint successfully deleted"
|
||||
expect(page).not_to have_content "https://url"
|
||||
|
||||
within(:table_row, ["Post webhook on Payment status change"]) do
|
||||
fill_in "payment_status_changed_webhook_endpoint_url", with: "https://url/payment"
|
||||
click_button "Create"
|
||||
expect(page.document).to have_content "Webhook endpoint successfully created"
|
||||
expect(page).to have_content "https://url/payment"
|
||||
|
||||
accept_confirm do
|
||||
click_button "Delete"
|
||||
end
|
||||
end
|
||||
|
||||
expect(page.document).to have_content "Webhook endpoint successfully deleted"
|
||||
expect(page).not_to have_content "https://url/payment"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user