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

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