Merge pull request #13777 from rioug/13481-webhook-payment

Payment status change webhook
This commit is contained in:
Filipe
2026-01-08 11:26:41 +00:00
committed by GitHub
26 changed files with 597 additions and 54 deletions

View 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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:)

View 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

View 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

View 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

View File

@@ -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")

View File

@@ -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;
}
}

View File

@@ -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|

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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").

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View 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

View 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

View File

@@ -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