From 4a0f0c4cfaab4060d8936cf53bc477bc5b317284 Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 13 Jan 2025 13:50:34 +0100 Subject: [PATCH] misc(PaymentRequest): Apply payment idempotency refactor (#2986) ## Context This PR follow https://github.com/getlago/lago-api/pull/2962 in the refactor of the payment processing. It applied the logic that was applied to payment for invoices to payment requests The main goal of this refactor will be to: - Avoid double payment at all cost - Avoid code duplication between payment processor integration to make maintenance easier ## Description This PR applies the following logic for payment processing of payment requests: - Creates a payment (or retrieve the pending one in case of retry) - Delegate the payment processing to the dedicated `PaymentProviders::*::Payments::CreateService` responsible only for the payment creation and the error handling (including idempotency). The payment provider payment creation logic will be shared between types of payment and will rely only on the Lago Payment record --- .../invoices/payments/adyen_create_job.rb | 2 +- .../payments/gocardless_create_job.rb | 2 +- .../invoices/payments/stripe_create_job.rb | 2 +- .../payments/adyen_create_job.rb | 6 +- .../payment_requests/payments/create_job.rb | 19 + .../payments/gocardless_create_job.rb | 6 +- .../payments/stripe_create_job.rb | 6 +- .../payment_providers/adyen_provider.rb | 2 +- app/models/payment_providers/base_provider.rb | 2 +- .../payment_providers/gocardless_provider.rb | 2 +- .../payment_providers/stripe_provider.rb | 2 +- .../invoices/payments/adyen_service.rb | 14 +- .../invoices/payments/create_service.rb | 27 +- .../invoices/payments/gocardless_service.rb | 15 +- .../invoices/payments/stripe_service.rb | 17 +- .../adyen/payments/create_service.rb | 22 +- .../create_payment_factory.rb | 4 +- .../gocardless/payments/create_service.rb | 27 +- .../stripe/payments/create_service.rb | 34 +- .../payment_requests/create_service.rb | 2 +- .../payments/adyen_service.rb | 93 ---- .../payments/create_service.rb | 149 +++++- .../payments/gocardless_service.rb | 104 ---- .../payments/stripe_service.rb | 125 ----- .../payments/adyen_create_job_spec.rb | 29 +- .../payments/create_job_spec.rb | 22 + .../payments/gocardless_create_job_spec.rb | 29 +- .../payments/stripe_create_job_spec.rb | 29 +- .../invoices/payments/adyen_service_spec.rb | 5 +- .../invoices/payments/create_service_spec.rb | 25 +- .../payments/gocardless_service_spec.rb | 3 +- .../adyen/payments/create_service_spec.rb | 11 +- .../create_payment_factory_spec.rb | 2 +- .../payments/create_service_spec.rb | 11 +- .../stripe/payments/create_service_spec.rb | 29 +- .../payment_requests/create_service_spec.rb | 4 +- .../payments/adyen_service_spec.rb | 303 ----------- .../payments/create_service_spec.rb | 494 +++++++++++++++++- .../payments/gocardless_service_spec.rb | 227 -------- .../payments/stripe_service_spec.rb | 349 ------------- 40 files changed, 785 insertions(+), 1471 deletions(-) create mode 100644 app/jobs/payment_requests/payments/create_job.rb create mode 100644 spec/jobs/payment_requests/payments/create_job_spec.rb diff --git a/app/jobs/invoices/payments/adyen_create_job.rb b/app/jobs/invoices/payments/adyen_create_job.rb index d20810833ec..575aaf8b5d5 100644 --- a/app/jobs/invoices/payments/adyen_create_job.rb +++ b/app/jobs/invoices/payments/adyen_create_job.rb @@ -10,7 +10,7 @@ class AdyenCreateJob < ApplicationJob retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 6 def perform(invoice) - # NOTE: Legacy job, kept only to avoid existing jobs + # NOTE: Legacy job, kept only to avoid failure with existing jobs Invoices::Payments::CreateService.call!(invoice:, payment_provider: :adyen) end diff --git a/app/jobs/invoices/payments/gocardless_create_job.rb b/app/jobs/invoices/payments/gocardless_create_job.rb index 70f821ea797..915cbf7a489 100644 --- a/app/jobs/invoices/payments/gocardless_create_job.rb +++ b/app/jobs/invoices/payments/gocardless_create_job.rb @@ -8,7 +8,7 @@ class GocardlessCreateJob < ApplicationJob unique :until_executed, on_conflict: :log def perform(invoice) - # NOTE: Legacy job, kept only to avoid existing jobs + # NOTE: Legacy job, kept only to avoid faileure with existing jobs Invoices::Payments::CreateService.call!(invoice:, payment_provider: :gocardless) end diff --git a/app/jobs/invoices/payments/stripe_create_job.rb b/app/jobs/invoices/payments/stripe_create_job.rb index 413755e5374..3f2cc462046 100644 --- a/app/jobs/invoices/payments/stripe_create_job.rb +++ b/app/jobs/invoices/payments/stripe_create_job.rb @@ -13,7 +13,7 @@ class StripeCreateJob < ApplicationJob retry_on Invoices::Payments::RateLimitError, wait: :polynomially_longer, attempts: 6 def perform(invoice) - # NOTE: Legacy job, kept only to avoid existing jobs + # NOTE: Legacy job, kept only to avoid faileure with existing jobs Invoices::Payments::CreateService.call!(invoice:, payment_provider: :stripe) end diff --git a/app/jobs/payment_requests/payments/adyen_create_job.rb b/app/jobs/payment_requests/payments/adyen_create_job.rb index 872e2034760..51c7220cbaf 100644 --- a/app/jobs/payment_requests/payments/adyen_create_job.rb +++ b/app/jobs/payment_requests/payments/adyen_create_job.rb @@ -10,11 +10,9 @@ class AdyenCreateJob < ApplicationJob retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 6 def perform(payable) - result = PaymentRequests::Payments::AdyenService.new(payable).create + # NOTE: Legacy job, kept only to avoid faileure with existing jobs - PaymentRequestMailer.with(payment_request: payable).requested.deliver_later if result.payable&.payment_failed? - - result.raise_if_error! + PaymentRequests::Payments::CreateService.call!(payable:, payment_provider: 'adyen') end end end diff --git a/app/jobs/payment_requests/payments/create_job.rb b/app/jobs/payment_requests/payments/create_job.rb new file mode 100644 index 00000000000..aacef461a79 --- /dev/null +++ b/app/jobs/payment_requests/payments/create_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class CreateJob < ApplicationJob + queue_as 'providers' + + unique :until_executed, on_conflict: :log + + retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6 + + def perform(payable:, payment_provider:) + PaymentRequests::Payments::CreateService.call!(payable:, payment_provider:) + end + end + end +end diff --git a/app/jobs/payment_requests/payments/gocardless_create_job.rb b/app/jobs/payment_requests/payments/gocardless_create_job.rb index ccca63cdf57..ef5fd23bbca 100644 --- a/app/jobs/payment_requests/payments/gocardless_create_job.rb +++ b/app/jobs/payment_requests/payments/gocardless_create_job.rb @@ -8,11 +8,9 @@ class GocardlessCreateJob < ApplicationJob unique :until_executed, on_conflict: :log def perform(payable) - result = PaymentRequests::Payments::GocardlessService.new(payable).create + # NOTE: Legacy job, kept only to avoid faileure with existing jobs - PaymentRequestMailer.with(payment_request: payable).requested.deliver_later if result.payable&.payment_failed? - - result.raise_if_error! + PaymentRequests::Payments::CreateService.call!(payable:, payment_provider: 'gocardless') end end end diff --git a/app/jobs/payment_requests/payments/stripe_create_job.rb b/app/jobs/payment_requests/payments/stripe_create_job.rb index 9bfcbc42cf3..6965f53dcc8 100644 --- a/app/jobs/payment_requests/payments/stripe_create_job.rb +++ b/app/jobs/payment_requests/payments/stripe_create_job.rb @@ -11,11 +11,9 @@ class StripeCreateJob < ApplicationJob retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6 def perform(payable) - result = PaymentRequests::Payments::StripeService.new(payable).create + # NOTE: Legacy job, kept only to avoid faileure with existing jobs - PaymentRequestMailer.with(payment_request: payable).requested.deliver_later if result.payable&.payment_failed? - - result.raise_if_error! + PaymentRequests::Payments::CreateService.call!(payable:, payment_provider: 'stripe') end end end diff --git a/app/models/payment_providers/adyen_provider.rb b/app/models/payment_providers/adyen_provider.rb index d45af2703eb..057c4414346 100644 --- a/app/models/payment_providers/adyen_provider.rb +++ b/app/models/payment_providers/adyen_provider.rb @@ -4,7 +4,7 @@ module PaymentProviders class AdyenProvider < BaseProvider SUCCESS_REDIRECT_URL = 'https://www.adyen.com/' - PENDING_STATUSES = %w[AuthorisedPending Received].freeze + PROCESSING_STATUSES = %w[AuthorisedPending Received].freeze SUCCESS_STATUSES = %w[Authorised SentForSettle SettleScheduled Settled Refunded].freeze FAILED_STATUSES = %w[Cancelled CaptureFailed Error Expired Refused].freeze diff --git a/app/models/payment_providers/base_provider.rb b/app/models/payment_providers/base_provider.rb index 813bb6435f0..6970ba9b2d3 100644 --- a/app/models/payment_providers/base_provider.rb +++ b/app/models/payment_providers/base_provider.rb @@ -28,7 +28,7 @@ class BaseProvider < ApplicationRecord settings_accessors :webhook_secret, :success_redirect_url def determine_payment_status(payment_status) - return :pending if self.class::PENDING_STATUSES.include?(payment_status) + return :processing if self.class::PROCESSING_STATUSES.include?(payment_status) return :succeeded if self.class::SUCCESS_STATUSES.include?(payment_status) return :failed if self.class::FAILED_STATUSES.include?(payment_status) diff --git a/app/models/payment_providers/gocardless_provider.rb b/app/models/payment_providers/gocardless_provider.rb index 8ee648424c3..69e725e5c33 100644 --- a/app/models/payment_providers/gocardless_provider.rb +++ b/app/models/payment_providers/gocardless_provider.rb @@ -4,7 +4,7 @@ module PaymentProviders class GocardlessProvider < BaseProvider SUCCESS_REDIRECT_URL = 'https://gocardless.com/' - PENDING_STATUSES = %w[pending_customer_approval pending_submission submitted confirmed].freeze + PROCESSING_STATUSES = %w[pending_customer_approval pending_submission submitted confirmed].freeze SUCCESS_STATUSES = %w[paid_out].freeze FAILED_STATUSES = %w[cancelled customer_approval_denied failed charged_back].freeze diff --git a/app/models/payment_providers/stripe_provider.rb b/app/models/payment_providers/stripe_provider.rb index 64b2e2c7830..20737ae3fba 100644 --- a/app/models/payment_providers/stripe_provider.rb +++ b/app/models/payment_providers/stripe_provider.rb @@ -18,7 +18,7 @@ class StripeProvider < BaseProvider charge.dispute.closed ].freeze - PENDING_STATUSES = %w[ + PROCESSING_STATUSES = %w[ processing requires_capture requires_action diff --git a/app/services/invoices/payments/adyen_service.rb b/app/services/invoices/payments/adyen_service.rb index 7444f33639d..84bccc04485 100644 --- a/app/services/invoices/payments/adyen_service.rb +++ b/app/services/invoices/payments/adyen_service.rb @@ -6,10 +6,6 @@ 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(invoice = nil) @invoice = invoice @@ -30,7 +26,7 @@ def update_payment_status(provider_payment_id:, status:, metadata: {}) payment.update!(status:) - invoice_payment_status = invoice_payment_status(status) + invoice_payment_status = payment.payment_provider&.determine_payment_status(status) update_invoice_payment_status(payment_status: invoice_payment_status) result @@ -175,14 +171,6 @@ def payment_url_params prms end - def invoice_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_invoice_payment_status(payment_status:, deliver_webhook: true) result = Invoices::UpdateService.call( invoice:, diff --git a/app/services/invoices/payments/create_service.rb b/app/services/invoices/payments/create_service.rb index f7b11776088..8b31a1e9fef 100644 --- a/app/services/invoices/payments/create_service.rb +++ b/app/services/invoices/payments/create_service.rb @@ -43,13 +43,20 @@ def call result.payment = payment - payment_result = ::PaymentProviders::CreatePaymentFactory.new_instance(provider:, payment:).call! + payment_result = ::PaymentProviders::CreatePaymentFactory.new_instance( + provider:, + payment:, + reference: "#{invoice.organization.name} - Invoice #{invoice.number}", + metadata: { + lago_invoice_id: invoice.id, + lago_customer_id: invoice.customer_id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + ).call! payment_status = payment_result.payment.payable_payment_status - update_invoice_payment_status( - payment_status: (payment_status == "processing") ? :pending : payment_status, - processing: payment_status == "processing" - ) + update_invoice_payment_status(payment_status:) Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) if result.payment.should_sync_payment? @@ -58,10 +65,12 @@ def call result.payment = e.result.payment if e.result.payment.payable_payment_status&.to_sym != :pending + # Avoid notification for amount_too_small errors deliver_error_webhook(e.result) - update_invoice_payment_status(payment_status: e.result.payment.payable_payment_status) end + update_invoice_payment_status(payment_status: e.result.payment.payable_payment_status) + # Some errors should be investigated and need to be raised raise if e.result.reraise @@ -103,13 +112,13 @@ def current_payment_provider_customer .find_by(payment_provider_id: current_payment_provider.id) end - def update_invoice_payment_status(payment_status:, processing: false) + def update_invoice_payment_status(payment_status:) Invoices::UpdateService.call!( invoice: invoice, params: { - payment_status:, # NOTE: A proper `processing` payment status should be introduced for invoices - ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + payment_status: (payment_status.to_s == "processing") ? :pending : payment_status, + ready_for_payment_processing: %w[pending failed].include?(payment_status.to_s) }, webhook_notification: payment_status.to_sym == :succeeded ) diff --git a/app/services/invoices/payments/gocardless_service.rb b/app/services/invoices/payments/gocardless_service.rb index 1304533b87c..cc825caa347 100644 --- a/app/services/invoices/payments/gocardless_service.rb +++ b/app/services/invoices/payments/gocardless_service.rb @@ -5,11 +5,6 @@ module Payments class GocardlessService < BaseService include Customers::PaymentProviderFinder - PENDING_STATUSES = %w[pending_customer_approval pending_submission submitted confirmed] - .freeze - SUCCESS_STATUSES = %w[paid_out].freeze - FAILED_STATUSES = %w[cancelled customer_approval_denied failed charged_back].freeze - def initialize(invoice = nil) @invoice = invoice @@ -26,7 +21,7 @@ def update_payment_status(provider_payment_id:, status:) payment.update!(status:) - invoice_payment_status = invoice_payment_status(status) + invoice_payment_status = payment.payment_provider&.determine_payment_status(status) update_invoice_payment_status(payment_status: invoice_payment_status) result @@ -40,14 +35,6 @@ def update_payment_status(provider_payment_id:, status:) delegate :organization, :customer, to: :invoice - def invoice_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_invoice_payment_status(payment_status:, deliver_webhook: true) update_invoice_result = Invoices::UpdateService.call( invoice: result.invoice, diff --git a/app/services/invoices/payments/stripe_service.rb b/app/services/invoices/payments/stripe_service.rb index 22a0ea4dfaa..38f00ed4303 100644 --- a/app/services/invoices/payments/stripe_service.rb +++ b/app/services/invoices/payments/stripe_service.rb @@ -5,11 +5,6 @@ module Payments class StripeService < BaseService include Customers::PaymentProviderFinder - PENDING_STATUSES = %w[processing requires_capture requires_action requires_confirmation requires_payment_method] - .freeze - SUCCESS_STATUSES = %w[succeeded].freeze - FAILED_STATUSES = %w[canceled].freeze - def initialize(invoice = nil) @invoice = invoice @@ -37,7 +32,7 @@ def update_payment_status(organization_id:, status:, stripe_payment:) payment.update!(status:) update_invoice_payment_status( - payment_status: invoice_payment_status(status), + payment_status: payment.payment_provider&.determine_payment_status(status), processing: status == "processing" ) @@ -89,7 +84,7 @@ def create_payment(stripe_payment, invoice: nil) status: "pending" ) - status = invoice_payment_status(stripe_payment.status) + status = payment.payment_provider&.determine_payment_status(stripe_payment.status) status = (status.to_sym == :pending) ? :processing : status payment.provider_payment_id = stripe_payment.id @@ -150,14 +145,6 @@ def description "#{organization.name} - Invoice #{invoice.number}" end - def invoice_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&.to_sym - end - def update_invoice_payment_status(payment_status:, deliver_webhook: true, processing: false) result = Invoices::UpdateService.call( invoice: invoice.presence || @result.invoice, diff --git a/app/services/payment_providers/adyen/payments/create_service.rb b/app/services/payment_providers/adyen/payments/create_service.rb index 8fd5d854fd7..9b1ad515a4f 100644 --- a/app/services/payment_providers/adyen/payments/create_service.rb +++ b/app/services/payment_providers/adyen/payments/create_service.rb @@ -4,12 +4,10 @@ module PaymentProviders module Adyen module Payments class CreateService < BaseService - PROCESSING_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(payment:) + def initialize(payment:, reference:, metadata:) @payment = payment + @reference = reference + @metadata = metadata @invoice = payment.payable @provider_customer = payment.payment_provider_customer @@ -29,7 +27,7 @@ def call payment.provider_payment_id = adyen_result.response["pspReference"] payment.status = adyen_result.response["resultCode"] - payment.payable_payment_status = payment_status_mapping(payment.status) + payment.payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) payment.save! result.payment = payment @@ -45,7 +43,7 @@ def call private - attr_reader :payment, :invoice, :provider_customer + attr_reader :payment, :reference, :metadata, :invoice, :provider_customer delegate :payment_provider, :customer, to: :provider_customer @@ -92,7 +90,7 @@ def payment_params currency: payment.amount_currency.upcase, value: payment.amount_cents }, - reference: invoice.number, + reference: reference, paymentMethod: { type: "scheme", storedPaymentMethodId: provider_customer.payment_method_id @@ -106,14 +104,6 @@ def payment_params prms end - def payment_status_mapping(payment_status) - return :processing if PROCESSING_STATUSES.include?(payment_status) - return :succeeded if SUCCESS_STATUSES.include?(payment_status) - return :failed if FAILED_STATUSES.include?(payment_status) - - payment_status - end - def prepare_failed_result(error, reraise: false) result.error_message = error.msg result.error_code = error.code diff --git a/app/services/payment_providers/create_payment_factory.rb b/app/services/payment_providers/create_payment_factory.rb index 56f30805643..8f8e0ddb81d 100644 --- a/app/services/payment_providers/create_payment_factory.rb +++ b/app/services/payment_providers/create_payment_factory.rb @@ -2,8 +2,8 @@ module PaymentProviders class CreatePaymentFactory - def self.new_instance(provider:, payment:) - service_class(provider:).new(payment:) + def self.new_instance(provider:, payment:, reference:, metadata:) + service_class(provider:).new(payment:, reference:, metadata:) end def self.service_class(provider:) diff --git a/app/services/payment_providers/gocardless/payments/create_service.rb b/app/services/payment_providers/gocardless/payments/create_service.rb index 25fae2f193c..b9f07385367 100644 --- a/app/services/payment_providers/gocardless/payments/create_service.rb +++ b/app/services/payment_providers/gocardless/payments/create_service.rb @@ -17,19 +17,16 @@ def code end end - def initialize(payment:) + def initialize(payment:, reference:, metadata:) @payment = payment + @reference = reference + @metadata = metadata @invoice = payment.payable @provider_customer = payment.payment_provider_customer super end - PROCESSING_STATUSES = %w[pending_customer_approval pending_submission submitted confirmed] - .freeze - SUCCESS_STATUSES = %w[paid_out].freeze - FAILED_STATUSES = %w[cancelled customer_approval_denied failed charged_back].freeze - def call result.payment = payment @@ -37,7 +34,7 @@ def call payment.provider_payment_id = gocardless_result.id payment.status = gocardless_result.status - payment.payable_payment_status = payment_status_mapping(payment.status) + payment.payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) payment.save! result.payment = payment @@ -48,7 +45,7 @@ def call prepare_failed_result(e, reraise: true) end - attr_reader :payment, :invoice, :provider_customer + attr_reader :payment, :reference, :metadata, :invoice, :provider_customer delegate :payment_provider, :customer, to: :provider_customer @@ -83,11 +80,7 @@ def create_gocardless_payment amount: payment.amount_cents, currency: payment.amount_currency.upcase, retry_if_possible: false, - metadata: { - lago_customer_id: customer.id, - lago_invoice_id: invoice.id, - invoice_issuing_date: invoice.issuing_date.iso8601 - }, + metadata: metadata.except(:invoice_type), links: { mandate: mandate_id } @@ -98,14 +91,6 @@ def create_gocardless_payment ) end - def payment_status_mapping(payment_status) - return :processing if PROCESSING_STATUSES.include?(payment_status) - return :succeeded if SUCCESS_STATUSES.include?(payment_status) - return :failed if FAILED_STATUSES.include?(payment_status) - - payment_status - end - def prepare_failed_result(error, reraise: false) result.error_message = error.message result.error_code = error.code diff --git a/app/services/payment_providers/stripe/payments/create_service.rb b/app/services/payment_providers/stripe/payments/create_service.rb index 52c51a6fd8e..903bfc0d978 100644 --- a/app/services/payment_providers/stripe/payments/create_service.rb +++ b/app/services/payment_providers/stripe/payments/create_service.rb @@ -4,13 +4,10 @@ module PaymentProviders module Stripe module Payments class CreateService < BaseService - PROCESSING_STATUSES = %w[processing requires_capture requires_action requires_confirmation requires_payment_method] - .freeze - SUCCESS_STATUSES = %w[succeeded].freeze - FAILED_STATUSES = %w[canceled].freeze - - def initialize(payment:) + def initialize(payment:, reference:, metadata:) @payment = payment + @reference = reference + @metadata = metadata @invoice = payment.payable @provider_customer = payment.payment_provider_customer @@ -24,7 +21,7 @@ def call payment.provider_payment_id = stripe_result.id payment.status = stripe_result.status - payment.payable_payment_status = payment_status_mapping(payment.status) + payment.payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) payment.provider_payment_data = stripe_result.next_action if stripe_result.status == "requires_action" payment.save! @@ -56,18 +53,10 @@ def call private - attr_reader :payment, :invoice, :provider_customer + attr_reader :payment, :reference, :metadata, :invoice, :provider_customer delegate :payment_provider, to: :provider_customer - def payment_status_mapping(payment_status) - return :processing if PROCESSING_STATUSES.include?(payment_status) - return :succeeded if SUCCESS_STATUSES.include?(payment_status) - return :failed if FAILED_STATUSES.include?(payment_status) - - payment_status - end - def handle_requires_action(payment) SendWebhookJob.perform_later("payment.requires_action", payment, { provider_customer_id: provider_customer.provider_customer_id @@ -133,13 +122,8 @@ def payment_intent_payload off_session: off_session?, return_url: success_redirect_url, error_on_requires_action: error_on_requires_action?, - description:, - metadata: { - lago_customer_id: provider_customer.customer_id, - lago_invoice_id: invoice.id, - invoice_issuing_date: invoice.issuing_date.iso8601, - invoice_type: invoice.invoice_type - } + description: reference, + metadata: metadata } end @@ -159,10 +143,6 @@ def error_on_requires_action? invoice.customer.country != "IN" end - def description - "#{invoice.organization.name} - Invoice #{invoice.number}" - end - def prepare_failed_result(error, reraise: false, payable_payment_status: :failed) result.error_message = error.message result.error_code = error.code diff --git a/app/services/payment_requests/create_service.rb b/app/services/payment_requests/create_service.rb index a349f3234eb..30e8649c0af 100644 --- a/app/services/payment_requests/create_service.rb +++ b/app/services/payment_requests/create_service.rb @@ -28,7 +28,7 @@ def call after_commit do SendWebhookJob.perform_later("payment_request.created", payment_request) - payment_result = Payments::CreateService.call(payment_request) + payment_result = PaymentRequests::Payments::CreateService.call_async(payable: payment_request) PaymentRequestMailer.with(payment_request:).requested.deliver_later unless payment_result.success? end diff --git a/app/services/payment_requests/payments/adyen_service.rb b/app/services/payment_requests/payments/adyen_service.rb index dc40208037b..37b115a09a4 100644 --- a/app/services/payment_requests/payments/adyen_service.rb +++ b/app/services/payment_requests/payments/adyen_service.rb @@ -12,48 +12,6 @@ def initialize(payable = nil) 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) - unless adyen_success - update_payable_payment_status(payment_status: :failed, deliver_webhook: false) - return result - end - - 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 = adyen_payment_provider.determine_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? - - result.payment = payment - result - end - def generate_payment_url return result unless should_process_payment? @@ -124,57 +82,6 @@ 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) - result.service_failure!(code: e.code, message: e.message) - nil - 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: "Overdue invoices", - 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 payment_url_params prms = { reference: "Overdue invoices", diff --git a/app/services/payment_requests/payments/create_service.rb b/app/services/payment_requests/payments/create_service.rb index fad6476e3df..b0cbb8727eb 100644 --- a/app/services/payment_requests/payments/create_service.rb +++ b/app/services/payment_requests/payments/create_service.rb @@ -3,35 +3,156 @@ module PaymentRequests module Payments class CreateService < BaseService - def initialize(payable) + include Customers::PaymentProviderFinder + + def initialize(payable:, payment_provider: nil) @payable = payable + @provider = payment_provider&.to_sym super end def call - return result.not_found_failure!(resource: "payment_provider") unless payment_provider - - case payment_provider - when :adyen - PaymentRequests::Payments::AdyenCreateJob.perform_later(payable) - when :gocardless - PaymentRequests::Payments::GocardlessCreateJob.perform_later(payable) - when :stripe - PaymentRequests::Payments::StripeCreateJob.perform_later(payable) + return result.not_found_failure!(resource: "payment_provider") unless provider + + 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 + + if processing_payment + # Payment is being processed, return the existing payment + # Status will be updated via webhooks + result.payment = processing_payment + return result end + payable.increment_payment_attempts! + + payment ||= Payment.create_with( + payment_provider_id: current_payment_provider.id, + payment_provider_customer_id: current_payment_provider_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency, + status: "pending" + ).find_or_create_by!( + payable:, + payable_payment_status: "pending" + ) + + result.payment = payment + + payment_result = ::PaymentProviders::CreatePaymentFactory.new_instance( + provider:, + payment:, + reference: "#{organization.name} - Overdue invoices", + metadata: { + lago_customer_id: payable.customer_id, + lago_payable_id: payable.id, + lago_payable_type: payable.class.name + } + ).call! + + update_payable_payment_status(payment_status: payment_result.payment.payable_payment_status) + update_invoices_payment_status(payment_status: payment_result.payment.payable_payment_status) + + PaymentRequestMailer.with(payment_request: payable).requested.deliver_later if payable.payment_failed? + + result + rescue BaseService::ServiceFailure => e + result.payment = e.result.payment + deliver_error_webhook(e.result) + update_payable_payment_status(payment_status: e.result.payment.payable_payment_status) + + # Some errors should be investigated and need to be raised + raise if e.result.reraise + + result + end + + def call_async + return result.not_found_failure!(resource: "payment_provider") unless provider + + PaymentRequests::Payments::CreateJob.perform_later(payable:, payment_provider: provider) + + result.payment_provider = provider result - rescue ActiveJob::Uniqueness::JobNotUnique => e - Sentry.capture_exception(e) end private attr_reader :payable - def payment_provider - payable.customer.payment_provider&.to_sym + delegate :customer, :organization, to: :payable + + def provider + @provider ||= payable.customer.payment_provider&.to_sym + end + + def should_process_payment? + return false if payable.payment_succeeded? + return false if current_payment_provider.blank? + + current_payment_provider_customer&.provider_customer_id + end + + def current_payment_provider + @current_payment_provider ||= payment_provider(customer) + end + + def current_payment_provider_customer + @current_payment_provider_customer ||= customer.payment_provider_customers + .find_by(payment_provider_id: current_payment_provider.id) + end + + def update_payable_payment_status(payment_status:) + PaymentRequests::UpdateService.call!( + payable: payable, + params: { + # NOTE: A proper `processing` payment status should be introduced for invoices + payment_status: (payment_status.to_s == "processing") ? :pending : payment_status, + ready_for_payment_processing: payment_status.to_sym == :failed + }, + webhook_notification: payment_status.to_sym == :succeeded + ) + end + + def update_invoices_payment_status(payment_status:) + payable.invoices.each do |invoice| + Invoices::UpdateService.call!( + invoice:, + params: { + # NOTE: A proper `processing` payment status should be introduced for invoices + payment_status: (payment_status.to_s == "processing") ? :pending : payment_status, + ready_for_payment_processing: payment_status.to_sym == :failed + }, + webhook_notification: payment_status.to_sym == :succeeded + ) + end + end + + def deliver_error_webhook(payment_result) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: current_payment_provider_customer.provider_customer_id, + provider_error: { + message: payment_result.error_message, + error_code: payment_result.error_code + } + }) + end + + def processing_payment + @processing_payment ||= Payment.find_by( + payable_id: payable.id, + payment_provider_id: current_payment_provider.id, + payment_provider_customer_id: current_payment_provider_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency, + payable_payment_status: "processing" + ) end end end diff --git a/app/services/payment_requests/payments/gocardless_service.rb b/app/services/payment_requests/payments/gocardless_service.rb index 8577c58b412..43beab6dc0c 100644 --- a/app/services/payment_requests/payments/gocardless_service.rb +++ b/app/services/payment_requests/payments/gocardless_service.rb @@ -24,48 +24,6 @@ def initialize(payable = nil) 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! - - gocardless_result = create_gocardless_payment - return result unless gocardless_result - - payment = Payment.new( - payable: payable, - payment_provider_id: gocardless_payment_provider.id, - payment_provider_customer_id: customer.gocardless_customer.id, - amount_cents: gocardless_result.amount, - amount_currency: gocardless_result.currency&.upcase, - provider_payment_id: gocardless_result.id, - status: gocardless_result.status - ) - - payment.save! - - payable_payment_status = gocardless_payment_provider.determine_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? - - result.payment = payment - result - rescue MandateNotFoundError => e - deliver_error_webhook(e) - update_payable_payment_status(payment_status: :failed, deliver_webhook: false) - - result.service_failure!(code: e.code, message: e.message) - 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 @@ -94,13 +52,6 @@ def update_payment_status(provider_payment_id:, status:) delegate :organization, :customer, to: :payable - def should_process_payment? - return false if payable.payment_succeeded? - return false if gocardless_payment_provider.blank? - - !!customer&.gocardless_customer&.provider_customer_id - end - def client @client ||= GoCardlessPro::Client.new( access_token: gocardless_payment_provider.access_token, @@ -112,51 +63,6 @@ def gocardless_payment_provider @gocardless_payment_provider ||= payment_provider(customer) end - def mandate_id - result = client.mandates.list( - params: { - customer: customer.gocardless_customer.provider_customer_id, - status: %w[pending_customer_approval pending_submission submitted active] - } - ) - - mandate = result&.records&.first - - raise MandateNotFoundError unless mandate - - customer.gocardless_customer.provider_mandate_id = mandate.id - customer.gocardless_customer.save! - - mandate.id - end - - def create_gocardless_payment - client.payments.create( - params: { - amount: payable.total_amount_cents, - currency: payable.currency.upcase, - retry_if_possible: false, - metadata: { - lago_customer_id: customer.id, - lago_payable_id: payable.id, - lago_payable_type: payable.class.name - }, - links: { - mandate: mandate_id - } - }, - headers: { - 'Idempotency-Key' => "#{payable.id}/#{payable.payment_attempts}" - } - ) - rescue GoCardlessPro::Error => e - deliver_error_webhook(e) - update_payable_payment_status(payment_status: :failed, deliver_webhook: false) - - result.service_failure!(code: e.code, message: e.message) - nil - end - def update_payable_payment_status(payment_status:, deliver_webhook: true) UpdateService.call( payable: result.payable, @@ -181,16 +87,6 @@ def update_invoices_payment_status(payment_status:, deliver_webhook: true) end end - def deliver_error_webhook(gocardless_error) - DeliverErrorWebhookService.call_async(payable, { - provider_customer_id: customer.gocardless_customer.provider_customer_id, - provider_error: { - message: gocardless_error.message, - error_code: gocardless_error.code - } - }) - end - def payment_status_succeeded?(payment_status) payment_status.to_sym == :succeeded end diff --git a/app/services/payment_requests/payments/stripe_service.rb b/app/services/payment_requests/payments/stripe_service.rb index 96ecad72bd9..517842c73ad 100644 --- a/app/services/payment_requests/payments/stripe_service.rb +++ b/app/services/payment_requests/payments/stripe_service.rb @@ -11,60 +11,6 @@ def initialize(payable = nil) 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! - - stripe_result = create_stripe_payment - # NOTE: return if payment was not processed - return result unless stripe_result - - payment = Payment.new( - payable: payable, - payment_provider_id: stripe_payment_provider.id, - payment_provider_customer_id: customer.stripe_customer.id, - amount_cents: stripe_result.amount, - amount_currency: stripe_result.currency&.upcase, - provider_payment_id: stripe_result.id, - status: stripe_result.status - ) - - payment.save! - - payable_payment_status = stripe_payment_provider.determine_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" - ) - - result.payment = payment - result - rescue ::Stripe::AuthenticationError, ::Stripe::CardError, ::Stripe::InvalidRequestError, Stripe::PermissionError => e - # NOTE: Do not mark the payable as failed if the amount is too small for Stripe - # For now we keep it as pending. - return result if e.code == "amount_too_small" - - deliver_error_webhook(e) - update_payable_payment_status(payment_status: :failed, deliver_webhook: false) - result - rescue ::Stripe::RateLimitError, ::Stripe::APIConnectionError - raise # Let the auto-retry process do its own job - rescue ::Stripe::StripeError => e - deliver_error_webhook(e) - raise - end - def generate_payment_url return result unless should_process_payment? @@ -140,77 +86,6 @@ def stripe_api_key stripe_payment_provider.secret_key end - def stripe_payment_method - payment_method_id = customer.stripe_customer.payment_method_id - - if payment_method_id - # NOTE: Check if payment method still exists - customer_service = PaymentProviderCustomers::StripeService.new(customer.stripe_customer) - customer_service_result = customer_service.check_payment_method(payment_method_id) - return customer_service_result.payment_method.id if customer_service_result.success? - end - - # NOTE: Retrieve list of existing payment_methods - payment_method = Stripe::PaymentMethod.list( - { - customer: customer.stripe_customer.provider_customer_id - }, - { - api_key: stripe_api_key - } - ).first - customer.stripe_customer.payment_method_id = payment_method&.id - customer.stripe_customer.save! - - payment_method&.id - end - - def update_payment_method_id - result = Stripe::Customer.retrieve( - customer.stripe_customer.provider_customer_id, - { - api_key: stripe_api_key - } - ) - # TODO: stripe customer should be updated/deleted - return if result.deleted? - - if (payment_method_id = result.invoice_settings.default_payment_method || result.default_source) - customer.stripe_customer.update!(payment_method_id:) - end - end - - def create_stripe_payment - update_payment_method_id - - Stripe::PaymentIntent.create( - stripe_payment_payload, - { - api_key: stripe_api_key, - idempotency_key: "#{payable.id}/#{payable.payment_attempts}" - } - ) - end - - def stripe_payment_payload - { - amount: payable.total_amount_cents, - currency: payable.currency.downcase, - customer: customer.stripe_customer.provider_customer_id, - payment_method: stripe_payment_method, - payment_method_types: customer.stripe_customer.provider_payment_methods, - confirm: true, - off_session: true, - error_on_requires_action: true, - description:, - metadata: { - lago_customer_id: customer.id, - lago_payable_id: payable.id, - lago_payable_type: payable.class.name - } - } - end - def description "#{organization.name} - Overdue invoices" end diff --git a/spec/jobs/payment_requests/payments/adyen_create_job_spec.rb b/spec/jobs/payment_requests/payments/adyen_create_job_spec.rb index e02a78109ac..9c58d2372a6 100644 --- a/spec/jobs/payment_requests/payments/adyen_create_job_spec.rb +++ b/spec/jobs/payment_requests/payments/adyen_create_job_spec.rb @@ -5,40 +5,17 @@ RSpec.describe PaymentRequests::Payments::AdyenCreateJob, type: :job do let(:payment_request) { create(:payment_request) } - let(:adyen_service) { instance_double(PaymentRequests::Payments::AdyenService) } let(:service_result) { BaseService::Result.new } before do - allow(PaymentRequests::Payments::AdyenService).to receive(:new) - .with(payment_request) - .and_return(adyen_service) - allow(adyen_service).to receive(:create) + allow(PaymentRequests::Payments::CreateService).to receive(:call!) + .with(payable: payment_request, payment_provider: 'adyen') .and_return(service_result) end it 'calls the stripe create service' do described_class.perform_now(payment_request) - expect(PaymentRequests::Payments::AdyenService).to have_received(:new) - expect(adyen_service).to have_received(:create) - end - - it "does not send a payment requested email" do - expect { described_class.perform_now(payment_request) } - .not_to have_enqueued_mail(PaymentRequestMailer, :requested) - end - - context "when the payment fails" do - let(:service_result) do - BaseService::Result.new.tap do |result| - result.payable = instance_double(PaymentRequest, payment_failed?: true) - end - end - - it "sends a payment requested email" do - expect { described_class.perform_now(payment_request) } - .to have_enqueued_mail(PaymentRequestMailer, :requested) - .with(params: {payment_request:}, args: []) - end + expect(PaymentRequests::Payments::CreateService).to have_received(:call!) end end diff --git a/spec/jobs/payment_requests/payments/create_job_spec.rb b/spec/jobs/payment_requests/payments/create_job_spec.rb new file mode 100644 index 00000000000..c497849fa1f --- /dev/null +++ b/spec/jobs/payment_requests/payments/create_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::CreateJob, type: :job do + let(:payment_request) { create(:payment_request) } + + let(:service_result) { BaseService::Result.new } + let(:payment_provider) { "stripe" } + + before do + allow(PaymentRequests::Payments::CreateService).to receive(:call!) + .with(payable: payment_request, payment_provider:) + .and_return(service_result) + end + + it "calls the stripe create service" do + described_class.perform_now(payable: payment_request, payment_provider:) + + expect(PaymentRequests::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/payment_requests/payments/gocardless_create_job_spec.rb b/spec/jobs/payment_requests/payments/gocardless_create_job_spec.rb index e61382348b4..608835eb312 100644 --- a/spec/jobs/payment_requests/payments/gocardless_create_job_spec.rb +++ b/spec/jobs/payment_requests/payments/gocardless_create_job_spec.rb @@ -5,40 +5,17 @@ RSpec.describe PaymentRequests::Payments::GocardlessCreateJob, type: :job do let(:payment_request) { create(:payment_request) } - let(:gocardless_service) { instance_double(PaymentRequests::Payments::GocardlessService) } let(:service_result) { BaseService::Result.new } before do - allow(PaymentRequests::Payments::GocardlessService).to receive(:new) - .with(payment_request) - .and_return(gocardless_service) - allow(gocardless_service).to receive(:create) + allow(PaymentRequests::Payments::CreateService).to receive(:call!) + .with(payable: payment_request, payment_provider: "gocardless") .and_return(service_result) end it "calls the stripe create service" do described_class.perform_now(payment_request) - expect(PaymentRequests::Payments::GocardlessService).to have_received(:new) - expect(gocardless_service).to have_received(:create) - end - - it "does not send a payment requested email" do - expect { described_class.perform_now(payment_request) } - .not_to have_enqueued_mail(PaymentRequestMailer, :requested) - end - - context "when the payment fails" do - let(:service_result) do - BaseService::Result.new.tap do |result| - result.payable = instance_double(PaymentRequest, payment_failed?: true) - end - end - - it "sends a payment requested email" do - expect { described_class.perform_now(payment_request) } - .to have_enqueued_mail(PaymentRequestMailer, :requested) - .with(params: {payment_request:}, args: []) - end + expect(PaymentRequests::Payments::CreateService).to have_received(:call!) end end diff --git a/spec/jobs/payment_requests/payments/stripe_create_job_spec.rb b/spec/jobs/payment_requests/payments/stripe_create_job_spec.rb index a4ce546932c..fb59fac099e 100644 --- a/spec/jobs/payment_requests/payments/stripe_create_job_spec.rb +++ b/spec/jobs/payment_requests/payments/stripe_create_job_spec.rb @@ -5,40 +5,17 @@ RSpec.describe PaymentRequests::Payments::StripeCreateJob, type: :job do let(:payment_request) { create(:payment_request) } - let(:stripe_service) { instance_double(PaymentRequests::Payments::StripeService) } let(:service_result) { BaseService::Result.new } before do - allow(PaymentRequests::Payments::StripeService).to receive(:new) - .with(payment_request) - .and_return(stripe_service) - allow(stripe_service).to receive(:create) + allow(PaymentRequests::Payments::CreateService).to receive(:call!) + .with(payable: payment_request, payment_provider: "stripe") .and_return(service_result) end it "calls the stripe create service" do described_class.perform_now(payment_request) - expect(PaymentRequests::Payments::StripeService).to have_received(:new) - expect(stripe_service).to have_received(:create) - end - - it "does not send a payment requested email" do - expect { described_class.perform_now(payment_request) } - .not_to have_enqueued_mail(PaymentRequestMailer, :requested) - end - - context "when the payment fails" do - let(:service_result) do - BaseService::Result.new.tap do |result| - result.payable = instance_double(PaymentRequest, payment_failed?: true) - end - end - - it "sends a payment requested email" do - expect { described_class.perform_now(payment_request) } - .to have_enqueued_mail(PaymentRequestMailer, :requested) - .with(params: {payment_request:}, args: []) - end + expect(PaymentRequests::Payments::CreateService).to have_received(:call!) end end diff --git a/spec/services/invoices/payments/adyen_service_spec.rb b/spec/services/invoices/payments/adyen_service_spec.rb index 3c9baea8c20..829e0f23c58 100644 --- a/spec/services/invoices/payments/adyen_service_spec.rb +++ b/spec/services/invoices/payments/adyen_service_spec.rb @@ -8,7 +8,7 @@ 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_customer) { create(:adyen_customer, customer:, payment_provider: adyen_payment_provider) } let(:adyen_client) { instance_double(Adyen::Client) } let(:payments_api) { Adyen::PaymentsApi.new(adyen_client, 70) } let(:payment_links_api) { Adyen::PaymentLinksApi.new(adyen_client, 70) } @@ -55,7 +55,8 @@ :payment, payable: invoice, provider_payment_id: "ch_123456", - status: "Pending" + status: "Pending", + payment_provider: adyen_payment_provider ) end diff --git a/spec/services/invoices/payments/create_service_spec.rb b/spec/services/invoices/payments/create_service_spec.rb index 77cda59d23f..75bec167324 100644 --- a/spec/services/invoices/payments/create_service_spec.rb +++ b/spec/services/invoices/payments/create_service_spec.rb @@ -27,8 +27,17 @@ provider_customer allow(provider_class) - .to receive(:new).with(payment: an_instance_of(Payment)) - .and_return(provider_service) + .to receive(:new) + .with( + payment: an_instance_of(Payment), + reference: "#{invoice.organization.name} - Invoice #{invoice.number}", + metadata: { + lago_invoice_id: invoice.id, + lago_customer_id: customer.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + ).and_return(provider_service) allow(provider_service).to receive(:call!) .and_return(result) end @@ -69,7 +78,7 @@ it "calls the gocardless service" do create_service.call - expect(provider_class).to have_received(:new).with(payment: an_instance_of(Payment)) + expect(provider_class).to have_received(:new) expect(provider_service).to have_received(:call!) end end @@ -215,16 +224,18 @@ end context "when invoice is credit? and open?" do + let(:invoice) { create(:invoice, :credit, :open, customer:, organization:, total_amount_cents: 100) } + let(:wallet_transaction) { create(:wallet_transaction) } + let(:fee) { create(:fee, fee_type: :credit, invoice: invoice, invoiceable: wallet_transaction) } + before do + fee + allow(Invoices::Payments::DeliverErrorWebhookService) .to receive(:call_async).and_call_original end it "delivers an error webhook" do - wallet_transaction = create(:wallet_transaction) - create(:fee, fee_type: :credit, invoice: invoice, invoiceable: wallet_transaction) - invoice.update!(status: :open, invoice_type: :credit) - expect { create_service.call }.to raise_error(BaseService::ServiceFailure) expect(Invoices::Payments::DeliverErrorWebhookService).to have_received(:call_async) diff --git a/spec/services/invoices/payments/gocardless_service_spec.rb b/spec/services/invoices/payments/gocardless_service_spec.rb index 2fee837f1f5..ea98cc61560 100644 --- a/spec/services/invoices/payments/gocardless_service_spec.rb +++ b/spec/services/invoices/payments/gocardless_service_spec.rb @@ -33,7 +33,8 @@ :payment, payable: invoice, provider_payment_id: "ch_123456", - status: "pending_submission" + status: "pending_submission", + payment_provider: gocardless_payment_provider ) end diff --git a/spec/services/payment_providers/adyen/payments/create_service_spec.rb b/spec/services/payment_providers/adyen/payments/create_service_spec.rb index 3a05b7c3722..1e85f95d16a 100644 --- a/spec/services/payment_providers/adyen/payments/create_service_spec.rb +++ b/spec/services/payment_providers/adyen/payments/create_service_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe PaymentProviders::Adyen::Payments::CreateService, type: :service do - subject(:create_service) { described_class.new(payment:) } + subject(:create_service) { described_class.new(payment:, reference:, metadata:) } let(:customer) { create(:customer, payment_provider_code: code) } let(:organization) { customer.organization } @@ -17,6 +17,15 @@ let(:payments_response) { generate(:adyen_payments_response) } let(:payment_methods_response) { generate(:adyen_payment_methods_response) } let(:code) { "adyen_1" } + let(:reference) { "organization.name - Invoice #{invoice.number}" } + let(:metadata) do + { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + end let(:invoice) do create( diff --git a/spec/services/payment_providers/create_payment_factory_spec.rb b/spec/services/payment_providers/create_payment_factory_spec.rb index ad5f041b5cb..d237351d036 100644 --- a/spec/services/payment_providers/create_payment_factory_spec.rb +++ b/spec/services/payment_providers/create_payment_factory_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe PaymentProviders::CreatePaymentFactory, type: :service do - subject(:new_instance) { described_class.new_instance(provider:, payment:) } + subject(:new_instance) { described_class.new_instance(provider:, payment:, reference: '', metadata: {}) } let(:provider) { "stripe" } let(:payment) { create(:payment) } diff --git a/spec/services/payment_providers/gocardless/payments/create_service_spec.rb b/spec/services/payment_providers/gocardless/payments/create_service_spec.rb index 028bd7a7bea..19d9abaa2f2 100644 --- a/spec/services/payment_providers/gocardless/payments/create_service_spec.rb +++ b/spec/services/payment_providers/gocardless/payments/create_service_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe PaymentProviders::Gocardless::Payments::CreateService, type: :service do - subject(:create_service) { described_class.new(payment:) } + subject(:create_service) { described_class.new(payment:, reference:, metadata:) } let(:customer) { create(:customer, payment_provider_code: code) } let(:organization) { customer.organization } @@ -14,6 +14,15 @@ let(:gocardless_mandates_service) { instance_double(GoCardlessPro::Services::MandatesService) } let(:gocardless_list_response) { instance_double(GoCardlessPro::ListResponse) } let(:code) { "gocardless_1" } + let(:reference) { "organization.name - Invoice #{invoice.number}" } + let(:metadata) do + { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + end let(:invoice) do create( diff --git a/spec/services/payment_providers/stripe/payments/create_service_spec.rb b/spec/services/payment_providers/stripe/payments/create_service_spec.rb index 9868828b511..ce2d9aa7d51 100644 --- a/spec/services/payment_providers/stripe/payments/create_service_spec.rb +++ b/spec/services/payment_providers/stripe/payments/create_service_spec.rb @@ -3,13 +3,22 @@ require "rails_helper" RSpec.describe PaymentProviders::Stripe::Payments::CreateService, type: :service do - subject(:create_service) { described_class.new(payment:) } + subject(:create_service) { described_class.new(payment:, reference:, metadata:) } let(:customer) { create(:customer, payment_provider_code: code) } let(:organization) { customer.organization } let(:stripe_payment_provider) { create(:stripe_provider, organization:, code:) } let(:stripe_customer) { create(:stripe_customer, customer:, payment_method_id: "pm_123456", payment_provider: stripe_payment_provider) } let(:code) { "stripe_1" } + let(:reference) { "organization.name - Invoice #{invoice.number}" } + let(:metadata) do + { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + end let(:invoice) do create( @@ -311,13 +320,8 @@ off_session: true, return_url: create_service.__send__(:success_redirect_url), error_on_requires_action: true, - description: create_service.__send__(:description), - metadata: { - lago_customer_id: customer.id, - lago_invoice_id: invoice.id, - invoice_issuing_date: invoice.issuing_date.iso8601, - invoice_type: invoice.invoice_type - } + description: reference, + metadata: metadata } end @@ -337,14 +341,5 @@ end end end - - context "with #description" do - let(:description_call) { create_service.__send__(:description) } - let(:description) { "#{organization.name} - Invoice #{invoice.number}" } - - it "returns the description" do - expect(description_call).to eq(description) - end - end end end diff --git a/spec/services/payment_requests/create_service_spec.rb b/spec/services/payment_requests/create_service_spec.rb index 7587f15cdbb..a0523814634 100644 --- a/spec/services/payment_requests/create_service_spec.rb +++ b/spec/services/payment_requests/create_service_spec.rb @@ -111,11 +111,11 @@ end it "creates a payment for the payment request" do - allow(PaymentRequests::Payments::CreateService).to receive(:call).and_call_original + allow(PaymentRequests::Payments::CreateService).to receive(:call_async).and_call_original result = create_service.call - expect(PaymentRequests::Payments::CreateService).to have_received(:call).with(result.payment_request) + expect(PaymentRequests::Payments::CreateService).to have_received(:call_async).with(payable: result.payment_request) end context "when Payments::CreateService returns an error" do diff --git a/spec/services/payment_requests/payments/adyen_service_spec.rb b/spec/services/payment_requests/payments/adyen_service_spec.rb index f2738f9d93a..7f018c13d3d 100644 --- a/spec/services/payment_requests/payments/adyen_service_spec.rb +++ b/spec/services/payment_requests/payments/adyen_service_spec.rb @@ -49,289 +49,6 @@ ) 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) - .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: "Overdue invoices", - shopperEmail: customer.email, - shopperInteraction: "ContAuth", - shopperReference: adyen_customer.provider_customer_id - } - ) - end - - it "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 "when payment request payment status is already succeeded" do - let(:payment_request) do - create( - :payment_request, - organization:, - customer:, - payment_status: "succeeded", - amount_cents: 799, - amount_currency: "EUR" - ) - end - - it "does not creates a payment", :aggregate_failures do - result = adyen_service.create - - expect(result).to be_success - expect(result.payable).to be_payment_succeeded - expect(result.payment).to be_nil - - expect(payments_api).not_to have_received(:payments) - end - 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 - - it "marks the payment request as payment failed" do - result = adyen_service.create - - expect(result).to be_success - expect(result.payable).to be_payment_failed - 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 - - it "marks the payment request as payment failed" do - result = adyen_service.create - - expect(result).to be_success - expect(result.payable).to be_payment_failed - 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 - - it "marks the payment request as payment failed" do - result = adyen_service.create - - expect(result).to be_success - expect(result.payable).to be_payment_failed - 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 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 - - it "updates payment request payment status to failed" do - result = adyen_service.create - - expect(result).not_to be_success - expect(result.payable).to be_payment_failed - end - end - end - describe "#generate_payment_url" do let(:payment_links_api) { Adyen::PaymentLinksApi.new(adyen_client, 70) } let(:payment_links_response) { generate(:adyen_payment_links_response) } @@ -396,26 +113,6 @@ end end - 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 - describe "#update_payment_status" do subject(:result) do adyen_service.update_payment_status(provider_payment_id:, status:) diff --git a/spec/services/payment_requests/payments/create_service_spec.rb b/spec/services/payment_requests/payments/create_service_spec.rb index 72b86354a04..de51c3dc5ba 100644 --- a/spec/services/payment_requests/payments/create_service_spec.rb +++ b/spec/services/payment_requests/payments/create_service_spec.rb @@ -3,21 +3,497 @@ require "rails_helper" RSpec.describe PaymentRequests::Payments::CreateService, type: :service do - subject(:create_service) { described_class.new(payment_request) } + subject(:create_service) { described_class.new(payable: payment_request, payment_provider: provider) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, payment_provider: provider, payment_provider_code:) } + let(:provider) { "stripe" } + let(:payment_provider_code) { "stripe_1" } let(:payment_request) do - create(:payment_request, customer:, organization: customer.organization) + 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 - let(:customer) { create(:customer, payment_provider:) } describe "#call" do + let(:payment_provider) { create(:stripe_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:stripe_customer, payment_provider:, customer:) } + let(:provider_class) { PaymentProviders::Stripe::Payments::CreateService } + let(:provider_service) { instance_double(provider_class) } + + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(payable_payment_status: "succeeded") + end + end + + before do + provider_customer + + allow(provider_class) + .to receive(:new) + .with( + payment: an_instance_of(Payment), + reference: "#{organization.name} - Overdue invoices", + metadata: { + lago_customer_id: customer.id, + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest" + } + ).and_return(provider_service) + allow(provider_service).to receive(:call!) + .and_return(service_result) + end + + context "with adyen payment provider" do + let(:provider) { "adyen" } + let(:payment_provider) { create(:adyen_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:adyen_customer, payment_provider:, customer:) } + + let(:provider_class) { PaymentProviders::Adyen::Payments::CreateService } + let(:provider_service) { instance_double(provider_class) } + + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(payable_payment_status: "succeeded") + end + end + + it 'creates a payment and calls the adyen service' do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.ready_for_payment_processing).to eq(false) + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + expect(payment.payable).to eq(payment_request) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + it "updates invoice payment status to succeeded" do + create_service.call + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_2.reload).to be_payment_succeeded + end + + it "does not send a payment requested email" do + expect { create_service.call } + .not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment fails" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(payable_payment_status: "failed") + end + end + + it "sends a payment requested email" do + expect { create_service.call } + .to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + end + + context "with gocardless payment provider" do + let(:provider) { "gocardless" } + let(:payment_provider) { create(:gocardless_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:gocardless_customer, payment_provider:, customer:) } + + let(:provider_class) { PaymentProviders::Gocardless::Payments::CreateService } + let(:provider_service) { instance_double(provider_class) } + + it 'creates a payment and calls the gocardless service' do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.ready_for_payment_processing).to eq(false) + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + expect(payment.payable).to eq(payment_request) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + it "updates invoice payment status to succeeded" do + create_service.call + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_2.reload).to be_payment_succeeded + end + + it "does not send a payment requested email" do + expect { create_service.call } + .not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment fails" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(payable_payment_status: "failed") + end + end + + it "sends a payment requested email" do + expect { create_service.call } + .to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + end + + context "with stripe payment provider" do + it 'creates a payment and calls the stripe service' do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.ready_for_payment_processing).to eq(false) + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + expect(payment.payable).to eq(payment_request) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + it "updates invoice payment status to succeeded" do + create_service.call + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_2.reload).to be_payment_succeeded + end + + it "does not send a payment requested email" do + expect { create_service.call } + .not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment fails" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(payable_payment_status: "failed") + end + end + + it "sends a payment requested email" do + expect { create_service.call } + .to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + end + + context "when payment request payment status is succeeded" do + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + payment_status: "succeeded", + amount_cents: 799, + amount_currency: "EUR", + invoices: [invoice_1, invoice_2] + ) + end + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(0) + expect(result.payment).to be_nil + + expect(provider_class).not_to have_received(:new) + end + end + + context "with no payment provider" do + let(:payment_provider) { nil } + + it "does not creates a stripe payment", :aggregate_failures do + result = create_service.call + + expect(result).to be_success + + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + + expect(provider_class).not_to have_received(:new) + 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 stripe payment", :aggregate_failures do + result = create_service.call + + 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(provider_class).not_to have_received(:new) + end + end + + context "when customer does not have a provider customer id" do + before { provider_customer.update!(provider_customer_id: nil) } + + it "does not creates a stripe payment", :aggregate_failures do + result = create_service.call + + expect(result).to be_success + + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + expect(provider_class).not_to have_received(:new) + end + end + + context "when provider service raises a service failure" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(status: "pending", payable_payment_status: "pending") + r.error_message = "error" + r.error_code = "code" + r.reraise = true + end + end + + before do + allow(provider_service).to receive(:call!) + .and_raise(BaseService::ServiceFailure.new(service_result, code: "code", error_message: "error")) + end + + it "re-reaise the error and delivers an error webhook" do + expect { create_service.call } + .to raise_error(BaseService::ServiceFailure) + .and enqueue_job(SendWebhookJob) + .with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: provider_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "code" + } + ).on_queue(:webhook) + end + + context "when payment has a payable_payment_status" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(payable_payment_status: "failed") + r.error_message = "error" + r.error_code = "code" + r.reraise = true + end + end + + it "updates the payment request payment status" do + expect { create_service.call } + .to raise_error(BaseService::ServiceFailure) + + expect(payment_request.reload).to be_payment_failed + end + end + + context "when payable_payment_status is pending" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(status: "failed", payable_payment_status: "pending") + r.error_message = "stripe_error" + r.error_code = "amount_too_small" + end + end + + it "re-reaise the error and delivers an error webhook" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("pending") + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + end + end + + context "when payment status is processing" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = OpenStruct.new(payable_payment_status: "pending", status: "processing") + end + end + + it "creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payable).to be_payment_pending + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.ready_for_payment_processing).to eq(false) + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + expect(payment.payable_payment_status).to eq("pending") + expect(payment.payable).to eq(payment_request) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + end + + context 'when a payment exits' do + let(:payment) do + create( + :payment, + payable: payment_request, + payment_provider:, + payment_provider_customer: provider_customer, + amount_cents: payment_request.total_amount_cents, + amount_currency: payment_request.currency, + status: "pending", + payable_payment_status: payment_status + ) + end + + let(:payment_status) { "pending" } + + before { payment } + + it "retrieves the payment for processing" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to eq(payment) + + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + context "when payment is already processing" do + let(:payment_status) { "processing" } + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to eq(payment) + + expect(provider_class).not_to have_received(:new) + expect(provider_service).not_to have_received(:call!) + end + end + end + end + + describe "#call_async" do context "with adyen payment provider" do let(:payment_provider) { "adyen" } it "enqueues a job to create a adyen payment" do expect do - create_service.call - end.to have_enqueued_job(PaymentRequests::Payments::AdyenCreateJob) + create_service.call_async + end.to have_enqueued_job(PaymentRequests::Payments::CreateJob) end end @@ -26,8 +502,8 @@ it "enqueues a job to create a gocardless payment" do expect do - create_service.call - end.to have_enqueued_job(PaymentRequests::Payments::GocardlessCreateJob) + create_service.call_async + end.to have_enqueued_job(PaymentRequests::Payments::CreateJob) end end @@ -36,8 +512,8 @@ it "enqueues a job to create a stripe payment" do expect do - create_service.call - end.to have_enqueued_job(PaymentRequests::Payments::StripeCreateJob) + create_service.call_async + end.to have_enqueued_job(PaymentRequests::Payments::CreateJob) end end end diff --git a/spec/services/payment_requests/payments/gocardless_service_spec.rb b/spec/services/payment_requests/payments/gocardless_service_spec.rb index 38b5a20a2e8..6ef66814978 100644 --- a/spec/services/payment_requests/payments/gocardless_service_spec.rb +++ b/spec/services/payment_requests/payments/gocardless_service_spec.rb @@ -48,233 +48,6 @@ ) end - describe "#create" do - before do - gocardless_payment_provider - gocardless_customer - - allow(GoCardlessPro::Client).to receive(:new) - .and_return(gocardless_client) - allow(gocardless_client).to receive(:mandates) - .and_return(gocardless_mandates_service) - allow(gocardless_mandates_service).to receive(:list) - .and_return(gocardless_list_response) - allow(gocardless_list_response).to receive(:records) - .and_return([GoCardlessPro::Resources::Mandate.new("id" => "mandate_id")]) - allow(gocardless_client).to receive(:payments) - .and_return(gocardless_payments_service) - allow(gocardless_payments_service).to receive(:create) - .and_return(GoCardlessPro::Resources::Payment.new( - "id" => "_ID_", - "amount" => payment_request.total_amount_cents, - "currency" => payment_request.currency, - "status" => "paid_out" - )) - allow(Invoices::PrepaidCreditJob).to receive(:perform_later) - end - - it "creates a gocardless payment", :aggregate_failures do - result = gocardless_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(gocardless_payment_provider) - expect(result.payment.payment_provider_customer).to eq(gocardless_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("paid_out") - expect(gocardless_customer.reload.provider_mandate_id).to eq("mandate_id") - - expect(gocardless_payments_service).to have_received(:create).with( - { - headers: { - "Idempotency-Key" => "#{payment_request.id}/1" - }, - params: - { - amount: 799, - currency: "USD", - links: {mandate: "mandate_id"}, - metadata: { - lago_customer_id: customer.id, - lago_payable_id: payment_request.id, - lago_payable_type: "PaymentRequest" - }, - retry_if_possible: false - } - } - ) - end - - it "updates invoice payment status to succeeded", :aggregate_failures do - 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 "when payment request payment status is already succeeded" do - let(:payment_request) do - create( - :payment_request, - organization:, - customer:, - payment_status: "succeeded", - amount_cents: 799, - amount_currency: "EUR" - ) - end - - it "does not creates a payment", :aggregate_failures do - result = gocardless_service.create - - expect(result).to be_success - expect(result.payable).to be_payment_succeeded - expect(result.payment).to be_nil - - expect(gocardless_payments_service).not_to have_received(:create) - end - end - - context "with no payment provider" do - let(:gocardless_payment_provider) { nil } - - it "does not creates a gocardless payment", :aggregate_failures do - result = gocardless_service.create - - expect(result).to be_success - expect(result.payable).to eq(payment_request) - expect(result.payment).to be_nil - expect(gocardless_payments_service).not_to have_received(:create) - 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 gocardless payment", :aggregate_failures do - result = gocardless_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(gocardless_payments_service).not_to have_received(:create) - end - end - - context "when customer does not have a provider customer id" do - before { gocardless_customer.update!(provider_customer_id: nil) } - - it "does not creates a gocardless payment", :aggregate_failures do - result = gocardless_service.create - - expect(result).to be_success - expect(result.payable).to eq(payment_request) - expect(result.payment).to be_nil - expect(gocardless_payments_service).not_to have_received(:create) - end - end - - context "with error on gocardless" do - before do - allow(gocardless_payments_service).to receive(:create) - .and_raise(GoCardlessPro::Error.new("code" => "code", "message" => "error")) - end - - it "delivers an error webhook" do - gocardless_service.create - - expect(SendWebhookJob).to have_been_enqueued - .with( - "payment_request.payment_failure", - payment_request, - provider_customer_id: gocardless_customer.provider_customer_id, - provider_error: { - message: "error", - error_code: "code" - } - ) - end - - it "returns a service failure" do - result = gocardless_service.create - - expect(result).not_to be_success - expect(result.error.code).to eq("code") - expect(result.payable).to be_payment_failed - end - end - - context "when customer has no mandate to make a payment" do - before do - allow(gocardless_list_response).to receive(:records) - .and_return([]) - - allow(gocardless_payments_service).to receive(:create) - .and_raise(GoCardlessPro::Error.new("code" => "code", "message" => "error")) - end - - it "delivers an error webhook", :aggregate_failures do - result = gocardless_service.create - - expect(result).not_to be_success - expect(result.error).to be_a(BaseService::ServiceFailure) - expect(result.error.code).to eq("no_mandate_error") - expect(result.error.error_message).to eq("No mandate available for payment") - expect(result.payable.reload).to be_payment_failed - expect(result.payable.reload.ready_for_payment_processing).to eq(true) - - expect(SendWebhookJob).to have_been_enqueued - .with( - "payment_request.payment_failure", - payment_request, - provider_customer_id: gocardless_customer.provider_customer_id, - provider_error: { - message: "No mandate available for payment", - error_code: "no_mandate_error" - } - ) - end - - it "marks the payment request as payment failed" do - result = gocardless_service.create - - expect(result).not_to be_success - expect(result.error.code).to eq("no_mandate_error") - expect(payment_request.reload).to be_payment_failed - end - end - end - describe "#update_payment_status" do subject(:result) do gocardless_service.update_payment_status(provider_payment_id:, status:) diff --git a/spec/services/payment_requests/payments/stripe_service_spec.rb b/spec/services/payment_requests/payments/stripe_service_spec.rb index 17db961f8bd..df7bd59a8f0 100644 --- a/spec/services/payment_requests/payments/stripe_service_spec.rb +++ b/spec/services/payment_requests/payments/stripe_service_spec.rb @@ -47,355 +47,6 @@ ) end - describe "#create" do - let(:provider_customer_service) { instance_double(PaymentProviderCustomers::StripeService) } - - let(:provider_customer_service_result) do - BaseService::Result.new.tap do |result| - result.payment_method = Stripe::PaymentMethod.new(id: "pm_123456") - end - end - - let(:customer_response) do - File.read(Rails.root.join("spec/fixtures/stripe/customer_retrieve_response.json")) - end - - let(:payment_status) { "succeeded" } - - before do - stripe_payment_provider - stripe_customer - - allow(Stripe::PaymentIntent).to receive(:create) - .and_return( - Stripe::PaymentIntent.construct_from( - id: "ch_123456", - status: payment_status, - amount: payment_request.total_amount_cents, - currency: payment_request.currency - ) - ) - allow(SegmentTrackJob).to receive(:perform_later) - allow(PaymentProviderCustomers::StripeService).to receive(:new) - .and_return(provider_customer_service) - allow(provider_customer_service).to receive(:check_payment_method) - .and_return(provider_customer_service_result) - - stub_request(:get, "https://api.stripe.com/v1/customers/#{stripe_customer.provider_customer_id}") - .to_return(status: 200, body: customer_response, headers: {}) - end - - it "creates a stripe payment and a payment", :aggregate_failures do - result = stripe_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.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(stripe_payment_provider) - expect(result.payment.payment_provider_customer).to eq(stripe_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("succeeded") - - expect(Stripe::PaymentIntent).to have_received(:create).with( - { - amount: payment_request.total_amount_cents, - currency: payment_request.currency.downcase, - customer: customer.stripe_customer.provider_customer_id, - payment_method: stripe_payment_method_id, - payment_method_types: customer.stripe_customer.provider_payment_methods, - confirm: true, - off_session: true, - error_on_requires_action: true, - description: "#{organization.name} - Overdue invoices", - metadata: { - lago_customer_id: customer.id, - lago_payable_id: payment_request.id, - lago_payable_type: "PaymentRequest" - } - }, - hash_including( - { - api_key: an_instance_of(String), - idempotency_key: "#{payment_request.id}/#{payment_request.reload.payment_attempts}" - } - ) - ) - end - - it "updates invoice payment status to succeeded", :aggregate_failures do - stripe_service.create - - expect(invoice_1.reload).to be_payment_succeeded - expect(invoice_2.reload).to be_payment_succeeded - end - - context "when payment request payment status is succeeded" do - let(:payment_request) do - create( - :payment_request, - organization:, - customer:, - payment_status: "succeeded", - amount_cents: 799, - amount_currency: "EUR", - invoices: [invoice_1, invoice_2] - ) - end - - it "does not creates a payment", :aggregate_failures do - result = stripe_service.create - - expect(result).to be_success - - expect(result.payable).to be_payment_succeeded - expect(result.payable.payment_attempts).to eq(0) - expect(result.payment).to be_nil - - expect(Stripe::PaymentIntent).not_to have_received(:create) - end - end - - context "with no payment provider" do - let(:stripe_payment_provider) { nil } - - it "does not creates a stripe payment", :aggregate_failures do - result = stripe_service.create - - expect(result).to be_success - - expect(result.payable).to eq(payment_request) - expect(result.payment).to be_nil - - expect(Stripe::PaymentIntent).not_to have_received(:create) - 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 stripe payment", :aggregate_failures do - result = stripe_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(Stripe::PaymentIntent).not_to have_received(:create) - end - end - - context "when customer does not have a provider customer id" do - before { stripe_customer.update!(provider_customer_id: nil) } - - it "does not creates a stripe payment", :aggregate_failures do - result = stripe_service.create - - expect(result).to be_success - - expect(result.payable).to eq(payment_request) - expect(result.payment).to be_nil - - expect(Stripe::PaymentIntent).not_to have_received(:create) - end - end - - context "when customer does not have a payment method" do - let(:stripe_customer) { create(:stripe_customer, customer:) } - - before do - allow(Stripe::Customer).to receive(:retrieve) - .and_return(Stripe::StripeObject.construct_from( - { - invoice_settings: { - default_payment_method: nil - }, - default_source: nil - } - )) - - allow(Stripe::PaymentMethod).to receive(:list) - .and_return(Stripe::ListObject.construct_from( - data: [ - { - id: "pm_123456", - object: "payment_method", - card: {brand: "visa"}, - created: 1_656_422_973, - customer: "cus_123456", - livemode: false, - metadata: {}, - type: "card" - } - ] - )) - end - - it "retrieves the payment method" do - result = stripe_service.create - - expect(result).to be_success - expect(customer.stripe_customer.reload).to be_present - expect(customer.stripe_customer.provider_customer_id).to eq(stripe_customer.provider_customer_id) - expect(customer.stripe_customer.payment_method_id).to eq("pm_123456") - - expect(Stripe::PaymentMethod).to have_received(:list) - expect(Stripe::PaymentIntent).to have_received(:create) - end - end - - context "with card error on stripe" do - let(:customer) { create(:customer, organization:, payment_provider_code: code) } - - let(:organization) do - create(:organization, webhook_url: "https://webhook.com") - end - - before do - allow(Stripe::PaymentIntent).to receive(:create) - .and_raise(::Stripe::CardError.new("error", {})) - allow(PaymentRequests::Payments::DeliverErrorWebhookService) - .to receive(:call_async) - .and_call_original - end - - it "delivers an error webhook" do - stripe_service.create - - expect(PaymentRequests::Payments::DeliverErrorWebhookService).to have_received(:call_async) - expect(SendWebhookJob).to have_been_enqueued - .with( - "payment_request.payment_failure", - payment_request, - provider_customer_id: stripe_customer.provider_customer_id, - provider_error: { - message: "error", - error_code: nil - } - ) - end - - it "marks the payment request as payment failed" do - result = stripe_service.create - - expect(result).to be_success - expect(result.payable).to be_payment_failed - end - end - - context "when payment request has a too small amount" do - let(:organization) { create(:organization) } - let(:customer) { create(:customer, organization:) } - - let(:payment_request) do - create( - :payment_request, - organization:, - customer:, - amount_cents: 20, - amount_currency: "EUR", - ready_for_payment_processing: true - ) - end - - before do - allow(Stripe::PaymentIntent).to receive(:create) - .and_raise(::Stripe::InvalidRequestError.new("amount_too_small", {}, code: "amount_too_small")) - end - - it "does not mark the payment request as failed" do - result = stripe_service.create - - expect(result).to be_success - expect(payment_request.reload).to be_payment_pending - end - end - - context "with random stripe error" do - let(:customer) { create(:customer, organization:, payment_provider_code: code) } - - let(:organization) do - create(:organization, webhook_url: "https://webhook.com") - end - - let(:stripe_error) { Stripe::StripeError.new("error") } - - before do - allow(Stripe::PaymentIntent).to receive(:create) - .and_raise(stripe_error) - allow(PaymentRequests::Payments::DeliverErrorWebhookService) - .to receive(:call_async) - .and_call_original - end - - it "delivers an error webhook and raises the error" do - expect { stripe_service.create } - .to raise_error(stripe_error) - .and not_change { payment_request.reload.payment_status } - - expect(PaymentRequests::Payments::DeliverErrorWebhookService).to have_received(:call_async) - expect(SendWebhookJob).to have_been_enqueued - .with( - "payment_request.payment_failure", - payment_request, - provider_customer_id: stripe_customer.provider_customer_id, - provider_error: { - message: "error", - error_code: nil - } - ) - end - end - - context "when payment status is processing" do - let(:payment_status) { "processing" } - - it "creates a stripe payment and a payment", :aggregate_failures do - result = stripe_service.create - - expect(result).to be_success - - expect(result.payable).to be_payment_pending - expect(result.payable.payment_attempts).to eq(1) - expect(result.payable.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(stripe_payment_provider) - expect(result.payment.payment_provider_customer).to eq(stripe_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("processing") - - expect(Stripe::PaymentIntent).to have_received(:create) - end - end - end - describe "#generate_payment_url" do before do stripe_payment_provider