Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dunning): Add update_payment_status for gocardless payments #2518

Merged
merged 1 commit into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/jobs/send_webhook_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class SendWebhookJob < ApplicationJob
'payment_provider.error' => Webhooks::PaymentProviders::ErrorService,
'payment_request.created' => Webhooks::PaymentRequests::CreatedService,
"payment_request.payment_failure" => Webhooks::PaymentProviders::PaymentRequestPaymentFailureService,
"payment_request.payment_status_updated" => Webhooks::PaymentRequests::PaymentStatusUpdatedService,
'subscription.terminated' => Webhooks::Subscriptions::TerminatedService,
'subscription.started' => Webhooks::Subscriptions::StartedService,
'subscription.termination_alert' => Webhooks::Subscriptions::TerminationAlertService,
Expand Down
33 changes: 28 additions & 5 deletions app/services/payment_requests/payments/gocardless_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,25 @@ def create
result
end

def update_payment_status(provider_payment_id:, status:)
payment = Payment.find_by(provider_payment_id:)
return result.not_found_failure!(resource: 'gocardless_payment') unless payment

result.payment = payment
result.payable = payment.payable
return result if payment.payable.payment_succeeded?

payment.update!(status:)

payable_payment_status = payable_payment_status(status)
update_payable_payment_status(payment_status: payable_payment_status)
update_invoices_payment_status(payment_status: payable_payment_status)

result
rescue BaseService::FailedResult => e
result.fail_with_error!(e)
end

private

attr_accessor :payable
Expand Down Expand Up @@ -149,14 +168,18 @@ def payable_payment_status(payment_status)
end

def update_payable_payment_status(payment_status:, deliver_webhook: true)
payable.update!(
payment_status:,
ready_for_payment_processing: payment_status.to_sym != :succeeded
)
UpdateService.call(
payable: result.payable,
params: {
payment_status:,
ready_for_payment_processing: payment_status.to_sym != :succeeded
},
webhook_notification: deliver_webhook
).raise_if_error!
end

def update_invoices_payment_status(payment_status:, deliver_webhook: true)
payable.invoices.each do |invoice|
result.payable.invoices.each do |invoice|
Invoices::UpdateService.call(
invoice:,
params: {
Expand Down
64 changes: 64 additions & 0 deletions app/services/payment_requests/update_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module PaymentRequests
class UpdateService < BaseService
def initialize(payable:, params:, webhook_notification: false)
@payable = payable
@params = params
@webhook_notification = webhook_notification

super
end

def call
return result.not_found_failure!(resource: "payment_request") unless payable

if params.key?(:payment_status) && !valid_payment_status?(params[:payment_status])
return result.single_validation_failure!(
field: :payment_status,
error_code: "value_is_invalid"
)
end

payable.payment_status = params[:payment_status] if params.key?(:payment_status)
payable.ready_for_payment_processing = params[:ready_for_payment_processing] if params.key?(:ready_for_payment_processing)
payable.save!

if payable.saved_change_to_payment_status?
track_payment_status_changed
deliver_webhook if webhook_notification
end

result.payable = payable
result
rescue ActiveRecord::RecordInvalid => e
result.record_validation_failure!(record: e.record)
end

private

attr_reader :payable, :params, :webhook_notification

def valid_payment_status?(payment_status)
PaymentRequest::PAYMENT_STATUS.include?(payment_status&.to_sym)
end

def track_payment_status_changed
SegmentTrackJob.perform_later(
membership_id: CurrentContext.membership,
event: "payment_status_changed",
properties: {
organization_id: payable.organization.id,
payment_request_id: payable.id,
payment_status: payable.payment_status
}
)
end

def deliver_webhook
return unless webhook_notification

SendWebhookJob.perform_later("payment_request.payment_status_updated", payable)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Webhooks
module PaymentRequests
class PaymentStatusUpdatedService < Webhooks::BaseService
def current_organization
@current_organization ||= object.organization
end

def object_serializer
::V1::PaymentRequestSerializer.new(
object,
root_name: "payment_request",
includes: %i[customer invoices]
)
end

def webhook_type
'payment_request.payment_status_updated'
end

def object_type
'payment_request'
end
end
end
end
146 changes: 146 additions & 0 deletions spec/services/payment_requests/payments/gocardless_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@
gocardless_service.create

expect(invoice_1.reload).to be_payment_succeeded
expect(invoice_1.ready_for_payment_processing).to eq(false)

expect(invoice_2.reload).to be_payment_succeeded
expect(invoice_2.ready_for_payment_processing).to eq(false)
end

context "with no payment provider" do
Expand Down Expand Up @@ -232,4 +235,147 @@
end
end
end

describe "#update_payment_status" do
let(:payment) do
create(
:payment,
payable: payment_request,
provider_payment_id: provider_payment_id,
status: "pending_submission"
)
end

let(:provider_payment_id) { "ch_123456" }

before do
allow(SendWebhookJob).to receive(:perform_later)
payment
end

it "updates the payment, payment_request and invoice payment_status", :aggregate_failures do
result = gocardless_service.update_payment_status(
provider_payment_id:,
status: "paid_out"
)

expect(result).to be_success
expect(result.payment.status).to eq("paid_out")

expect(result.payable.reload).to be_payment_succeeded
expect(result.payable.ready_for_payment_processing).to eq(false)

expect(invoice_1.reload).to be_payment_succeeded
expect(invoice_1.ready_for_payment_processing).to eq(false)
expect(invoice_2.reload).to be_payment_succeeded
expect(invoice_2.ready_for_payment_processing).to eq(false)
end

context "when status is failed" do
it "updates the payment, payment_request and invoice status", :aggregate_failures do
result = gocardless_service.update_payment_status(
provider_payment_id:,
status: "failed"
)

expect(result).to be_success
expect(result.payment.status).to eq("failed")

expect(result.payable.reload).to be_payment_failed
expect(result.payable.ready_for_payment_processing).to eq(true)

expect(invoice_1.reload).to be_payment_failed
expect(invoice_1.ready_for_payment_processing).to eq(true)

expect(invoice_2.reload).to be_payment_failed
expect(invoice_2.ready_for_payment_processing).to eq(true)
end
end

context "when payment is not found" do
let(:payment) { nil }

it "returns a not found error", :aggregate_failures do
result = gocardless_service.update_payment_status(
provider_payment_id:,
status: "paid_out"
)

expect(result).not_to be_success
expect(result.payment).to be_nil
expect(result.error).to be_a(BaseService::NotFoundFailure)
expect(result.error.error_code).to eq("gocardless_payment_not_found")
end
end

context "when payment_request and invoice is already payment_succeeded" do
before do
payment_request.payment_succeeded!
invoice_1.payment_succeeded!
invoice_2.payment_succeeded!
end

it "does not update the status of invoice, payment_request and payment" do
expect {
gocardless_service.update_payment_status(provider_payment_id:, status: "paid_out")
}.to not_change { invoice_1.reload.payment_status }
.and not_change { invoice_2.reload.payment_status }
.and not_change { payment_request.reload.payment_status }
.and not_change { payment.reload.status }

result = gocardless_service.update_payment_status(provider_payment_id:, status: "paid_out")

expect(result).to be_success
end
end

context "with invalid status", :aggregate_failures do
let(:status) { "invalid-status" }

it "does not update the payment_status of payment_request, invoice and payment" do
expect {
gocardless_service.update_payment_status(provider_payment_id:, status:)
}.to not_change { payment_request.reload.payment_status }
.and not_change { invoice_1.reload.payment_status }
.and not_change { invoice_2.reload.payment_status }
.and change { payment.reload.status }.to(status)
end

it "returns an error", :aggregate_failures do
result = gocardless_service.update_payment_status(provider_payment_id:, status:)

expect(result).not_to be_success
expect(result.error).to be_a(BaseService::ValidationFailure)
expect(result.error.messages.keys).to include(:payment_status)
expect(result.error.messages[:payment_status]).to include("value_is_invalid")
end
end

context "when payment request is not passed to constructor" do
subject(:gocardless_service) { described_class.new(nil) }

before do
payment_request
end

it "updates the payment and invoice payment_status" do
result = gocardless_service.update_payment_status(
provider_payment_id:,
status: "paid_out"
)

expect(result).to be_success
expect(result.payment.status).to eq("paid_out")

expect(result.payable).to be_payment_succeeded
expect(result.payable.ready_for_payment_processing).to eq(false)

expect(invoice_1.reload).to be_payment_succeeded
expect(invoice_1.ready_for_payment_processing).to eq(false)

expect(invoice_2.reload).to be_payment_succeeded
expect(invoice_2.ready_for_payment_processing).to eq(false)
end
end
end
end
52 changes: 52 additions & 0 deletions spec/services/payment_requests/update_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe PaymentRequests::UpdateService do
subject(:result) do
described_class.call(
payable: payment_request,
params: update_args,
webhook_notification:
)
end

let(:payment_request) { create :payment_request }
let(:webhook_notification) { false }
let(:update_args) { {payment_status: "succeeded"} }

describe "#call" do
before do
allow(SegmentTrackJob).to receive(:perform_later)
end

it "updates the invoice", :aggregate_failures do
expect(result).to be_success
expect(result.payable).to eq(payment_request)
expect(result.payable).to be_payment_succeeded
end

it "calls SegmentTrackJob" do
result

expect(SegmentTrackJob).to have_received(:perform_later).with(
membership_id: CurrentContext.membership,
event: "payment_status_changed",
properties: {
organization_id: payment_request.organization.id,
payment_request_id: payment_request.id,
payment_status: payment_request.payment_status
}
)
end

context "when payment_request does not exist" do
let(:payment_request) { nil }

it "returns an error" do
expect(result).not_to be_success
expect(result.error.error_code).to eq("payment_request_not_found")
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe Webhooks::PaymentRequests::PaymentStatusUpdatedService do
subject(:webhook_service) { described_class.new(object: payment_request) }

let(:payment_request) { create(:payment_request) }

describe ".call" do
it_behaves_like "creates webhook", "payment_request.payment_status_updated", "payment_request"
end
end