From c74ab92b9620043efc83ebc905920e7e7aca94ab Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 23 Aug 2024 17:03:35 +0100 Subject: [PATCH 1/6] Add method in payment request to increment payment attempts --- app/models/payment_request.rb | 5 +++++ .../payment_requests/payments/stripe_service.rb | 7 +------ spec/models/payment_request_spec.rb | 10 ++++++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/models/payment_request.rb b/app/models/payment_request.rb index da4d69d9030..fd552ca6fae 100644 --- a/app/models/payment_request.rb +++ b/app/models/payment_request.rb @@ -24,6 +24,11 @@ class PaymentRequest < ApplicationRecord def invoice_ids applied_invoices.pluck(:invoice_id) end + + def increment_payment_attempts! + increment(:payment_attempts) + save! + end end # == Schema Information diff --git a/app/services/payment_requests/payments/stripe_service.rb b/app/services/payment_requests/payments/stripe_service.rb index 7b6d7bb26fe..cdc02be0228 100644 --- a/app/services/payment_requests/payments/stripe_service.rb +++ b/app/services/payment_requests/payments/stripe_service.rb @@ -25,7 +25,7 @@ def create return result end - increment_payment_attempts + payable.increment_payment_attempts! stripe_result = create_stripe_payment # NOTE: return if payment was not processed @@ -201,11 +201,6 @@ def update_invoices_payment_status(payment_status:, deliver_webhook: true, proce end end - def increment_payment_attempts - payable.increment(:payment_attempts) - payable.save! - end - def deliver_error_webhook(stripe_error) DeliverErrorWebhookService.call_async(payable, { provider_customer_id: customer.stripe_customer.provider_customer_id, diff --git a/spec/models/payment_request_spec.rb b/spec/models/payment_request_spec.rb index 784cdc58090..e150a29960e 100644 --- a/spec/models/payment_request_spec.rb +++ b/spec/models/payment_request_spec.rb @@ -72,4 +72,14 @@ expect(payment_request.invoice_ids).to eq(invoices.map(&:id)) end end + + describe "#increment_payment_attempts!" do + let(:payment_request) { create :payment_request } + + it "updates payment_attempts attribute +1" do + expect { payment_request.increment_payment_attempts! } + .to change { payment_request.reload.payment_attempts } + .by(1) + end + end end From c8872ee67e678ff1533c3e513e906b2f174ba2da Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 23 Aug 2024 17:07:50 +0100 Subject: [PATCH 2/6] Add adyen payment for payment requests --- .../payments/adyen_service.rb | 170 +++++++++++ .../payments/adyen_service_spec.rb | 280 ++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 app/services/payment_requests/payments/adyen_service.rb create mode 100644 spec/services/payment_requests/payments/adyen_service_spec.rb diff --git a/app/services/payment_requests/payments/adyen_service.rb b/app/services/payment_requests/payments/adyen_service.rb new file mode 100644 index 00000000000..83842eb7c3d --- /dev/null +++ b/app/services/payment_requests/payments/adyen_service.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class AdyenService < BaseService + include Lago::Adyen::ErrorHandlable + include Customers::PaymentProviderFinder + + PENDING_STATUSES = %w[AuthorisedPending Received].freeze + SUCCESS_STATUSES = %w[Authorised SentForSettle SettleScheduled Settled Refunded].freeze + FAILED_STATUSES = %w[Cancelled CaptureFailed Error Expired Refused].freeze + + def initialize(payable = nil) + @payable = payable + + super(nil) + end + + def create + result.payable = payable + return result unless should_process_payment? + + unless payable.total_amount_cents.positive? + update_payable_payment_status(payment_status: :succeeded) + return result + end + + payable.increment_payment_attempts! + + res = create_adyen_payment + return result unless res + + adyen_success, _adyen_error = handle_adyen_response(res) + return result unless adyen_success + + payment = Payment.new( + payable: payable, + payment_provider_id: adyen_payment_provider.id, + payment_provider_customer_id: customer.adyen_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency.upcase, + provider_payment_id: res.response['pspReference'], + status: res.response['resultCode'] + ) + payment.save! + + payable_payment_status = payable_payment_status(payment.status) + update_payable_payment_status(payment_status: payable_payment_status) + + Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) if payment.should_sync_payment? + + result.payment = payment + result + end + + private + + attr_accessor :payable + + delegate :organization, :customer, to: :payable + + def should_process_payment? + return false if payable.payment_succeeded? + return false if adyen_payment_provider.blank? + + !!customer&.adyen_customer&.provider_customer_id + end + + def client + @client ||= Adyen::Client.new( + api_key: adyen_payment_provider.api_key, + env: adyen_payment_provider.environment, + live_url_prefix: adyen_payment_provider.live_prefix + ) + end + + def adyen_payment_provider + @adyen_payment_provider ||= payment_provider(customer) + end + + def update_payment_method_id + result = client.checkout.payments_api.payment_methods( + Lago::Adyen::Params.new(payment_method_params).to_h + ).response + + payment_method_id = result['storedPaymentMethods']&.first&.dig('id') + customer.adyen_customer.update!(payment_method_id:) if payment_method_id + end + + def create_adyen_payment + update_payment_method_id + + client.checkout.payments_api.payments(Lago::Adyen::Params.new(payment_params).to_h) + rescue Adyen::AuthenticationError, Adyen::ValidationError => e + deliver_error_webhook(e) + update_payable_payment_status(payment_status: :failed, deliver_webhook: false) + nil + rescue Adyen::AdyenError => e + deliver_error_webhook(e) + update_payable_payment_status(payment_status: :failed, deliver_webhook: false) + raise e + end + + def payment_method_params + { + merchantAccount: adyen_payment_provider.merchant_account, + shopperReference: customer.adyen_customer.provider_customer_id + } + end + + def payment_params + prms = { + amount: { + currency: payable.currency.upcase, + value: payable.total_amount_cents + }, + reference: "reference here", # invoice.number, + paymentMethod: { + type: 'scheme', + storedPaymentMethodId: customer.adyen_customer.payment_method_id + }, + shopperReference: customer.adyen_customer.provider_customer_id, + merchantAccount: adyen_payment_provider.merchant_account, + shopperInteraction: 'ContAuth', + recurringProcessingModel: 'UnscheduledCardOnFile' + } + prms[:shopperEmail] = customer.email if customer.email + prms + end + + def payable_payment_status(payment_status) + return :pending if PENDING_STATUSES.include?(payment_status) + return :succeeded if SUCCESS_STATUSES.include?(payment_status) + return :failed if FAILED_STATUSES.include?(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 + ) + end + + # TODO: + # def update_invoice_payment_status(payment_status:, deliver_webhook: true) + # result = Invoices::UpdateService.call( + # invoice:, + # params: { + # payment_status:, + # ready_for_payment_processing: payment_status.to_sym != :succeeded + # }, + # webhook_notification: deliver_webhook + # ) + # result.raise_if_error! + # end + + def deliver_error_webhook(adyen_error) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: customer.adyen_customer.provider_customer_id, + provider_error: { + message: adyen_error.msg, + error_code: adyen_error.code + } + }) + end + end + end +end diff --git a/spec/services/payment_requests/payments/adyen_service_spec.rb b/spec/services/payment_requests/payments/adyen_service_spec.rb new file mode 100644 index 00000000000..ab394a7f43d --- /dev/null +++ b/spec/services/payment_requests/payments/adyen_service_spec.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::AdyenService, type: :service do + subject(:adyen_service) { described_class.new(payment_request) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:adyen_payment_provider) { create(:adyen_provider, organization:, code:) } + let(:adyen_customer) { create(:adyen_customer, customer:) } + let(:adyen_client) { instance_double(Adyen::Client) } + let(:payments_api) { Adyen::PaymentsApi.new(adyen_client, 70) } + let(:checkout) { Adyen::Checkout.new(adyen_client, 70) } + let(:payments_response) { generate(:adyen_payments_response) } + let(:payment_methods_response) { generate(:adyen_payment_methods_response) } + let(:code) { "adyen_1" } + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2] + ) + end + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 599, + currency: "USD", + ready_for_payment_processing: true + ) + end + + describe "#create" do + before do + adyen_payment_provider + adyen_customer + + allow(Adyen::Client).to receive(:new) + .and_return(adyen_client) + allow(adyen_client).to receive(:checkout) + .and_return(checkout) + allow(checkout).to receive(:payments_api) + .and_return(payments_api) + allow(payments_api).to receive(:payments) + .and_return(payments_response) + allow(payments_api).to receive(:payment_methods) + .and_return(payment_methods_response) + end + + it "creates an adyen payment", :aggregate_failures do + result = adyen_service.create + + expect(result).to be_success + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.reload.ready_for_payment_processing).to eq(false) + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(payment_request) + expect(result.payment.payment_provider).to eq(adyen_payment_provider) + expect(result.payment.payment_provider_customer).to eq(adyen_customer) + expect(result.payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(result.payment.amount_currency).to eq(payment_request.currency) + expect(result.payment.status).to eq("Authorised") + + expect(adyen_customer.reload.payment_method_id) + .to eq(payment_methods_response.response["storedPaymentMethods"].first["id"]) + + expect(payments_api).to have_received(:payments) + + # TODO: add expection of the payload send to Adyen with the right data, + # for example the list of invoice ids within its metadata... + # does ayden params has metadata? + end + + xit "updates invoice payment status to succeeded", :aggregate_failures do + adyen_service.create + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_2.reload).to be_payment_succeeded + end + + context "with no payment provider" do + let(:adyen_payment_provider) { nil } + + it "does not creates a adyen payment", :aggregate_failures do + result = adyen_service.create + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + expect(payments_api).not_to have_received(:payments) + end + end + + context "with 0 amount" do + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 0, + amount_currency: "EUR", + invoices: [invoice] + ) + end + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 0, + currency: 'EUR' + ) + end + + it "does not creates a adyen payment", :aggregate_failures do + result = adyen_service.create + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + expect(result.payable).to be_payment_succeeded + expect(payments_api).not_to have_received(:payments) + end + end + + context "when customer does not have a provider customer id" do + before { adyen_customer.update!(provider_customer_id: nil) } + + it "does not creates a adyen payment", :aggregate_failures do + result = adyen_service.create + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + expect(payments_api).not_to have_received(:payments) + end + end + + context "with error response from adyen" do + let(:payments_error_response) { generate(:adyen_payments_error_response) } + + before do + allow(payments_api).to receive(:payments).and_return(payments_error_response) + end + + it "delivers an error webhook" do + expect { adyen_service.create }.to enqueue_job(SendWebhookJob) + .with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: adyen_customer.provider_customer_id, + provider_error: { + message: "There are no payment methods available for the given parameters.", + error_code: "validation" + } + ).on_queue(:webhook) + end + end + + context "with validation error on adyen" do + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + + let(:organization) do + create(:organization, webhook_url: "https://webhook.com") + end + + context "when changing payment method fails with invalid card" do + before do + allow(payments_api).to receive(:payment_methods) + .and_raise(Adyen::ValidationError.new("Invalid card number", nil)) + end + + it "delivers an error webhook" do + expect { adyen_service.create }.to enqueue_job(SendWebhookJob) + .with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: adyen_customer.provider_customer_id, + provider_error: { + message: "Invalid card number", + error_code: nil + } + ).on_queue(:webhook) + end + end + + context "when payment fails with invalid card" do + before do + allow(payments_api).to receive(:payments) + .and_raise(Adyen::ValidationError.new("Invalid card number", nil)) + end + + it "delivers an error webhook" do + expect { adyen_service.create }.to enqueue_job(SendWebhookJob) + .with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: adyen_customer.provider_customer_id, + provider_error: { + message: "Invalid card number", + error_code: nil + } + ).on_queue(:webhook) + end + end + end + + context "with error on adyen" do + let(:customer) do + create(:customer, organization:, payment_provider_code: code) + end + + let(:organization) do + create(:organization, webhook_url: "https://webhook.com") + end + + before do + allow(payments_api).to receive(:payments) + .and_raise(Adyen::AdyenError.new(nil, nil, "error", "code")) + end + + it "delivers an error webhook" do + expect { adyen_service.create }.to raise_error(Adyen::AdyenError) + .and enqueue_job(SendWebhookJob).with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: adyen_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "code" + } + ) + end + end + end + + # PRIVATE METHOD, DOH!!!!! + describe "#payment_method_params" do + subject(:payment_method_params) { adyen_service.__send__(:payment_method_params) } + + let(:params) do + { + merchantAccount: adyen_payment_provider.merchant_account, + shopperReference: adyen_customer.provider_customer_id + } + end + + before do + adyen_payment_provider + adyen_customer + end + + it "returns payment method params" do + expect(payment_method_params).to eq(params) + end + end +end From a662d67970a5bb935026688239654e704f20b6f4 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 23 Aug 2024 17:16:43 +0100 Subject: [PATCH 3/6] stripe: calculate payment status once --- app/services/payment_requests/payments/stripe_service.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/payment_requests/payments/stripe_service.rb b/app/services/payment_requests/payments/stripe_service.rb index cdc02be0228..294b83a097f 100644 --- a/app/services/payment_requests/payments/stripe_service.rb +++ b/app/services/payment_requests/payments/stripe_service.rb @@ -42,12 +42,13 @@ def create ) payment.save! + payable_payment_status = payable_payment_status(payment.status) update_payable_payment_status( - payment_status: payable_payment_status(payment.status), + payment_status: payable_payment_status, processing: payment.status == 'processing' ) update_invoices_payment_status( - payment_status: payable_payment_status(payment.status), + payment_status: payable_payment_status, processing: payment.status == 'processing' ) From 8469ea59b1c0c8589f5131fcc4bfa1f8d5db1c8d Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 23 Aug 2024 17:17:09 +0100 Subject: [PATCH 4/6] Adyen: update invoices status on payment success --- .../payments/adyen_service.rb | 25 ++++++++++--------- .../payments/adyen_service_spec.rb | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/services/payment_requests/payments/adyen_service.rb b/app/services/payment_requests/payments/adyen_service.rb index 83842eb7c3d..6e35e52ba67 100644 --- a/app/services/payment_requests/payments/adyen_service.rb +++ b/app/services/payment_requests/payments/adyen_service.rb @@ -46,6 +46,7 @@ def create payable_payment_status = payable_payment_status(payment.status) update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) if payment.should_sync_payment? @@ -143,18 +144,18 @@ def update_payable_payment_status(payment_status:, deliver_webhook: true) ) end - # TODO: - # def update_invoice_payment_status(payment_status:, deliver_webhook: true) - # result = Invoices::UpdateService.call( - # invoice:, - # params: { - # payment_status:, - # ready_for_payment_processing: payment_status.to_sym != :succeeded - # }, - # webhook_notification: deliver_webhook - # ) - # result.raise_if_error! - # end + def update_invoices_payment_status(payment_status:, deliver_webhook: true) + payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice:, + params: { + payment_status:, + ready_for_payment_processing: payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end def deliver_error_webhook(adyen_error) DeliverErrorWebhookService.call_async(payable, { diff --git a/spec/services/payment_requests/payments/adyen_service_spec.rb b/spec/services/payment_requests/payments/adyen_service_spec.rb index ab394a7f43d..705910d0cb6 100644 --- a/spec/services/payment_requests/payments/adyen_service_spec.rb +++ b/spec/services/payment_requests/payments/adyen_service_spec.rb @@ -93,7 +93,7 @@ # does ayden params has metadata? end - xit "updates invoice payment status to succeeded", :aggregate_failures do + it "updates invoice payment status to succeeded", :aggregate_failures do adyen_service.create expect(invoice_1.reload).to be_payment_succeeded From 3224256612676e2351adfe7457b71b6a7742dfac Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 23 Aug 2024 17:29:51 +0100 Subject: [PATCH 5/6] set expectation of the payload sent to adyen... ... also use payment request id as reference because payment request does not have an assigned number, should we? --- .../payments/adyen_service.rb | 2 +- .../payments/adyen_service_spec.rb | 30 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/services/payment_requests/payments/adyen_service.rb b/app/services/payment_requests/payments/adyen_service.rb index 6e35e52ba67..b64da75d85e 100644 --- a/app/services/payment_requests/payments/adyen_service.rb +++ b/app/services/payment_requests/payments/adyen_service.rb @@ -115,7 +115,7 @@ def payment_params currency: payable.currency.upcase, value: payable.total_amount_cents }, - reference: "reference here", # invoice.number, + reference: payable.id, paymentMethod: { type: 'scheme', storedPaymentMethodId: customer.adyen_customer.payment_method_id diff --git a/spec/services/payment_requests/payments/adyen_service_spec.rb b/spec/services/payment_requests/payments/adyen_service_spec.rb index 705910d0cb6..d243807286a 100644 --- a/spec/services/payment_requests/payments/adyen_service_spec.rb +++ b/spec/services/payment_requests/payments/adyen_service_spec.rb @@ -86,11 +86,30 @@ expect(adyen_customer.reload.payment_method_id) .to eq(payment_methods_response.response["storedPaymentMethods"].first["id"]) - expect(payments_api).to have_received(:payments) - - # TODO: add expection of the payload send to Adyen with the right data, - # for example the list of invoice ids within its metadata... - # does ayden params has metadata? + expect(payments_api) + .to have_received(:payments) + .with( + { + amount: { + currency: "USD", + value: 799 + }, + applicationInfo: { + externalPlatform: {integrator: "Lago", name: "Lago"}, + merchantApplication: {name: "Lago"} + }, + merchantAccount: adyen_payment_provider.merchant_account, + paymentMethod: { + storedPaymentMethodId: adyen_customer.payment_method_id, + type: "scheme" + }, + recurringProcessingModel: "UnscheduledCardOnFile", + reference: payment_request.id, + shopperEmail: customer.email, + shopperInteraction: "ContAuth", + shopperReference: adyen_customer.provider_customer_id + } + ) end it "updates invoice payment status to succeeded", :aggregate_failures do @@ -257,7 +276,6 @@ end end - # PRIVATE METHOD, DOH!!!!! describe "#payment_method_params" do subject(:payment_method_params) { adyen_service.__send__(:payment_method_params) } From 79a97cd8ba51d48af8c3f259326916091a6de4eb Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Mon, 26 Aug 2024 10:11:41 +0100 Subject: [PATCH 6/6] Wrap payment request and invoices payment status update... within a database transaction --- .../payments/adyen_service.rb | 11 +++++---- .../payments/stripe_service.rb | 23 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/services/payment_requests/payments/adyen_service.rb b/app/services/payment_requests/payments/adyen_service.rb index b64da75d85e..587ab2fac5b 100644 --- a/app/services/payment_requests/payments/adyen_service.rb +++ b/app/services/payment_requests/payments/adyen_service.rb @@ -42,11 +42,14 @@ def create provider_payment_id: res.response['pspReference'], status: res.response['resultCode'] ) - payment.save! - payable_payment_status = payable_payment_status(payment.status) - update_payable_payment_status(payment_status: payable_payment_status) - update_invoices_payment_status(payment_status: payable_payment_status) + ActiveRecord::Base.transaction do + payment.save! + + payable_payment_status = payable_payment_status(payment.status) + update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) + end Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) if payment.should_sync_payment? diff --git a/app/services/payment_requests/payments/stripe_service.rb b/app/services/payment_requests/payments/stripe_service.rb index 294b83a097f..b6e1922ffc1 100644 --- a/app/services/payment_requests/payments/stripe_service.rb +++ b/app/services/payment_requests/payments/stripe_service.rb @@ -40,17 +40,20 @@ def create provider_payment_id: stripe_result.id, status: stripe_result.status ) - payment.save! - payable_payment_status = payable_payment_status(payment.status) - update_payable_payment_status( - payment_status: payable_payment_status, - processing: payment.status == 'processing' - ) - update_invoices_payment_status( - payment_status: payable_payment_status, - processing: payment.status == 'processing' - ) + ActiveRecord::Base.transaction do + payment.save! + + payable_payment_status = payable_payment_status(payment.status) + update_payable_payment_status( + payment_status: payable_payment_status, + processing: payment.status == 'processing' + ) + update_invoices_payment_status( + payment_status: payable_payment_status, + processing: payment.status == 'processing' + ) + end result.payment = payment result