From 7a58b4703cdea7a1a8e0891d321848c5739f7dac Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 2 Dec 2024 17:08:36 +0100 Subject: [PATCH] Add PaymentRequest support --- .../cashfree/handle_event_job.rb | 10 +- .../payment_providers/cashfree_provider.rb | 6 +- .../invoices/payments/cashfree_service.rb | 46 ++- .../cashfree/handle_event_service.rb | 32 ++ .../cashfree/webhooks/base_service.rb | 24 ++ .../webhooks/payment_link_event_service.rb | 51 +++ .../payment_providers/cashfree_service.rb | 27 +- .../payments/cashfree_service.rb | 224 ++++++++++ .../payments/payment_providers/factory.rb | 8 +- spec/fixtures/cashfree/event.json | 1 - .../cashfree/payment_link_event_payment.json | 36 ++ .../payment_link_event_payment_request.json | 36 ++ .../cashfree/handle_event_job_spec.rb | 27 ++ spec/requests/webhooks_controller_spec.rb | 72 ++-- .../payments/cashfree_service_spec.rb | 286 +++++++++++++ .../cashfree/handle_event_service_spec.rb | 34 ++ .../payment_link_event_service_spec.rb | 54 +++ .../cashfree_service_spec.rb | 30 +- .../payments/cashfree_service_spec.rb | 391 ++++++++++++++++++ .../payment_providers/factory_spec.rb | 36 +- 20 files changed, 1304 insertions(+), 127 deletions(-) create mode 100644 app/services/payment_providers/cashfree/handle_event_service.rb create mode 100644 app/services/payment_providers/cashfree/webhooks/base_service.rb create mode 100644 app/services/payment_providers/cashfree/webhooks/payment_link_event_service.rb create mode 100644 app/services/payment_requests/payments/cashfree_service.rb delete mode 100644 spec/fixtures/cashfree/event.json create mode 100644 spec/fixtures/cashfree/payment_link_event_payment.json create mode 100644 spec/fixtures/cashfree/payment_link_event_payment_request.json create mode 100644 spec/jobs/payment_providers/cashfree/handle_event_job_spec.rb create mode 100644 spec/services/invoices/payments/cashfree_service_spec.rb create mode 100644 spec/services/payment_providers/cashfree/handle_event_service_spec.rb create mode 100644 spec/services/payment_providers/cashfree/webhooks/payment_link_event_service_spec.rb create mode 100644 spec/services/payment_requests/payments/cashfree_service_spec.rb diff --git a/app/jobs/payment_providers/cashfree/handle_event_job.rb b/app/jobs/payment_providers/cashfree/handle_event_job.rb index a58e236cf08..c498ebd529c 100644 --- a/app/jobs/payment_providers/cashfree/handle_event_job.rb +++ b/app/jobs/payment_providers/cashfree/handle_event_job.rb @@ -3,11 +3,13 @@ module PaymentProviders module Cashfree class HandleEventJob < ApplicationJob - queue_as 'providers' + queue_as "providers" - def perform(event_json:) - result = PaymentProviders::CashfreeService.new.handle_event(event_json:) - result.raise_if_error! + def perform(organization:, event:) + PaymentProviders::Cashfree::HandleEventService.call!( + organization:, + event_json: event + ) end end end diff --git a/app/models/payment_providers/cashfree_provider.rb b/app/models/payment_providers/cashfree_provider.rb index e5772d2e6f5..4cb882e4309 100644 --- a/app/models/payment_providers/cashfree_provider.rb +++ b/app/models/payment_providers/cashfree_provider.rb @@ -2,9 +2,11 @@ module PaymentProviders class CashfreeProvider < BaseProvider - SUCCESS_REDIRECT_URL = 'https://cashfree.com/' + CashfreePayment = Data.define(:id, :status, :metadata) + + SUCCESS_REDIRECT_URL = "https://cashfree.com/" API_VERSION = "2023-08-01" - BASE_URL = (Rails.env.production? ? 'https://api.cashfree.com/pg/links' : 'https://sandbox.cashfree.com/pg/links') + BASE_URL = (Rails.env.production? ? "https://api.cashfree.com/pg/links" : "https://sandbox.cashfree.com/pg/links") validates :client_id, presence: true validates :client_secret, presence: true diff --git a/app/services/invoices/payments/cashfree_service.rb b/app/services/invoices/payments/cashfree_service.rb index f952ef3a776..7640612c828 100644 --- a/app/services/invoices/payments/cashfree_service.rb +++ b/app/services/invoices/payments/cashfree_service.rb @@ -12,21 +12,23 @@ class CashfreeService < BaseService def initialize(invoice = nil) @invoice = invoice - super(nil) + super end - def update_payment_status(provider_payment_id:, status:) - payment = Payment.find_by(provider_payment_id:) - return result.not_found_failure!(resource: 'cashfree_payment') unless payment + def update_payment_status(organization_id:, status:, cashfree_payment:) + payment = if cashfree_payment.metadata[:payment_type] == "one-time" + create_payment(cashfree_payment) + else + Payment.find_by(provider_payment_id: cashfree_payment.id) + end + return result.not_found_failure!(resource: "cashfree_payment") unless payment result.payment = payment result.invoice = payment.payable return result if payment.payable.payment_succeeded? - invoice_payment_status = invoice_payment_status(status) - - payment.update!(status: invoice_payment_status) - update_invoice_payment_status(payment_status: invoice_payment_status) + payment.update!(status:) + update_invoice_payment_status(payment_status: invoice_payment_status(status)) result rescue BaseService::FailedResult => e @@ -36,9 +38,8 @@ def update_payment_status(provider_payment_id:, status:) def generate_payment_url return result unless should_process_payment? - res = create_post_request(payment_url_params) - - result.payment_url = JSON.parse(res.body)["link_url"] + payment_link_response = create_payment_link(payment_url_params) + result.payment_url = JSON.parse(payment_link_response.body)["link_url"] result rescue LagoHttpClient::HttpError => e @@ -52,21 +53,36 @@ def generate_payment_url delegate :organization, :customer, to: :invoice + def create_payment(cashfree_payment) + @invoice = Invoice.find_by(id: cashfree_payment.metadata[:lago_invoice_id]) + + increment_payment_attempts + + Payment.new( + payable: @invoice, + payment_provider_id: cashfree_payment_provider.id, + payment_provider_customer_id: customer.cashfree_customer.id, + amount_cents: @invoice.total_amount_cents, + amount_currency: @invoice.currency, + provider_payment_id: cashfree_payment.id + ) + end + def should_process_payment? return false if invoice.payment_succeeded? || invoice.voided? return false if cashfree_payment_provider.blank? - customer&.cashfree_customer&.id + !!customer&.cashfree_customer&.id end def client @client ||= LagoHttpClient::Client.new(::PaymentProviders::CashfreeProvider::BASE_URL) end - def create_post_request(body) + def create_payment_link(body) client.post_with_response(body, { - "accept" => 'application/json', - "content-type" => 'application/json', + "accept" => "application/json", + "content-type" => "application/json", "x-client-id" => cashfree_payment_provider.client_id, "x-client-secret" => cashfree_payment_provider.client_secret, "x-api-version" => ::PaymentProviders::CashfreeProvider::API_VERSION diff --git a/app/services/payment_providers/cashfree/handle_event_service.rb b/app/services/payment_providers/cashfree/handle_event_service.rb new file mode 100644 index 00000000000..7af2a16afc6 --- /dev/null +++ b/app/services/payment_providers/cashfree/handle_event_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + class HandleEventService < BaseService + EVENT_MAPPING = { + "PAYMENT_LINK_EVENT" => PaymentProviders::Cashfree::Webhooks::PaymentLinkEventService + }.freeze + + def initialize(organization:, event_json:) + @organization = organization + @event_json = event_json + + super + end + + def call + EVENT_MAPPING[event["type"]].call!(organization_id: organization.id, event_json:) + + result + end + + private + + attr_reader :organization, :event_json + + def event + @event ||= JSON.parse(event_json) + end + end + end +end diff --git a/app/services/payment_providers/cashfree/webhooks/base_service.rb b/app/services/payment_providers/cashfree/webhooks/base_service.rb new file mode 100644 index 00000000000..fb60539d172 --- /dev/null +++ b/app/services/payment_providers/cashfree/webhooks/base_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + module Webhooks + class BaseService < BaseService + def initialize(organization_id:, event_json:) + @organization = Organization.find(organization_id) + @event_json = event_json + + super + end + + private + + attr_reader :organization, :event_json + + def event + @event ||= JSON.parse(event_json) + end + end + end + end +end diff --git a/app/services/payment_providers/cashfree/webhooks/payment_link_event_service.rb b/app/services/payment_providers/cashfree/webhooks/payment_link_event_service.rb new file mode 100644 index 00000000000..3e4314318b7 --- /dev/null +++ b/app/services/payment_providers/cashfree/webhooks/payment_link_event_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + module Webhooks + class PaymentLinkEventService < BaseService + LINK_STATUS_ACTIONS = %w[PAID].freeze + + PAYMENT_SERVICE_CLASS_MAP = { + "Invoice" => Invoices::Payments::CashfreeService, + "PaymentRequest" => PaymentRequests::Payments::CashfreeService + }.freeze + + def call + return result unless LINK_STATUS_ACTIONS.include?(link_status) + return result if provider_payment_id.nil? + + payment_service_class.new.update_payment_status( + organization_id: organization.id, + status: link_status, + cashfree_payment: PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: provider_payment_id, + status: link_status, + metadata: event.dig("data", "link_notes").to_h.symbolize_keys || {} + ) + ).raise_if_error! + end + + private + + def payment_service_class + PAYMENT_SERVICE_CLASS_MAP.fetch(payable_type || "Invoice") do + raise NameError, "Invalid lago_payable_type: #{payable_type}" + end + end + + def link_status + @link_status ||= event.dig("data", "link_status") + end + + def provider_payment_id + @provider_payment_id ||= event.dig("data", "link_notes", "lago_invoice_id") || event.dig("data", "link_notes", "lago_payable_id") + end + + def payable_type + @payable_type ||= event.dig("data", "link_notes", "lago_payable_type") + end + end + end + end +end diff --git a/app/services/payment_providers/cashfree_service.rb b/app/services/payment_providers/cashfree_service.rb index 877abeae84c..99bff00047c 100644 --- a/app/services/payment_providers/cashfree_service.rb +++ b/app/services/payment_providers/cashfree_service.rb @@ -43,6 +43,8 @@ def create_or_update(**args) end def handle_incoming_webhook(organization_id:, body:, timestamp:, signature:, code: nil) + organization = Organization.find_by(id: organization_id) + payment_provider_result = PaymentProviders::FindService.call( organization_id:, code:, @@ -59,33 +61,10 @@ def handle_incoming_webhook(organization_id:, body:, timestamp:, signature:, cod return result.service_failure!(code: "webhook_error", message: "Invalid signature") end - PaymentProviders::Cashfree::HandleEventJob.perform_later(event_json: body) + PaymentProviders::Cashfree::HandleEventJob.perform_later(organization:, event: body) result.event = body result end - - def handle_event(event_json:) - event = JSON.parse(event_json) - event_type = event["type"] - - case event_type - when "PAYMENT_LINK_EVENT" - link_status = event.dig("data", "link_status") - provider_payment_id = event.dig("data", "link_notes", "lago_invoice_id") - - if LINK_STATUS_ACTIONS.include?(link_status) && !provider_payment_id.nil? - update_payment_status_result = Invoices::Payments::CashfreeService - .new.update_payment_status( - provider_payment_id: provider_payment_id, - status: link_status - ) - - return update_payment_status_result unless update_payment_status_result.success? - end - end - - result.raise_if_error! - end end end diff --git a/app/services/payment_requests/payments/cashfree_service.rb b/app/services/payment_requests/payments/cashfree_service.rb new file mode 100644 index 00000000000..792704c5454 --- /dev/null +++ b/app/services/payment_requests/payments/cashfree_service.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class CashfreeService < BaseService + include Customers::PaymentProviderFinder + + PENDING_STATUSES = %w[PARTIALLY_PAID].freeze + SUCCESS_STATUSES = %w[PAID].freeze + FAILED_STATUSES = %w[EXPIRED CANCELLED].freeze + + def initialize(payable = nil) + @payable = payable + + super + end + + def call + 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! + + payment = Payment.new( + payable: payable, + payment_provider_id: cashfree_payment_provider.id, + payment_provider_customer_id: customer.cashfree_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency.upcase, + provider_payment_id: payable.id, # NOTE: We are not creating a resource on cashfree's sude. + status: :pending + ) + payment.save! + + payable_payment_status = payable_payment_status(payment.status) + update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) + + 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? + + payment_link_response = create_payment_link(payment_url_params) + result.payment_url = JSON.parse(payment_link_response.body)["link_url"] + + result + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + result.service_failure!(code: e.error_code, message: e.error_body) + end + + def update_payment_status(organization_id:, status:, cashfree_payment:) + payment = if cashfree_payment.metadata[:payment_type] == "one-time" + create_payment(cashfree_payment) + else + Payment.find_by(provider_payment_id: cashfree_payment.id) + end + return result.not_found_failure!(resource: "cashfree_payment") unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + + payment.update!(status:) + + payable_payment_status = payable_payment_status(status) + update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) + reset_customer_dunning_campaign_status(payable_payment_status) + + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_accessor :payable + + delegate :organization, :customer, to: :payable + + def should_process_payment? + return false if payable.payment_succeeded? + return false if cashfree_payment_provider.blank? + + customer&.cashfree_customer&.id + end + + def cashfree_payment_provider + @cashfree_payment_provider ||= payment_provider(customer) + end + + def client + @client ||= LagoHttpClient::Client.new(::PaymentProviders::CashfreeProvider::BASE_URL) + end + + def create_payment_link(body) + client.post_with_response(body, { + "accept" => "application/json", + "content-type" => "application/json", + "x-client-id" => cashfree_payment_provider.client_id, + "x-client-secret" => cashfree_payment_provider.client_secret, + "x-api-version" => ::PaymentProviders::CashfreeProvider::API_VERSION + }) + end + + def success_redirect_url + cashfree_payment_provider.success_redirect_url.presence || ::PaymentProviders::CashfreeProvider::SUCCESS_REDIRECT_URL + end + + def payment_url_params + { + customer_details: { + customer_phone: customer.phone || "9999999999", + customer_email: customer.email, + customer_name: customer.name + }, + link_notify: { + send_sms: false, + send_email: false + }, + link_meta: { + upi_intent: true, + return_url: success_redirect_url + }, + link_notes: { + lago_customer_id: customer.id, + lago_payable_id: payable.id, + lago_payable_type: payable.class.name, + payment_issuing_date: payable.created_at.iso8601, + payment_type: "one-time" + }, + link_id: "#{SecureRandom.uuid}.#{payable.payment_attempts}", + link_amount: payable.total_amount_cents / 100.to_f, + link_currency: payable.currency.upcase, + link_purpose: payable.id, + link_expiry_time: (Time.current + 10.minutes).iso8601, + link_partial_payments: false, + link_auto_reminders: false + } + end + + def payable_payment_status(payment_status) + return :pending if PENDING_STATUSES.include?(payment_status) + return :succeeded if SUCCESS_STATUSES.include?(payment_status) + return :failed if FAILED_STATUSES.include?(payment_status) + + payment_status + end + + def update_payable_payment_status(payment_status:, deliver_webhook: true) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + + def update_invoices_payment_status(payment_status:, deliver_webhook: true) + payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice:, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end + + def payment_status_succeeded?(payment_status) + payment_status.to_sym == :succeeded + end + + def create_payment(cashfree_payment) + @payable = PaymentRequest.find(cashfree_payment.metadata[:lago_payable_id]) + + payable.increment_payment_attempts! + + Payment.new( + payable:, + payment_provider_id: cashfree_payment_provider.id, + payment_provider_customer_id: customer.cashfree_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency.upcase, + provider_payment_id: cashfree_payment.id + ) + end + + def deliver_error_webhook(cashfree_error) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: customer.cashfree_customer.provider_customer_id, + provider_error: { + message: cashfree_error.error_body, + error_code: cashfree_error.error_code + } + }) + end + + def reset_customer_dunning_campaign_status(payment_status) + return unless payment_status_succeeded?(payment_status) + return unless payable.try(:dunning_campaign) + + customer.reset_dunning_campaign! + end + end + end +end diff --git a/app/services/payment_requests/payments/payment_providers/factory.rb b/app/services/payment_requests/payments/payment_providers/factory.rb index a61bc1cd398..af20cad8fef 100644 --- a/app/services/payment_requests/payments/payment_providers/factory.rb +++ b/app/services/payment_requests/payments/payment_providers/factory.rb @@ -10,11 +10,13 @@ def self.new_instance(payable:) def self.service_class(payment_provider) case payment_provider&.to_s - when 'stripe' + when "stripe" PaymentRequests::Payments::StripeService - when 'adyen' + when "adyen" PaymentRequests::Payments::AdyenService - when 'gocardless' + when "cashfree" + PaymentRequests::Payments::CashfreeService + when "gocardless" PaymentRequests::Payments::GocardlessService else raise(NotImplementedError) diff --git a/spec/fixtures/cashfree/event.json b/spec/fixtures/cashfree/event.json deleted file mode 100644 index 984d74b87dc..00000000000 --- a/spec/fixtures/cashfree/event.json +++ /dev/null @@ -1 +0,0 @@ -{"data":{"cf_link_id":1576977,"link_id":"payment_ps11","link_status":"PAID","link_currency":"INR","link_amount":"200.12","link_amount_paid":"55.00","link_partial_payments":true,"link_minimum_partial_amount":"11.00","link_purpose":"Payment for order 10","link_created_at":"2021-08-18T07:13:41","customer_details":{"customer_phone":"9000000000","customer_email":"john@gmail.com","customer_name":"John "},"link_meta":{"notify_url":"https://ee08e626ecd88c61c85f5c69c0418cb5.m.pipedream.net"},"link_url":"https://payments-test.cashfree.com/links//U1mgll3c0e9g","link_expiry_time":"2021-11-28T21:46:20","link_notes":{"lago_invoice_id":"06afb06b-4e54-4f8f-89c1-6d8b9907465a"},"link_auto_reminders":true,"link_notify":{"send_sms":true,"send_email":true},"order":{"order_amount":"22.00","order_id":"CFPay_U1mgll3c0e9g_ehdcjjbtckf","order_expiry_time":"2021-08-18T07:34:50","order_hash":"Gb2gC7z0tILhGbZUIeds","transaction_id":1021206,"transaction_status":"SUCCESS"}},"type":"PAYMENT_LINK_EVENT","version":1,"event_time":"2021-08-18T12:55:06+05:30"} \ No newline at end of file diff --git a/spec/fixtures/cashfree/payment_link_event_payment.json b/spec/fixtures/cashfree/payment_link_event_payment.json new file mode 100644 index 00000000000..9dfab769dec --- /dev/null +++ b/spec/fixtures/cashfree/payment_link_event_payment.json @@ -0,0 +1,36 @@ +{ + "data": { + "cf_link_id": 1576977, + "link_id": "payment_ps11", + "link_status": "PAID", + "link_currency": "INR", + "link_amount": "200.12", + "link_amount_paid": "55.00", + "link_partial_payments": true, + "link_minimum_partial_amount": "11.00", + "link_purpose": "Payment for order 10", + "link_created_at": "2021-08-18T07:13:41", + "customer_details": { + "customer_phone": "9000000000", + "customer_email": "john@gmail.com", + "customer_name": "John " + }, + "link_meta": { "notify_url": "https://ee08e626ecd88c61c85f5c69c0418cb5.m.pipedream.net" }, + "link_url": "https://payments-test.cashfree.com/links//U1mgll3c0e9g", + "link_expiry_time": "2021-11-28T21:46:20", + "link_notes": { "lago_invoice_id": "06afb06b-4e54-4f8f-89c1-6d8b9907465a" }, + "link_auto_reminders": true, + "link_notify": { "send_sms": true, "send_email": true }, + "order": { + "order_amount": "22.00", + "order_id": "CFPay_U1mgll3c0e9g_ehdcjjbtckf", + "order_expiry_time": "2021-08-18T07:34:50", + "order_hash": "Gb2gC7z0tILhGbZUIeds", + "transaction_id": 1021206, + "transaction_status": "SUCCESS" + } + }, + "type": "PAYMENT_LINK_EVENT", + "version": 1, + "event_time": "2021-08-18T12:55:06+05:30" +} diff --git a/spec/fixtures/cashfree/payment_link_event_payment_request.json b/spec/fixtures/cashfree/payment_link_event_payment_request.json new file mode 100644 index 00000000000..4afd4c0516a --- /dev/null +++ b/spec/fixtures/cashfree/payment_link_event_payment_request.json @@ -0,0 +1,36 @@ +{ + "data": { + "cf_link_id": 1576977, + "link_id": "payment_ps11", + "link_status": "PAID", + "link_currency": "INR", + "link_amount": "200.12", + "link_amount_paid": "55.00", + "link_partial_payments": true, + "link_minimum_partial_amount": "11.00", + "link_purpose": "Payment for order 10", + "link_created_at": "2021-08-18T07:13:41", + "customer_details": { + "customer_phone": "9000000000", + "customer_email": "john@gmail.com", + "customer_name": "John " + }, + "link_meta": { "notify_url": "https://ee08e626ecd88c61c85f5c69c0418cb5.m.pipedream.net" }, + "link_url": "https://payments-test.cashfree.com/links//U1mgll3c0e9g", + "link_expiry_time": "2021-11-28T21:46:20", + "link_notes": { "lago_payable_id": "06afb06b-4e54-4f8f-89c1-6d8b9907465a", "lago_payable_type": "PaymentRequest" }, + "link_auto_reminders": true, + "link_notify": { "send_sms": true, "send_email": true }, + "order": { + "order_amount": "22.00", + "order_id": "CFPay_U1mgll3c0e9g_ehdcjjbtckf", + "order_expiry_time": "2021-08-18T07:34:50", + "order_hash": "Gb2gC7z0tILhGbZUIeds", + "transaction_id": 1021206, + "transaction_status": "SUCCESS" + } + }, + "type": "PAYMENT_LINK_EVENT", + "version": 1, + "event_time": "2021-08-18T12:55:06+05:30" +} diff --git a/spec/jobs/payment_providers/cashfree/handle_event_job_spec.rb b/spec/jobs/payment_providers/cashfree/handle_event_job_spec.rb new file mode 100644 index 00000000000..bb3b57d629d --- /dev/null +++ b/spec/jobs/payment_providers/cashfree/handle_event_job_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::HandleEventJob, type: :job do + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + + let(:cashfree_event) do + {} + end + + before do + allow(PaymentProviders::Cashfree::HandleEventService) + .to receive(:call) + .and_return(result) + end + + it "calls the handle event service" do + described_class.perform_now( + organization:, + event: cashfree_event + ) + + expect(PaymentProviders::Cashfree::HandleEventService).to have_received(:call) + end +end diff --git a/spec/requests/webhooks_controller_spec.rb b/spec/requests/webhooks_controller_spec.rb index 39fcaf1e2f5..41a1328ab6a 100644 --- a/spec/requests/webhooks_controller_spec.rb +++ b/spec/requests/webhooks_controller_spec.rb @@ -66,27 +66,27 @@ end end - describe 'POST /gocardless' do + describe "POST /gocardless" do let(:organization) { create(:organization) } let(:gocardless_provider) do create( :gocardless_provider, organization:, - webhook_secret: 'secrets' + webhook_secret: "secrets" ) end let(:gocardless_service) { instance_double(PaymentProviders::GocardlessService) } let(:events) do - path = Rails.root.join('spec/fixtures/gocardless/events.json') + path = Rails.root.join("spec/fixtures/gocardless/events.json") JSON.parse(File.read(path)) end let(:result) do result = BaseService::Result.new - result.events = events['events'].map { |event| GoCardlessPro::Resources::Event.new(event) } + result.events = events["events"].map { |event| GoCardlessPro::Resources::Event.new(event) } result end @@ -96,18 +96,18 @@ organization_id: organization.id, code: nil, body: events.to_json, - signature: 'signature' + signature: "signature" ) .and_return(result) end - it 'handle gocardless webhooks' do + it "handle gocardless webhooks" do post( "/webhooks/gocardless/#{gocardless_provider.organization_id}", params: events.to_json, headers: { - 'Webhook-Signature' => 'signature', - 'Content-Type' => 'application/json' + "Webhook-Signature" => "signature", + "Content-Type" => "application/json" } ) @@ -116,18 +116,18 @@ expect(PaymentProviders::Gocardless::HandleIncomingWebhookService).to have_received(:call) end - context 'when failing to handle gocardless event' do + context "when failing to handle gocardless event" do let(:result) do - BaseService::Result.new.service_failure!(code: 'webhook_error', message: 'Invalid payload') + BaseService::Result.new.service_failure!(code: "webhook_error", message: "Invalid payload") end - it 'returns a bad request' do + it "returns a bad request" do post( "/webhooks/gocardless/#{gocardless_provider.organization_id}", params: events.to_json, headers: { - 'Webhook-Signature' => 'signature', - 'Content-Type' => 'application/json' + "Webhook-Signature" => "signature", + "Content-Type" => "application/json" } ) @@ -138,7 +138,7 @@ end end - describe 'POST /adyen' do + describe "POST /adyen" do let(:organization) { create(:organization) } let(:adyen_provider) do @@ -146,7 +146,7 @@ end let(:body) do - path = Rails.root.join('spec/fixtures/adyen/webhook_authorisation_response.json') + path = Rails.root.join("spec/fixtures/adyen/webhook_authorisation_response.json") JSON.parse(File.read(path)) end @@ -161,17 +161,17 @@ .with( organization_id: organization.id, code: nil, - body: body['notificationItems'].first&.dig('NotificationRequestItem') + body: body["notificationItems"].first&.dig("NotificationRequestItem") ) .and_return(result) end - it 'handle adyen webhooks' do + it "handle adyen webhooks" do post( "/webhooks/adyen/#{adyen_provider.organization_id}", params: body.to_json, headers: { - 'Content-Type' => 'application/json' + "Content-Type" => "application/json" } ) @@ -179,17 +179,17 @@ expect(PaymentProviders::Adyen::HandleIncomingWebhookService).to have_received(:call) end - context 'when failing to handle adyen event' do + context "when failing to handle adyen event" do let(:result) do - BaseService::Result.new.service_failure!(code: 'webhook_error', message: 'Invalid payload') + BaseService::Result.new.service_failure!(code: "webhook_error", message: "Invalid payload") end - it 'returns a bad request' do + it "returns a bad request" do post( "/webhooks/adyen/#{adyen_provider.organization_id}", params: body.to_json, headers: { - 'Content-Type' => 'application/json' + "Content-Type" => "application/json" } ) @@ -199,7 +199,7 @@ end end - describe 'POST /cashfree' do + describe "POST /cashfree" do let(:organization) { create(:organization) } let(:cashfree_provider) do @@ -209,7 +209,7 @@ let(:cashfree_service) { instance_double(PaymentProviders::CashfreeService) } let(:body) do - path = Rails.root.join('spec/fixtures/cashfree/event.json') + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment.json") JSON.parse(File.read(path)) end @@ -227,20 +227,20 @@ organization_id: organization.id, code: nil, body: body.to_json, - timestamp: '1629271506', - signature: 'MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=' + timestamp: "1629271506", + signature: "MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=" ) .and_return(result) end - it 'handle cashfree webhooks' do + it "handle cashfree webhooks" do post( "/webhooks/cashfree/#{cashfree_provider.organization_id}", params: body.to_json, headers: { - 'Content-Type' => 'application/json', - 'X-Cashfree-Timestamp' => '1629271506', - 'X-Cashfree-Signature' => 'MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=' + "Content-Type" => "application/json", + "X-Cashfree-Timestamp" => "1629271506", + "X-Cashfree-Signature" => "MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=" } ) @@ -250,19 +250,19 @@ expect(cashfree_service).to have_received(:handle_incoming_webhook) end - context 'when failing to handle cashfree event' do + context "when failing to handle cashfree event" do let(:result) do - BaseService::Result.new.service_failure!(code: 'webhook_error', message: 'Invalid payload') + BaseService::Result.new.service_failure!(code: "webhook_error", message: "Invalid payload") end - it 'returns a bad request' do + it "returns a bad request" do post( "/webhooks/cashfree/#{cashfree_provider.organization_id}", params: body.to_json, headers: { - 'Content-Type' => 'application/json', - 'X-Cashfree-Timestamp' => '1629271506', - 'X-Cashfree-Signature' => 'MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=' + "Content-Type" => "application/json", + "X-Cashfree-Timestamp" => "1629271506", + "X-Cashfree-Signature" => "MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=" } ) diff --git a/spec/services/invoices/payments/cashfree_service_spec.rb b/spec/services/invoices/payments/cashfree_service_spec.rb new file mode 100644 index 00000000000..8a8624a83da --- /dev/null +++ b/spec/services/invoices/payments/cashfree_service_spec.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::CashfreeService, type: :service do + subject(:cashfree_service) { described_class.new(invoice) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:cashfree_payment_provider) { create(:cashfree_provider, organization:, code:) } + let(:cashfree_customer) { create(:cashfree_customer, customer:) } + let(:cashfree_client) { instance_double(LagoHttpClient::Client) } + + let(:code) { "cashfree_1" } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 1000, + currency: "USD", + ready_for_payment_processing: true + ) + end + + describe ".call" do + before do + cashfree_payment_provider + cashfree_customer + + allow(Invoices::PrepaidCreditJob).to receive(:perform_later) + end + + it "creates a cashfree payment", aggregate_failure: true do + result = cashfree_service.call + + expect(result).to be_success + + expect(result.invoice).to be_payment_pending + expect(result.invoice.payment_attempts).to eq(1) + expect(result.invoice.reload.ready_for_payment_processing).to eq(true) + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(cashfree_payment_provider) + expect(result.payment.payment_provider_customer).to eq(cashfree_customer) + expect(result.payment.amount_cents).to eq(invoice.total_amount_cents) + expect(result.payment.amount_currency).to eq(invoice.currency) + expect(result.payment.status).to eq("pending") + end + + it_behaves_like "syncs payment" do + let(:service_call) { cashfree_service.call } + end + + context "with no payment provider" do + let(:cashfree_payment_provider) { nil } + + it "does not creates a payment", aggregate_failure: true do + result = cashfree_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + end + end + + context "with 0 amount" do + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 0, + currency: "EUR" + ) + end + + it "does not creates a payment", aggregate_failure: true do + result = cashfree_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + expect(result.invoice).to be_payment_succeeded + end + end + + context "when customer does not exists" do + let(:cashfree_customer) { nil } + + it "does not creates a adyen payment", aggregate_failure: true do + result = cashfree_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + end + end + end + + describe ".update_payment_status" do + let(:payment) do + create( + :payment, + payable: invoice, + provider_payment_id: invoice.id, + status: "pending" + ) + end + + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: "PAID", + metadata: {} + ) + end + + before do + allow(SendWebhookJob).to receive(:perform_later) + payment + end + + it "updates the payment and invoice payment_status" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("PAID") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false + ) + end + + context "when status is failed" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: "EXPIRED", + metadata: {} + ) + end + + it "updates the payment and invoice status" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("EXPIRED") + expect(result.invoice.reload).to have_attributes( + payment_status: "failed", + ready_for_payment_processing: true + ) + end + end + + context "when invoice is already payment_succeeded" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: %w[PARTIALLY_PAID PAID EXPIRED CANCELED].sample, + metadata: {} + ) + end + + before { invoice.payment_succeeded! } + + it "does not update the status of invoice and payment" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.invoice.payment_status).to eq("succeeded") + end + end + + context "with invalid status" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: "foo-bar", + metadata: {} + ) + end + + it "does not update the payment_status of invoice", aggregate_failures: true do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payment_status) + expect(result.error.messages[:payment_status]).to include("value_is_invalid") + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: "PAID", + metadata: {payment_type: "one-time", lago_invoice_id: invoice.id} + ) + end + + before do + cashfree_payment_provider + cashfree_customer + end + + it "creates a payment and updates invoice payment status", aggregate_failure: true do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("PAID") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false + ) + end + end + end + + describe ".generate_payment_url" do + let(:payment_links_response) { Net::HTTPResponse.new("1.0", "200", "OK") } + + before do + cashfree_payment_provider + cashfree_customer + + allow(LagoHttpClient::Client).to receive(:new) + .and_return(cashfree_client) + allow(cashfree_client).to receive(:post_with_response) + .and_return(payment_links_response) + allow(payment_links_response).to receive(:body) + .and_return({link_url: "https://payments-test.cashfree.com/links//U1mgll3c0e9g"}.to_json) + end + + it "generates payment url" do + result = cashfree_service.generate_payment_url + + expect(result.payment_url).to be_present + end + + context "when invoice is payment_succeeded" do + before { invoice.payment_succeeded! } + + it "does not generate payment url" do + result = cashfree_service.generate_payment_url + + expect(result.payment_url).to be_nil + end + end + + context "when invoice is voided" do + before { invoice.voided! } + + it "does not generate payment url" do + result = cashfree_service.generate_payment_url + + expect(result.payment_url).to be_nil + end + end + end +end diff --git a/spec/services/payment_providers/cashfree/handle_event_service_spec.rb b/spec/services/payment_providers/cashfree/handle_event_service_spec.rb new file mode 100644 index 00000000000..65f80aaa812 --- /dev/null +++ b/spec/services/payment_providers/cashfree/handle_event_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::HandleEventService do + subject(:event_service) { described_class.new(organization:, event_json:) } + + let(:organization) { create(:organization) } + + let(:payment_service) { instance_double(Invoices::Payments::CashfreeService) } + let(:service_result) { BaseService::Result.new } + + let(:event_json) do + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment.json") + File.read(path) + end + + describe ".call" do + let(:event_json) do + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment_request.json") + File.read(path) + end + + before do + allow(PaymentProviders::Cashfree::Webhooks::PaymentLinkEventService).to receive(:call) + .and_return(service_result) + end + + it "routes the event to an other service" do + expect(event_service.call).to be_success + expect(PaymentProviders::Cashfree::Webhooks::PaymentLinkEventService).to have_received(:call) + end + end +end diff --git a/spec/services/payment_providers/cashfree/webhooks/payment_link_event_service_spec.rb b/spec/services/payment_providers/cashfree/webhooks/payment_link_event_service_spec.rb new file mode 100644 index 00000000000..f5e3206397c --- /dev/null +++ b/spec/services/payment_providers/cashfree/webhooks/payment_link_event_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::Webhooks::PaymentLinkEventService, type: :service do + subject(:webhook_service) { described_class.new(organization_id: organization.id, event_json:) } + + let(:organization) { create(:organization) } + let(:event_json) { File.read("spec/fixtures/cashfree/payment_link_event_payment.json") } + + let(:payment_service) { instance_double(Invoices::Payments::CashfreeService) } + let(:service_result) { BaseService::Result.new } + + describe "#call" do + context "when succeeded payment event" do + before do + allow(Invoices::Payments::CashfreeService).to receive(:new) + .and_return(payment_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + end + + it "routes the event to an other service" do + webhook_service.call + + expect(Invoices::Payments::CashfreeService).to have_received(:new) + expect(payment_service).to have_received(:update_payment_status) + end + end + + context "when succeeded payment_request event" do + let(:payment_service) { instance_double(PaymentRequests::Payments::CashfreeService) } + + let(:event_json) do + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment_request.json") + File.read(path) + end + + before do + allow(PaymentRequests::Payments::CashfreeService).to receive(:new) + .and_return(payment_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + end + + it "routes the event to an other service" do + webhook_service.call + + expect(PaymentRequests::Payments::CashfreeService).to have_received(:new) + expect(payment_service).to have_received(:update_payment_status) + end + end + end +end diff --git a/spec/services/payment_providers/cashfree_service_spec.rb b/spec/services/payment_providers/cashfree_service_spec.rb index 5673a8956c2..74a4061ec13 100644 --- a/spec/services/payment_providers/cashfree_service_spec.rb +++ b/spec/services/payment_providers/cashfree_service_spec.rb @@ -112,8 +112,8 @@ let(:cashfree_provider) { create(:cashfree_provider, organization:, client_id:, client_secret:) } let(:body) do - path = Rails.root.join("spec/fixtures/cashfree/event.json") - File.read(path) + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment.json") + JSON.parse(File.read(path)).to_json # NOTE: Ensure valid sha256 signature end before { cashfree_provider } @@ -149,30 +149,4 @@ end end end - - describe ".handle_event" do - let(:payment_service) { instance_double(Invoices::Payments::CashfreeService) } - let(:service_result) { BaseService::Result.new } - - before do - allow(Invoices::Payments::CashfreeService).to receive(:new) - .and_return(payment_service) - allow(payment_service).to receive(:update_payment_status) - .and_return(service_result) - end - - context "when succeeded payment event" do - let(:event) do - path = Rails.root.join("spec/fixtures/cashfree/event.json") - File.read(path) - end - - it "routes the event to an other service" do - cashfree_service.handle_event(event_json: event) - - expect(Invoices::Payments::CashfreeService).to have_received(:new) - expect(payment_service).to have_received(:update_payment_status) - end - end - end end diff --git a/spec/services/payment_requests/payments/cashfree_service_spec.rb b/spec/services/payment_requests/payments/cashfree_service_spec.rb new file mode 100644 index 00000000000..d20ed273aa0 --- /dev/null +++ b/spec/services/payment_requests/payments/cashfree_service_spec.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::CashfreeService, type: :service do + subject(:cashfree_service) { described_class.new(payment_request) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:cashfree_payment_provider) { create(:cashfree_provider, organization:, code:) } + let(:cashfree_customer) { create(:cashfree_customer, customer:) } + let(:cashfree_client) { instance_double(LagoHttpClient::Client) } + + let(:code) { "cashfree_1" } + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2] + ) + end + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 599, + currency: "USD", + ready_for_payment_processing: true + ) + end + + describe ".call" do + before do + cashfree_payment_provider + cashfree_customer + end + + it "creates a cashfree payment", aggregate_failure: true do + result = cashfree_service.call + + expect(result).to be_success + + expect(result.payable).to be_payment_pending + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.reload.ready_for_payment_processing).to eq(true) + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(payment_request) + expect(result.payment.payment_provider).to eq(cashfree_payment_provider) + expect(result.payment.payment_provider_customer).to eq(cashfree_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("pending") + end + + context "with no payment provider" do + let(:cashfree_payment_provider) { nil } + + it "does not creates a payment", aggregate_failure: true do + result = cashfree_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + 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 payment", aggregate_failure: true do + result = cashfree_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 + end + end + + context "when customer does not exists" do + let(:cashfree_customer) { nil } + + it "does not creates a adyen payment", aggregate_failure: true do + result = cashfree_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + end + end + end + + describe ".update_payment_status" do + let(:payment) do + create( + :payment, + payable: payment_request, + provider_payment_id: payment_request.id, + status: "pending" + ) + end + + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "PAID", + metadata: {} + ) + end + + let(:result) do + cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + end + + before do + allow(SendWebhookJob).to receive(:perform_later) + allow(SegmentTrackJob).to receive(:perform_later) + payment + end + + it "updates the payment and invoice payment_status" do + expect(result).to be_success + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(result.payment.status).to eq("PAID") + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment request belongs to a dunning campaign" do + let(:customer) do + create( + :customer, + payment_provider_code: code, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: Time.zone.now + ) + end + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2], + dunning_campaign: create(:dunning_campaign) + ) + end + + it "resets the customer dunning campaign counters" do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(result).to be_success + end + + context "when status is failed" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "EXPIRED", + metadata: {} + ) + end + + it "doest not reset the customer dunning campaign counters" do + expect { result && customer.reload } + .to not_change(customer, :last_dunning_campaign_attempt) + .and not_change { customer.last_dunning_campaign_attempt_at&.to_i } + + expect(result).to be_success + end + end + end + + context "when status is failed" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "EXPIRED", + metadata: {} + ) + end + + it "updates the payment, payment_request and invoices status", :aggregate_failures do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("EXPIRED") + + expect(result.payable.reload).to be_payment_failed + expect(result.payable.ready_for_payment_processing).to eq(true) + + expect(invoice_1.reload).to be_payment_failed + expect(invoice_1.ready_for_payment_processing).to eq(true) + + expect(invoice_2.reload).to be_payment_failed + expect(invoice_2.ready_for_payment_processing).to eq(true) + end + + it "sends a payment requested email" do + expect { result } + .to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + + context "when payment_request and invoices is already payment_succeeded" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: %w[PARTIALLY_PAID PAID EXPIRED CANCELED].sample, + metadata: {} + ) + end + + before do + payment_request.payment_succeeded! + invoice_1.payment_succeeded! + invoice_2.payment_succeeded! + end + + it "does not update the status of invoices, payment_request and payment" do + expect { result } + .to not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment_request.reload.payment_status } + .and not_change { payment.reload.status } + + expect(result).to be_success + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "with invalid status" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "foo-bar", + metadata: {} + ) + end + + it "does not update the payment_status of payment_request, invoices and payment" do + expect { result } + .to not_change { payment_request.reload.payment_status } + .and not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and change { payment.reload.status }.to("foo-bar") + end + + it "returns an error", :aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payment_status) + expect(result.error.messages[:payment_status]).to include("value_is_invalid") + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "PAID", + metadata: { + payment_type: "one-time", + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest" + } + ) + end + + before do + cashfree_payment_provider + cashfree_customer + end + + it "creates a payment and updates invoice payment status", aggregate_failure: true do + expect(result).to be_success + expect(result.payment.status).to eq("PAID") + + expect(result.payable).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + end + end + end + + describe ".generate_payment_url" do + let(:payment_links_response) { Net::HTTPResponse.new("1.0", "200", "OK") } + + before do + cashfree_payment_provider + cashfree_customer + + allow(LagoHttpClient::Client).to receive(:new) + .and_return(cashfree_client) + allow(cashfree_client).to receive(:post_with_response) + .and_return(payment_links_response) + allow(payment_links_response).to receive(:body) + .and_return({link_url: "https://payments-test.cashfree.com/links//U1mgll3c0e9g"}.to_json) + end + + it "generates payment url" do + result = cashfree_service.generate_payment_url + + expect(result.payment_url).to be_present + end + + context "when invoice is payment_succeeded" do + before { payment_request.payment_succeeded! } + + it "does not generate payment url" do + result = cashfree_service.generate_payment_url + + expect(result.payment_url).to be_nil + end + end + end +end diff --git a/spec/services/payment_requests/payments/payment_providers/factory_spec.rb b/spec/services/payment_requests/payments/payment_providers/factory_spec.rb index ff67c923f68..363f9f2b8c1 100644 --- a/spec/services/payment_requests/payments/payment_providers/factory_spec.rb +++ b/spec/services/payment_requests/payments/payment_providers/factory_spec.rb @@ -1,34 +1,42 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" RSpec.describe PaymentRequests::Payments::PaymentProviders::Factory, type: :service do subject(:factory_service) { described_class.new_instance(payable:) } - let(:payment_provider) { 'stripe' } + let(:payment_provider) { "stripe" } let(:payable) { create(:payment_request, customer:) } let(:customer) { create(:customer, payment_provider:) } - describe '#self.new_instance' do - context 'when stripe' do - it 'returns correct class' do - expect(factory_service.class.to_s).to eq('PaymentRequests::Payments::StripeService') + describe "#self.new_instance" do + context "when stripe" do + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::StripeService") end end - context 'when adyen' do - let(:payment_provider) { 'adyen' } + context "when adyen" do + let(:payment_provider) { "adyen" } - it 'returns correct class' do - expect(factory_service.class.to_s).to eq('PaymentRequests::Payments::AdyenService') + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::AdyenService") end end - context 'when gocardless' do - let(:payment_provider) { 'gocardless' } + context "when cashfree" do + let(:payment_provider) { "cashfree" } - it 'returns correct class' do - expect(factory_service.class.to_s).to eq('PaymentRequests::Payments::GocardlessService') + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::CashfreeService") + end + end + + context "when gocardless" do + let(:payment_provider) { "gocardless" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::GocardlessService") end end end