From 4a6ba29b99982ba253a42cf7a5052be2f31d70a8 Mon Sep 17 00:00:00 2001 From: Gaetan Craig-Riou Date: Fri, 28 Nov 2025 14:12:50 +1100 Subject: [PATCH] Add Payments::WebhookService It enqueues jobs to post the generated payload to the various configured webhook endpoints for payment status change --- app/services/payments/webhook_service.rb | 47 +++++++ .../services/payments/webhook_service_spec.rb | 124 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 app/services/payments/webhook_service.rb create mode 100644 spec/services/payments/webhook_service_spec.rb diff --git a/app/services/payments/webhook_service.rb b/app/services/payments/webhook_service.rb new file mode 100644 index 0000000000..451a073e3c --- /dev/null +++ b/app/services/payments/webhook_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Create a webhook payload for an payment status event. +# The payload will be delivered asynchronously. + +module Payments + class WebhookService + def self.create_webhook_job(payment:, event:, at:) + order = payment.order + enterprise = order.distributor + + 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 + + payload = { + 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) + } + + coordinator = 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.map(&:url) + + # plus url for coordinator manager (ignore duplicate) + users_webhook_urls = coordinator.users.flat_map do |user| + user.webhook_endpoints.payment_status.map(&:url) + end + + webhook_urls | users_webhook_urls + end + end +end diff --git a/spec/services/payments/webhook_service_spec.rb b/spec/services/payments/webhook_service_spec.rb new file mode 100644 index 0000000000..e73216bd20 --- /dev/null +++ b/spec/services/payments/webhook_service_spec.rb @@ -0,0 +1,124 @@ +# 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.create!( + url: "http://coordinator.payment.url", webhook_type: "payment_status_changed" + ) + 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 # TODO check this + } + 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.create!( + url: "http://user1.payment.url", webhook_type: "payment_status_changed" + ) + user2.webhook_endpoints.create!( + url: "http://user2.payment.url", webhook_type: "payment_status_changed" + ) + + 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.create!( + url: "http://coordinator.payment.url", webhook_type: "payment_status_changed" + ) + user2.webhook_endpoints.create!( + url: "http://user2.payment.url", webhook_type: "payment_status_changed" + ) + + 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