From ea129146f092cf3f63b9e3b9e83fe5739ce31b1d Mon Sep 17 00:00:00 2001 From: Ayush Chothe Date: Tue, 24 Sep 2024 12:42:52 +0530 Subject: [PATCH 1/8] Add Cashfree Payment Provider --- app/controllers/webhooks_controller.rb | 20 + .../payment_providers/cashfree/base.rb | 20 + .../payment_providers/cashfree/create.rb | 18 + .../payment_providers/cashfree/update.rb | 18 + .../resolvers/payment_providers_resolver.rb | 2 + app/graphql/types/customers/object.rb | 2 + app/graphql/types/mutation_type.rb | 2 + .../current_organization_type.rb | 1 + .../types/payment_providers/cashfree.rb | 17 + .../types/payment_providers/cashfree_input.rb | 15 + app/graphql/types/payment_providers/object.rb | 5 +- .../cashfree/handle_event_job.rb | 14 + app/models/customer.rb | 5 +- app/models/organization.rb | 11 +- .../cashfree_customer.rb | 31 ++ .../payment_providers/cashfree_provider.rb | 39 ++ app/serializers/v1/customer_serializer.rb | 3 + .../invoices/payments/cashfree_service.rb | 146 +++++++ .../payments/payment_providers/factory.rb | 2 + .../cashfree_service.rb | 32 ++ .../payment_provider_customers/factory.rb | 2 + .../cashfree/payments/create_service.rb | 34 ++ .../payment_providers/cashfree_service.rb | 85 ++++ .../create_payment_factory.rb | 2 + config/routes.rb | 1 + schema.graphql | 63 ++- schema.json | 374 ++++++++++++++++++ spec/factories/payment_provider_customers.rb | 6 + spec/factories/payment_providers.rb | 19 + spec/fixtures/cashfree/event.json | 1 + .../payment_providers/cashfree/create_spec.rb | 63 +++ .../payment_providers/cashfree/update_spec.rb | 67 ++++ .../payment_provider_resolver_spec.rb | 6 + .../payment_providers_resolver_spec.rb | 35 +- .../current_organization_type_spec.rb | 1 + .../payment_providers/cashfree_input_spec.rb | 13 + .../types/payment_providers/cashfree_spec.rb | 15 + spec/requests/webhooks_controller_spec.rb | 75 ++++ .../payment_providers/factory_spec.rb | 8 + .../cashfree_service_spec.rb | 145 +++++++ 40 files changed, 1407 insertions(+), 11 deletions(-) create mode 100644 app/graphql/mutations/payment_providers/cashfree/base.rb create mode 100644 app/graphql/mutations/payment_providers/cashfree/create.rb create mode 100644 app/graphql/mutations/payment_providers/cashfree/update.rb create mode 100644 app/graphql/types/payment_providers/cashfree.rb create mode 100644 app/graphql/types/payment_providers/cashfree_input.rb create mode 100644 app/jobs/payment_providers/cashfree/handle_event_job.rb create mode 100644 app/models/payment_provider_customers/cashfree_customer.rb create mode 100644 app/models/payment_providers/cashfree_provider.rb create mode 100644 app/services/invoices/payments/cashfree_service.rb create mode 100644 app/services/payment_provider_customers/cashfree_service.rb create mode 100644 app/services/payment_providers/cashfree/payments/create_service.rb create mode 100644 app/services/payment_providers/cashfree_service.rb create mode 100644 spec/fixtures/cashfree/event.json create mode 100644 spec/graphql/mutations/payment_providers/cashfree/create_spec.rb create mode 100644 spec/graphql/mutations/payment_providers/cashfree/update_spec.rb create mode 100644 spec/graphql/types/payment_providers/cashfree_input_spec.rb create mode 100644 spec/graphql/types/payment_providers/cashfree_spec.rb create mode 100644 spec/services/payment_providers/cashfree_service_spec.rb diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 1922cb628f1..05867afa811 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -16,6 +16,26 @@ def stripe head(:ok) end + def cashfree + result = PaymentProviders::CashfreeService.new.handle_incoming_webhook( + organization_id: params[:organization_id], + code: params[:code].presence, + body: request.body.read, + timestamp: request.headers['X-Cashfree-Timestamp'], + signature: request.headers['X-Cashfree-Signature'] + ) + + unless result.success? + if result.error.is_a?(BaseService::ServiceFailure) && result.error.code == 'webhook_error' + return head(:bad_request) + end + + result.raise_if_error! + end + + head(:ok) + end + def gocardless result = PaymentProviders::Gocardless::HandleIncomingWebhookService.call( organization_id: params[:organization_id], diff --git a/app/graphql/mutations/payment_providers/cashfree/base.rb b/app/graphql/mutations/payment_providers/cashfree/base.rb new file mode 100644 index 00000000000..a28059edf27 --- /dev/null +++ b/app/graphql/mutations/payment_providers/cashfree/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Cashfree + class Base < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + def resolve(**args) + result = ::PaymentProviders::CashfreeService + .new(context[:current_user]) + .create_or_update(**args.merge(organization: current_organization)) + + result.success? ? result.cashfree_provider : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/cashfree/create.rb b/app/graphql/mutations/payment_providers/cashfree/create.rb new file mode 100644 index 00000000000..b717cfec994 --- /dev/null +++ b/app/graphql/mutations/payment_providers/cashfree/create.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Cashfree + class Create < Base + REQUIRED_PERMISSION = 'organization:integrations:create' + + graphql_name 'AddCashfreePaymentProvider' + description 'Add or update Cashfree payment provider' + + input_object_class Types::PaymentProviders::CashfreeInput + + type Types::PaymentProviders::Cashfree + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/cashfree/update.rb b/app/graphql/mutations/payment_providers/cashfree/update.rb new file mode 100644 index 00000000000..325274ddd48 --- /dev/null +++ b/app/graphql/mutations/payment_providers/cashfree/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Cashfree + class Update < Base + REQUIRED_PERMISSION = 'organization:integrations:update' + + graphql_name 'UpdateCashfreePaymentProvider' + description 'Update Cashfree payment provider' + + input_object_class Types::PaymentProviders::UpdateInput + + type Types::PaymentProviders::Cashfree + end + end + end +end diff --git a/app/graphql/resolvers/payment_providers_resolver.rb b/app/graphql/resolvers/payment_providers_resolver.rb index 41ac9c21d1a..93613c79c22 100644 --- a/app/graphql/resolvers/payment_providers_resolver.rb +++ b/app/graphql/resolvers/payment_providers_resolver.rb @@ -31,6 +31,8 @@ def provider_type(type) PaymentProviders::StripeProvider.to_s when 'gocardless' PaymentProviders::GocardlessProvider.to_s + when 'cashfree' + PaymentProviders::CashfreeProvider.to_s else raise(NotImplementedError) end diff --git a/app/graphql/types/customers/object.rb b/app/graphql/types/customers/object.rb index ec6c7815a53..3a7744bb5bb 100644 --- a/app/graphql/types/customers/object.rb +++ b/app/graphql/types/customers/object.rb @@ -130,6 +130,8 @@ def provider_customer object.stripe_customer when :gocardless object.gocardless_customer + when :cashfree + object.cashfree_customer when :adyen object.adyen_customer end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index fd2a72bc3f5..7cabdddffa3 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -48,10 +48,12 @@ class MutationType < Types::BaseObject field :update_add_on, mutation: Mutations::AddOns::Update field :add_adyen_payment_provider, mutation: Mutations::PaymentProviders::Adyen::Create + field :add_cashfree_payment_provider, mutation: Mutations::PaymentProviders::Cashfree::Create field :add_gocardless_payment_provider, mutation: Mutations::PaymentProviders::Gocardless::Create field :add_stripe_payment_provider, mutation: Mutations::PaymentProviders::Stripe::Create field :update_adyen_payment_provider, mutation: Mutations::PaymentProviders::Adyen::Update + field :update_cashfree_payment_provider, mutation: Mutations::PaymentProviders::Cashfree::Update field :update_gocardless_payment_provider, mutation: Mutations::PaymentProviders::Gocardless::Update field :update_stripe_payment_provider, mutation: Mutations::PaymentProviders::Stripe::Update diff --git a/app/graphql/types/organizations/current_organization_type.rb b/app/graphql/types/organizations/current_organization_type.rb index e54f59aa975..bb5d0e04b41 100644 --- a/app/graphql/types/organizations/current_organization_type.rb +++ b/app/graphql/types/organizations/current_organization_type.rb @@ -47,6 +47,7 @@ class CurrentOrganizationType < BaseOrganizationType field :taxes, [Types::Taxes::Object], resolver: Resolvers::TaxesResolver, permission: 'organization:taxes:view' field :adyen_payment_providers, [Types::PaymentProviders::Adyen], permission: 'organization:integrations:view' + field :cashfree_payment_providers, [Types::PaymentProviders::Cashfree], permission: 'organization:integrations:view' field :gocardless_payment_providers, [Types::PaymentProviders::Gocardless], permission: 'organization:integrations:view' field :stripe_payment_providers, [Types::PaymentProviders::Stripe], permission: 'organization:integrations:view' diff --git a/app/graphql/types/payment_providers/cashfree.rb b/app/graphql/types/payment_providers/cashfree.rb new file mode 100644 index 00000000000..7d0f02d9aa6 --- /dev/null +++ b/app/graphql/types/payment_providers/cashfree.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Cashfree < Types::BaseObject + graphql_name 'CashfreeProvider' + + field :code, String, null: false + field :id, ID, null: false + field :name, String, null: false + + field :client_id, String, null: true, permission: 'organization:integrations:view' + field :client_secret, String, null: true, permission: 'organization:integrations:view' + field :success_redirect_url, String, null: true, permission: 'organization:integrations:view' + end + end +end diff --git a/app/graphql/types/payment_providers/cashfree_input.rb b/app/graphql/types/payment_providers/cashfree_input.rb new file mode 100644 index 00000000000..a18f0986d46 --- /dev/null +++ b/app/graphql/types/payment_providers/cashfree_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class CashfreeInput < BaseInputObject + description 'Cashfree input arguments' + + argument :client_id, String, required: true + argument :client_secret, String, required: true + argument :code, String, required: true + argument :name, String, required: true + argument :success_redirect_url, String, required: false + end + end +end diff --git a/app/graphql/types/payment_providers/object.rb b/app/graphql/types/payment_providers/object.rb index e1591f4dd2a..a5ebf1ab491 100644 --- a/app/graphql/types/payment_providers/object.rb +++ b/app/graphql/types/payment_providers/object.rb @@ -7,7 +7,8 @@ class Object < Types::BaseUnion possible_types Types::PaymentProviders::Adyen, Types::PaymentProviders::Gocardless, - Types::PaymentProviders::Stripe + Types::PaymentProviders::Stripe, + Types::PaymentProviders::Cashfree def self.resolve_type(object, _context) case object.class.to_s @@ -17,6 +18,8 @@ def self.resolve_type(object, _context) Types::PaymentProviders::Stripe when 'PaymentProviders::GocardlessProvider' Types::PaymentProviders::Gocardless + when 'PaymentProviders::CashfreeProvider' + Types::PaymentProviders::Cashfree else raise "Unexpected Payment provider type: #{object.inspect}" end diff --git a/app/jobs/payment_providers/cashfree/handle_event_job.rb b/app/jobs/payment_providers/cashfree/handle_event_job.rb new file mode 100644 index 00000000000..a58e236cf08 --- /dev/null +++ b/app/jobs/payment_providers/cashfree/handle_event_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + class HandleEventJob < ApplicationJob + queue_as 'providers' + + def perform(event_json:) + result = PaymentProviders::CashfreeService.new.handle_event(event_json:) + result.raise_if_error! + end + end + end +end diff --git a/app/models/customer.rb b/app/models/customer.rb index 91015b26df4..8c795201e1f 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -60,6 +60,7 @@ class Customer < ApplicationRecord has_one :stripe_customer, class_name: 'PaymentProviderCustomers::StripeCustomer' has_one :gocardless_customer, class_name: 'PaymentProviderCustomers::GocardlessCustomer' + has_one :cashfree_customer, class_name: 'PaymentProviderCustomers::CashfreeCustomer' has_one :adyen_customer, class_name: 'PaymentProviderCustomers::AdyenCustomer' has_one :netsuite_customer, class_name: 'IntegrationCustomers::NetsuiteCustomer' has_one :anrok_customer, class_name: 'IntegrationCustomers::AnrokCustomer' @@ -67,7 +68,7 @@ class Customer < ApplicationRecord has_one :hubspot_customer, class_name: 'IntegrationCustomers::HubspotCustomer' has_one :salesforce_customer, class_name: 'IntegrationCustomers::SalesforceCustomer' - PAYMENT_PROVIDERS = %w[stripe gocardless adyen].freeze + PAYMENT_PROVIDERS = %w[stripe gocardless cashfree adyen].freeze default_scope -> { kept } sequenced scope: ->(customer) { customer.organization.customers.with_discarded }, @@ -157,6 +158,8 @@ def provider_customer stripe_customer when :gocardless gocardless_customer + when :cashfree + cashfree_customer when :adyen adyen_customer end diff --git a/app/models/organization.rb b/app/models/organization.rb index bba7409e352..a4fa8ad2292 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -39,9 +39,10 @@ class Organization < ApplicationRecord has_many :error_details has_many :dunning_campaigns - has_many :stripe_payment_providers, class_name: "PaymentProviders::StripeProvider" - has_many :gocardless_payment_providers, class_name: "PaymentProviders::GocardlessProvider" - has_many :adyen_payment_providers, class_name: "PaymentProviders::AdyenProvider" + has_many :stripe_payment_providers, class_name: 'PaymentProviders::StripeProvider' + has_many :gocardless_payment_providers, class_name: 'PaymentProviders::GocardlessProvider' + has_many :cashfree_payment_providers, class_name: 'PaymentProviders::CashfreeProvider' + has_many :adyen_payment_providers, class_name: 'PaymentProviders::AdyenProvider' has_many :hubspot_integrations, class_name: "Integrations::HubspotIntegration" has_many :netsuite_integrations, class_name: "Integrations::NetsuiteIntegration" @@ -124,7 +125,9 @@ def payment_provider(provider) stripe_payment_provider when "gocardless" gocardless_payment_provider - when "adyen" + when 'cashfree' + cashfree_payment_provider + when 'adyen' adyen_payment_provider end end diff --git a/app/models/payment_provider_customers/cashfree_customer.rb b/app/models/payment_provider_customers/cashfree_customer.rb new file mode 100644 index 00000000000..b1239cff8ad --- /dev/null +++ b/app/models/payment_provider_customers/cashfree_customer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class CashfreeCustomer < BaseCustomer + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# +# id :uuid not null, primary key +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_providers/cashfree_provider.rb b/app/models/payment_providers/cashfree_provider.rb new file mode 100644 index 00000000000..1788a4845a7 --- /dev/null +++ b/app/models/payment_providers/cashfree_provider.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module PaymentProviders + class CashfreeProvider < BaseProvider + 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') + + validates :client_id, presence: true + validates :client_secret, presence: true + validates :success_redirect_url, url: true, allow_nil: true, length: {maximum: 1024} + + secrets_accessors :client_id, :client_secret + end +end + +# == Schema Information +# +# Table name: payment_providers +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/serializers/v1/customer_serializer.rb b/app/serializers/v1/customer_serializer.rb index 3f102bad4f4..ec4c07564e9 100644 --- a/app/serializers/v1/customer_serializer.rb +++ b/app/serializers/v1/customer_serializer.rb @@ -73,6 +73,9 @@ def billing_configuration when :gocardless configuration[:provider_customer_id] = model.gocardless_customer&.provider_customer_id configuration.merge!(model.gocardless_customer&.settings&.symbolize_keys || {}) + when :cashfree + configuration[:provider_customer_id] = model.cashfree_customer&.provider_customer_id + configuration.merge!(model.cashfree_customer&.settings&.symbolize_keys || {}) when :adyen configuration[:provider_customer_id] = model.adyen_customer&.provider_customer_id configuration.merge!(model.adyen_customer&.settings&.symbolize_keys || {}) diff --git a/app/services/invoices/payments/cashfree_service.rb b/app/services/invoices/payments/cashfree_service.rb new file mode 100644 index 00000000000..f952ef3a776 --- /dev/null +++ b/app/services/invoices/payments/cashfree_service.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module Invoices + 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(invoice = nil) + @invoice = invoice + + super(nil) + 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 + + 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) + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + 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"] + + result + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + result.service_failure!(code: e.error_code, message: e.error_body) + end + + private + + attr_accessor :invoice + + delegate :organization, :customer, to: :invoice + + def should_process_payment? + return false if invoice.payment_succeeded? || invoice.voided? + return false if cashfree_payment_provider.blank? + + customer&.cashfree_customer&.id + end + + def client + @client ||= LagoHttpClient::Client.new(::PaymentProviders::CashfreeProvider::BASE_URL) + end + + def create_post_request(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 cashfree_payment_provider + @cashfree_payment_provider ||= payment_provider(customer) + 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_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601 + }, + link_id: "#{SecureRandom.uuid}.#{invoice.payment_attempts}", + link_amount: invoice.total_amount_cents / 100.to_f, + link_currency: invoice.currency.upcase, + link_purpose: invoice.id, + link_expiry_time: (Time.current + 10.minutes).iso8601, + link_partial_payments: false, + link_auto_reminders: false + } + 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) + @invoice = result.invoice + result = Invoices::UpdateService.call( + invoice:, + params: { + payment_status:, + ready_for_payment_processing: payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ) + result.raise_if_error! + end + + def deliver_error_webhook(cashfree_error) + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: customer.cashfree_customer.provider_customer_id, + provider_error: { + message: cashfree_error.error_body, + error_code: cashfree_error.error_code + } + }) + end + end + end +end diff --git a/app/services/invoices/payments/payment_providers/factory.rb b/app/services/invoices/payments/payment_providers/factory.rb index 1fddd97ccd0..ab71253612e 100644 --- a/app/services/invoices/payments/payment_providers/factory.rb +++ b/app/services/invoices/payments/payment_providers/factory.rb @@ -16,6 +16,8 @@ def self.service_class(payment_provider) Invoices::Payments::AdyenService when 'gocardless' Invoices::Payments::GocardlessService + when 'cashfree' + Invoices::Payments::CashfreeService else raise(NotImplementedError) end diff --git a/app/services/payment_provider_customers/cashfree_service.rb b/app/services/payment_provider_customers/cashfree_service.rb new file mode 100644 index 00000000000..73f8b3f11a1 --- /dev/null +++ b/app/services/payment_provider_customers/cashfree_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class CashfreeService < BaseService + include Customers::PaymentProviderFinder + + def initialize(cashfree_customer = nil) + @cashfree_customer = cashfree_customer + + super(nil) + end + + def create + result.cashfree_customer = cashfree_customer + result + end + + def update + result + end + + def generate_checkout_url(send_webhook: true) + result.not_allowed_failure!(code: 'feature_not_supported') + end + + private + + attr_accessor :cashfree_customer + + delegate :customer, to: :cashfree_customer + end +end diff --git a/app/services/payment_provider_customers/factory.rb b/app/services/payment_provider_customers/factory.rb index 28f471a20af..a83b2151268 100644 --- a/app/services/payment_provider_customers/factory.rb +++ b/app/services/payment_provider_customers/factory.rb @@ -12,6 +12,8 @@ def self.service_class(provider_customer) PaymentProviderCustomers::StripeService when 'PaymentProviderCustomers::GocardlessCustomer' PaymentProviderCustomers::GocardlessService + when 'PaymentProviderCustomers::CashfreeCustomer' + PaymentProviderCustomers::CashfreeService when 'PaymentProviderCustomers::AdyenCustomer' PaymentProviderCustomers::AdyenService else diff --git a/app/services/payment_providers/cashfree/payments/create_service.rb b/app/services/payment_providers/cashfree/payments/create_service.rb new file mode 100644 index 00000000000..8b508285692 --- /dev/null +++ b/app/services/payment_providers/cashfree/payments/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + module Payments + class CreateService < BaseService + include Customers::PaymentProviderFinder + + PENDING_STATUSES = %w[PARTIALLY_PAID].freeze + SUCCESS_STATUSES = %w[PAID].freeze + FAILED_STATUSES = %w[EXPIRED CANCELLED].freeze + + def initialize(payment:) + @payment = payment + @invoice = payment.payable + @provider_customer = payment.payment_provider_customer + + super + end + + def call + result.payment = payment + result + end + + private + + attr_reader :payment, :invoice, :provider_customer + + delegate :payment_provider, :customer, to: :provider_customer + end + end + end +end diff --git a/app/services/payment_providers/cashfree_service.rb b/app/services/payment_providers/cashfree_service.rb new file mode 100644 index 00000000000..82c2752f8af --- /dev/null +++ b/app/services/payment_providers/cashfree_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module PaymentProviders + class CashfreeService < BaseService + LINK_STATUS_ACTIONS = %w[PAID].freeze + PAYMENT_ACTIONS = %w[SUCCESS FAILED USER_DROPPED CANCELLED VOID PENDING FLAGGED NOT_ATTEMPTED].freeze + # REFUND_ACTIONS = %w[created funds_returned paid refund_settled failed].freeze + + def create_or_update(**args) + payment_provider_result = PaymentProviders::FindService.call( + organization_id: args[:organization].id, + code: args[:code], + id: args[:id], + payment_provider_type: 'cashfree' + ) + + cashfree_provider = if payment_provider_result.success? + payment_provider_result.payment_provider + else + PaymentProviders::CashfreeProvider.new( + organization_id: args[:organization].id, + code: args[:code] + ) + end + + cashfree_provider.client_id = args[:client_id] if args.key?(:client_id) + cashfree_provider.client_secret = args[:client_secret] if args.key?(:client_secret) + cashfree_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) + cashfree_provider.code = args[:code] if args.key?(:code) + cashfree_provider.name = args[:name] if args.key?(:name) + cashfree_provider.save! + + result.cashfree_provider = cashfree_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + def handle_incoming_webhook(organization_id:, body:, timestamp:, signature:, code: nil) + payment_provider_result = PaymentProviders::FindService.call( + organization_id:, + code:, + payment_provider_type: 'cashfree' + ) + + return payment_provider_result unless payment_provider_result.success? + + secret_key = payment_provider_result.payment_provider.client_secret + data = "#{timestamp}#{body}" + gen_signature = Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', secret_key, data)) + + unless gen_signature == signature + return result.service_failure!(code: 'webhook_error', message: 'Invalid signature') + end + + PaymentProviders::Cashfree::HandleEventJob.perform_later(event_json: 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_providers/create_payment_factory.rb b/app/services/payment_providers/create_payment_factory.rb index 8f8e0ddb81d..5a9af3da14a 100644 --- a/app/services/payment_providers/create_payment_factory.rb +++ b/app/services/payment_providers/create_payment_factory.rb @@ -12,6 +12,8 @@ def self.service_class(provider:) case provider.to_sym when :adyen PaymentProviders::Adyen::Payments::CreateService + when :cashfree + PaymentProviders::Cachfree::Payments::CreateService when :gocardless PaymentProviders::Gocardless::Payments::CreateService when :stripe diff --git a/config/routes.rb b/config/routes.rb index 1db92962885..e5c4d74f4fc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -88,6 +88,7 @@ resources :webhooks, only: [] do post 'stripe/:organization_id', to: 'webhooks#stripe', on: :collection, as: :stripe + post 'cashfree/:organization_id', to: 'webhooks#cashfree', on: :collection, as: :cashfree post 'gocardless/:organization_id', to: 'webhooks#gocardless', on: :collection, as: :gocardless post 'adyen/:organization_id', to: 'webhooks#adyen', on: :collection, as: :adyen end diff --git a/schema.graphql b/schema.graphql index a4b81474c5f..33830f2bc10 100644 --- a/schema.graphql +++ b/schema.graphql @@ -33,6 +33,22 @@ input AddAdyenPaymentProviderInput { successRedirectUrl: String } +""" +Cashfree input arguments +""" +input AddCashfreePaymentProviderInput { + clientId: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + clientSecret: String! + code: String! + name: String! + successRedirectUrl: String +} + """ Gocardless input arguments """ @@ -299,6 +315,15 @@ enum BillingTimeEnum { calendar } +type CashfreeProvider { + clientId: String + clientSecret: String + code: String! + id: ID! + name: String! + successRedirectUrl: String +} + type Charge { billableMetric: BillableMetric! chargeModel: ChargeModelEnum! @@ -3142,6 +3167,7 @@ type CurrentOrganization { apiKey: String appliedDunningCampaign: DunningCampaign billingConfiguration: OrganizationBillingConfiguration + cashfreePaymentProviders: [CashfreeProvider!] city: String country: CountryCode createdAt: ISO8601DateTime! @@ -4862,6 +4888,16 @@ type Mutation { input: AddAdyenPaymentProviderInput! ): AdyenProvider + """ + Add or update Cashfree payment provider + """ + addCashfreePaymentProvider( + """ + Parameters for AddCashfreePaymentProvider + """ + input: AddCashfreePaymentProviderInput! + ): CashfreeProvider + """ Add or update Gocardless payment provider """ @@ -5760,6 +5796,16 @@ type Mutation { input: UpdateBillableMetricInput! ): BillableMetric + """ + Update Cashfree payment provider + """ + updateCashfreePaymentProvider( + """ + Parameters for UpdateCashfreePaymentProvider + """ + input: UpdateCashfreePaymentProviderInput! + ): CashfreeProvider + """ Update an existing coupon """ @@ -6152,7 +6198,7 @@ type OverdueBalanceCollection { metadata: CollectionMetadata! } -union PaymentProvider = AdyenProvider | GocardlessProvider | StripeProvider +union PaymentProvider = AdyenProvider | CashfreeProvider | GocardlessProvider | StripeProvider """ PaymentProviderCollection type @@ -6433,6 +6479,7 @@ enum ProviderPaymentMethodsEnum { enum ProviderTypeEnum { adyen + cashfree gocardless stripe } @@ -8264,6 +8311,20 @@ input UpdateBillableMetricInput { weightedInterval: WeightedIntervalEnum } +""" +Update input arguments +""" +input UpdateCashfreePaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + id: ID! + name: String + successRedirectUrl: String +} + """ Autogenerated input type of UpdateCoupon """ diff --git a/schema.json b/schema.json index f453990baaf..51966452b2f 100644 --- a/schema.json +++ b/schema.json @@ -203,6 +203,105 @@ ], "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "AddCashfreePaymentProviderInput", + "description": "Cashfree input arguments", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "clientId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "INPUT_OBJECT", "name": "AddGocardlessPaymentProviderInput", @@ -2420,6 +2519,115 @@ "inputFields": null, "enumValues": null }, + { + "kind": "OBJECT", + "name": "CashfreeProvider", + "description": null, + "interfaces": [ + + ], + "possibleTypes": null, + "fields": [ + { + "name": "clientId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "clientSecret", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + } + ], + "inputFields": null, + "enumValues": null + }, { "kind": "OBJECT", "name": "Charge", @@ -11724,6 +11932,28 @@ "deprecationReason": null, "args": [] }, + { + "name": "cashfreePaymentProviders", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, { "name": "city", "description": null, @@ -23480,6 +23710,35 @@ } ] }, + { + "name": "addCashfreePaymentProvider", + "description": "Add or update Cashfree payment provider", + "type": { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for AddCashfreePaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddCashfreePaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "addGocardlessPaymentProvider", "description": "Add or update Gocardless payment provider", @@ -26127,6 +26386,35 @@ } ] }, + { + "name": "updateCashfreePaymentProvider", + "description": "Update Cashfree payment provider", + "type": { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCashfreePaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCashfreePaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "updateCoupon", "description": "Update an existing coupon", @@ -27858,6 +28146,11 @@ "name": "AdyenProvider", "ofType": null }, + { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + }, { "kind": "OBJECT", "name": "GocardlessProvider", @@ -30775,6 +31068,12 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "cashfree", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "adyen", "description": null, @@ -38440,6 +38739,81 @@ ], "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCashfreePaymentProviderInput", + "description": "Update input arguments", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "INPUT_OBJECT", "name": "UpdateCouponInput", diff --git a/spec/factories/payment_provider_customers.rb b/spec/factories/payment_provider_customers.rb index 9cd9c65e450..af6614832f6 100644 --- a/spec/factories/payment_provider_customers.rb +++ b/spec/factories/payment_provider_customers.rb @@ -14,6 +14,12 @@ provider_customer_id { SecureRandom.uuid } end + factory :cashfree_customer, class: 'PaymentProviderCustomers::CashfreeCustomer' do + customer + + provider_customer_id { SecureRandom.uuid } + end + factory :adyen_customer, class: 'PaymentProviderCustomers::AdyenCustomer' do customer diff --git a/spec/factories/payment_providers.rb b/spec/factories/payment_providers.rb index 63e8fc73ae6..ddf0ffbecd1 100644 --- a/spec/factories/payment_providers.rb +++ b/spec/factories/payment_providers.rb @@ -61,4 +61,23 @@ success_redirect_url { Faker::Internet.url } end end + + factory :cashfree_provider, class: 'PaymentProviders::CashfreeProvider' do + organization + type { 'PaymentProviders::CashfreeProvider' } + code { "cashfree_account_#{SecureRandom.uuid}" } + name { 'Cashfree Account 1' } + + secrets do + {client_id: SecureRandom.uuid, client_secret: SecureRandom.uuid}.to_json + end + + settings do + {success_redirect_url:} + end + + transient do + success_redirect_url { Faker::Internet.url } + end + end end diff --git a/spec/fixtures/cashfree/event.json b/spec/fixtures/cashfree/event.json new file mode 100644 index 00000000000..984d74b87dc --- /dev/null +++ b/spec/fixtures/cashfree/event.json @@ -0,0 +1 @@ +{"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/graphql/mutations/payment_providers/cashfree/create_spec.rb b/spec/graphql/mutations/payment_providers/cashfree/create_spec.rb new file mode 100644 index 00000000000..3e22a1324ab --- /dev/null +++ b/spec/graphql/mutations/payment_providers/cashfree/create_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::PaymentProviders::Cashfree::Create, type: :graphql do + let(:required_permission) { 'organization:integrations:create' } + let(:membership) { create(:membership) } + let(:client_id) { '123456_abc' } + let(:client_secret) { 'cfsk_ma_prod_abc_123456' } + let(:code) { 'cashfree_1' } + let(:name) { 'Cashfree 1' } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: AddCashfreePaymentProviderInput!) { + addCashfreePaymentProvider(input: $input) { + id, + code, + name, + clientId, + clientSecret + successRedirectUrl + } + } + GQL + end + + it_behaves_like 'requires current user' + it_behaves_like 'requires current organization' + it_behaves_like 'requires permission', 'organization:integrations:create' + + it 'creates a cashfree provider' do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, 'organization:integrations:view'], + query: mutation, + variables: { + input: { + code:, + name:, + clientId: client_id, + clientSecret: client_secret, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result['data']['addCashfreePaymentProvider'] + + aggregate_failures do + expect(result_data['id']).to be_present + expect(result_data['code']).to eq(code) + expect(result_data['name']).to eq(name) + expect(result_data['clientId']).to eq(client_id) + expect(result_data['clientSecret']).to eq(client_secret) + expect(result_data['successRedirectUrl']).to eq(success_redirect_url) + end + end +end diff --git a/spec/graphql/mutations/payment_providers/cashfree/update_spec.rb b/spec/graphql/mutations/payment_providers/cashfree/update_spec.rb new file mode 100644 index 00000000000..0721628035d --- /dev/null +++ b/spec/graphql/mutations/payment_providers/cashfree/update_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::PaymentProviders::Cashfree::Update, type: :graphql do + let(:required_permission) { 'organization:integrations:update' } + let(:membership) { create(:membership) } + let(:cashfree_provider) { create(:cashfree_provider, organization: membership.organization) } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: UpdateCashfreePaymentProviderInput!) { + updateCashfreePaymentProvider(input: $input) { + id, + successRedirectUrl + } + } + GQL + end + + it_behaves_like 'requires current user' + it_behaves_like 'requires current organization' + it_behaves_like 'requires permission', 'organization:integrations:update' + + it 'updates an cashfree provider' do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, 'organization:integrations:view'], + query: mutation, + variables: { + input: { + id: cashfree_provider.id, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result['data']['updateCashfreePaymentProvider'] + + expect(result_data['successRedirectUrl']).to eq(success_redirect_url) + end + + context 'when success redirect url is nil' do + it 'removes success redirect url from the provider' do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: cashfree_provider.id, + successRedirectUrl: nil + } + } + ) + + result_data = result['data']['updateCashfreePaymentProvider'] + + expect(result_data['successRedirectUrl']).to eq(nil) + end + end +end diff --git a/spec/graphql/resolvers/payment_provider_resolver_spec.rb b/spec/graphql/resolvers/payment_provider_resolver_spec.rb index 03d16a75f62..97a9d690ca9 100644 --- a/spec/graphql/resolvers/payment_provider_resolver_spec.rb +++ b/spec/graphql/resolvers/payment_provider_resolver_spec.rb @@ -14,6 +14,12 @@ name __typename } + ... on CashfreeProvider { + id + code + name + __typename + } ... on GocardlessProvider { id code diff --git a/spec/graphql/resolvers/payment_providers_resolver_spec.rb b/spec/graphql/resolvers/payment_providers_resolver_spec.rb index 539d9eb8ebf..cf6678d339e 100644 --- a/spec/graphql/resolvers/payment_providers_resolver_spec.rb +++ b/spec/graphql/resolvers/payment_providers_resolver_spec.rb @@ -14,6 +14,11 @@ code __typename } + ... on CashfreeProvider { + id + code + __typename + } ... on GocardlessProvider { id code @@ -34,11 +39,13 @@ let(:membership) { create(:membership) } let(:organization) { membership.organization } let(:adyen_provider) { create(:adyen_provider, organization:) } + let(:cashfree_provider) { create(:cashfree_provider, organization:) } let(:gocardless_provider) { create(:gocardless_provider, organization:) } let(:stripe_provider) { create(:stripe_provider, organization:) } before do adyen_provider + cashfree_provider gocardless_provider stripe_provider end @@ -58,6 +65,11 @@ code __typename } + ... on CashfreeProvider { + id + code + __typename + } ... on GocardlessProvider { id code @@ -106,6 +118,11 @@ code __typename } + ... on CashfreeProvider { + id + code + __typename + } ... on GocardlessProvider { id code @@ -136,6 +153,9 @@ adyen_provider_result = payment_providers_response['collection'].find do |record| record['__typename'] == 'AdyenProvider' end + cashfree_provider_result = payment_providers_response['collection'].find do |record| + record['__typename'] == 'CashfreeProvider' + end gocardless_provider_result = payment_providers_response['collection'].find do |record| record['__typename'] == 'GocardlessProvider' end @@ -144,14 +164,15 @@ end aggregate_failures do - expect(payment_providers_response['collection'].count).to eq(3) + expect(payment_providers_response['collection'].count).to eq(4) expect(adyen_provider_result['id']).to eq(adyen_provider.id) + expect(cashfree_provider_result['id']).to eq(cashfree_provider.id) expect(gocardless_provider_result['id']).to eq(gocardless_provider.id) expect(stripe_provider_result['id']).to eq(stripe_provider.id) expect(payment_providers_response['metadata']['currentPage']).to eq(1) - expect(payment_providers_response['metadata']['totalCount']).to eq(3) + expect(payment_providers_response['metadata']['totalCount']).to eq(4) end end end @@ -165,6 +186,10 @@ ... on AdyenProvider { livePrefix } + ... on CashfreeProvider { + clientId + clientSecret + } ... on GocardlessProvider { hasAccessToken } @@ -188,11 +213,13 @@ ) expect(adyen_provider.live_prefix).to be_a String + expect(cashfree_provider.client_id).to be_a String + expect(cashfree_provider.client_secret).to be_a String expect(gocardless_provider.access_token).to be_a String expect(stripe_provider.success_redirect_url).to be_a String payment_providers_response = result['data']['paymentProviders']['collection'] - expect(payment_providers_response.map(&:values)).to eq [[nil], [nil], [nil]] + expect(payment_providers_response.map(&:values)).to eq [[nil], [nil, nil], [nil], [nil]] end end @@ -206,7 +233,7 @@ ) payment_providers_response = result['data']['paymentProviders']['collection'] - expect(payment_providers_response.map(&:values)).to eq [[adyen_provider.live_prefix], [true], [stripe_provider.success_redirect_url]] + expect(payment_providers_response.map(&:values)).to eq [[adyen_provider.live_prefix], [cashfree_provider.client_id, cashfree_provider.client_secret], [true], [stripe_provider.success_redirect_url]] end end end diff --git a/spec/graphql/types/organizations/current_organization_type_spec.rb b/spec/graphql/types/organizations/current_organization_type_spec.rb index 2950055d292..fa7bcdb4188 100644 --- a/spec/graphql/types/organizations/current_organization_type_spec.rb +++ b/spec/graphql/types/organizations/current_organization_type_spec.rb @@ -39,6 +39,7 @@ it { is_expected.to have_field(:taxes).of_type('[Tax!]').with_permission('organization:taxes:view') } it { is_expected.to have_field(:adyen_payment_providers).of_type('[AdyenProvider!]').with_permission('organization:integrations:view') } + it { is_expected.to have_field(:cashfree_payment_providers).of_type('[CashfreeProvider!]').with_permission('organization:integrations:view') } it { is_expected.to have_field(:gocardless_payment_providers).of_type('[GocardlessProvider!]').with_permission('organization:integrations:view') } it { is_expected.to have_field(:stripe_payment_providers).of_type('[StripeProvider!]').with_permission('organization:integrations:view') } diff --git a/spec/graphql/types/payment_providers/cashfree_input_spec.rb b/spec/graphql/types/payment_providers/cashfree_input_spec.rb new file mode 100644 index 00000000000..93de2c1ded3 --- /dev/null +++ b/spec/graphql/types/payment_providers/cashfree_input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Types::PaymentProviders::CashfreeInput do + subject { described_class } + + it { is_expected.to accept_argument(:client_id).of_type('String!') } + it { is_expected.to accept_argument(:client_secret).of_type('String!') } + it { is_expected.to accept_argument(:code).of_type('String!') } + it { is_expected.to accept_argument(:name).of_type('String!') } + it { is_expected.to accept_argument(:success_redirect_url).of_type('String') } +end diff --git a/spec/graphql/types/payment_providers/cashfree_spec.rb b/spec/graphql/types/payment_providers/cashfree_spec.rb new file mode 100644 index 00000000000..bd41d543e1a --- /dev/null +++ b/spec/graphql/types/payment_providers/cashfree_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Types::PaymentProviders::Cashfree do + subject { described_class } + + it { is_expected.to have_field(:id).of_type('ID!') } + it { is_expected.to have_field(:code).of_type('String!') } + it { is_expected.to have_field(:name).of_type('String!') } + + it { is_expected.to have_field(:client_id).of_type('String').with_permission('organization:integrations:view') } + it { is_expected.to have_field(:client_secret).of_type('String').with_permission('organization:integrations:view') } + it { is_expected.to have_field(:success_redirect_url).of_type('String').with_permission('organization:integrations:view') } +end diff --git a/spec/requests/webhooks_controller_spec.rb b/spec/requests/webhooks_controller_spec.rb index 6c523334a46..39fcaf1e2f5 100644 --- a/spec/requests/webhooks_controller_spec.rb +++ b/spec/requests/webhooks_controller_spec.rb @@ -198,4 +198,79 @@ end end end + + describe 'POST /cashfree' do + let(:organization) { create(:organization) } + + let(:cashfree_provider) do + create(:cashfree_provider, organization:) + end + + let(:cashfree_service) { instance_double(PaymentProviders::CashfreeService) } + + let(:body) do + path = Rails.root.join('spec/fixtures/cashfree/event.json') + JSON.parse(File.read(path)) + end + + let(:result) do + result = BaseService::Result.new + result.body = body + result + end + + before do + allow(PaymentProviders::CashfreeService).to receive(:new) + .and_return(cashfree_service) + allow(cashfree_service).to receive(:handle_incoming_webhook) + .with( + organization_id: organization.id, + code: nil, + body: body.to_json, + timestamp: '1629271506', + signature: 'MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=' + ) + .and_return(result) + end + + 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=' + } + ) + + expect(response).to have_http_status(:success) + + expect(PaymentProviders::CashfreeService).to have_received(:new) + expect(cashfree_service).to have_received(:handle_incoming_webhook) + end + + context 'when failing to handle cashfree event' do + let(:result) do + BaseService::Result.new.service_failure!(code: 'webhook_error', message: 'Invalid payload') + end + + 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=' + } + ) + + expect(response).to have_http_status(:bad_request) + + expect(PaymentProviders::CashfreeService).to have_received(:new) + expect(cashfree_service).to have_received(:handle_incoming_webhook) + end + end + end end diff --git a/spec/services/invoices/payments/payment_providers/factory_spec.rb b/spec/services/invoices/payments/payment_providers/factory_spec.rb index 60418face2d..6320781aa96 100644 --- a/spec/services/invoices/payments/payment_providers/factory_spec.rb +++ b/spec/services/invoices/payments/payment_providers/factory_spec.rb @@ -31,5 +31,13 @@ expect(factory_service.class.to_s).to eq('Invoices::Payments::GocardlessService') end end + + context 'when cashfree' do + let(:payment_provider) { 'cashfree' } + + it 'returns correct class' do + expect(factory_service.class.to_s).to eq('Invoices::Payments::CashfreeService') + end + end end end diff --git a/spec/services/payment_providers/cashfree_service_spec.rb b/spec/services/payment_providers/cashfree_service_spec.rb new file mode 100644 index 00000000000..673328ce80e --- /dev/null +++ b/spec/services/payment_providers/cashfree_service_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentProviders::CashfreeService, type: :service do + subject(:cashfree_service) { described_class.new(membership.user) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:code) { 'code_1' } + let(:name) { 'Name 1' } + let(:client_id) { '123456_abc' } + let(:client_secret) { 'cfsk_ma_prod_abc_123456' } + let(:success_redirect_url) { Faker::Internet.url } + + describe '.create_or_update' do + it 'creates a cashfree provider' do + expect do + cashfree_service.create_or_update( + organization:, + code:, + name:, + client_id:, + client_secret:, + success_redirect_url: + ) + end.to change(PaymentProviders::CashfreeProvider, :count).by(1) + end + + context 'when organization already have a cashfree provider' do + let(:cashfree_provider) do + create(:cashfree_provider, organization:, client_id: '123456_abc_old', client_secret: 'cfsk_ma_prod_abc_123456_old', code:) + end + + before { cashfree_provider } + + it 'updates the existing provider' do + result = cashfree_service.create_or_update( + organization:, + code:, + name:, + client_id:, + client_secret:, + success_redirect_url: + ) + + expect(result).to be_success + + aggregate_failures do + expect(result.cashfree_provider.id).to eq(cashfree_provider.id) + expect(result.cashfree_provider.client_id).to eq('123456_abc') + expect(result.cashfree_provider.client_secret).to eq('cfsk_ma_prod_abc_123456') + expect(result.cashfree_provider.code).to eq(code) + expect(result.cashfree_provider.name).to eq(name) + expect(result.cashfree_provider.success_redirect_url).to eq(success_redirect_url) + end + end + end + + context 'with validation error' do + let(:token) { nil } + + it 'returns an error result' do + result = cashfree_service.create_or_update( + organization: + ) + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:client_id]).to eq(['value_is_mandatory']) + expect(result.error.messages[:client_secret]).to eq(['value_is_mandatory']) + end + end + end + end + + describe '.handle_incoming_webhook' do + 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) + end + + before { cashfree_provider } + + it 'checks the webhook' do + result = cashfree_service.handle_incoming_webhook( + organization_id: organization.id, + body:, + timestamp: '1629271506', + signature: 'MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=' + ) + + expect(result).to be_success + + expect(PaymentProviders::Cashfree::HandleEventJob).to have_been_enqueued + end + + context 'when failing to validate the signature' do + it 'returns an error' do + result = cashfree_service.handle_incoming_webhook( + organization_id: organization.id, + body:, + timestamp: '1629271506', + signature: 'signature' + ) + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq('webhook_error') + expect(result.error.error_message).to eq('Invalid signature') + end + 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 From 77f23904bf4774b89ec18d59894ec429585f7a86 Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Thu, 31 Oct 2024 16:41:25 +0100 Subject: [PATCH 2/8] fix annotation --- app/models/payment_provider_customers/cashfree_customer.rb | 3 ++- app/models/payment_providers/cashfree_provider.rb | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/payment_provider_customers/cashfree_customer.rb b/app/models/payment_provider_customers/cashfree_customer.rb index b1239cff8ad..2ca86d22399 100644 --- a/app/models/payment_provider_customers/cashfree_customer.rb +++ b/app/models/payment_provider_customers/cashfree_customer.rb @@ -10,6 +10,7 @@ class CashfreeCustomer < BaseCustomer # Table name: payment_provider_customers # # id :uuid not null, primary key +# deleted_at :datetime # settings :jsonb not null # type :string not null # created_at :datetime not null @@ -20,7 +21,7 @@ class CashfreeCustomer < BaseCustomer # # Indexes # -# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) # index_payment_provider_customers_on_payment_provider_id (payment_provider_id) # index_payment_provider_customers_on_provider_customer_id (provider_customer_id) # diff --git a/app/models/payment_providers/cashfree_provider.rb b/app/models/payment_providers/cashfree_provider.rb index 1788a4845a7..e5772d2e6f5 100644 --- a/app/models/payment_providers/cashfree_provider.rb +++ b/app/models/payment_providers/cashfree_provider.rb @@ -20,6 +20,7 @@ class CashfreeProvider < BaseProvider # # id :uuid not null, primary key # code :string not null +# deleted_at :datetime # name :string not null # secrets :string # settings :jsonb not null @@ -30,7 +31,7 @@ class CashfreeProvider < BaseProvider # # Indexes # -# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) # index_payment_providers_on_organization_id (organization_id) # # Foreign Keys From d96098efb232fdd7c33f50efacd43dfe5aa8c169 Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 2 Dec 2024 09:51:09 +0100 Subject: [PATCH 3/8] misc(payment_providers): Split customer creation in --- .../cashfree/customers/create_service.rb | 41 +++++++++++++++++++ .../create_customer_factory.rb | 2 + .../cashfree/customers/create_service_spec.rb | 23 +++++++++++ .../create_customer_factory_spec.rb | 9 ++++ 4 files changed, 75 insertions(+) create mode 100644 app/services/payment_providers/cashfree/customers/create_service.rb create mode 100644 spec/services/payment_providers/cashfree/customers/create_service_spec.rb diff --git a/app/services/payment_providers/cashfree/customers/create_service.rb b/app/services/payment_providers/cashfree/customers/create_service.rb new file mode 100644 index 00000000000..5e6cae0c4aa --- /dev/null +++ b/app/services/payment_providers/cashfree/customers/create_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + module Customers + class CreateService < BaseService + def initialize(customer:, payment_provider_id:, params:, async: true) + @customer = customer + @payment_provider_id = payment_provider_id + @params = params || {} + @async = async + + super + end + + def call + provider_customer = PaymentProviderCustomers::CashfreeCustomer.find_by(customer_id: customer.id) + provider_customer ||= PaymentProviderCustomers::CashfreeCustomer.new(customer_id: customer.id, payment_provider_id:) + + if params.key?(:sync_with_provider) + provider_customer.sync_with_provider = params[:sync_with_provider].presence + end + + provider_customer.save! + + result.provider_customer = provider_customer + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :customer, :payment_provider_id, :params, :async + + delegate :organization, to: :customer + end + end + end +end diff --git a/app/services/payment_providers/create_customer_factory.rb b/app/services/payment_providers/create_customer_factory.rb index e79c2a81c84..b9ac4f1cfb6 100644 --- a/app/services/payment_providers/create_customer_factory.rb +++ b/app/services/payment_providers/create_customer_factory.rb @@ -10,6 +10,8 @@ def self.service_class(provider:) case provider when "adyen" PaymentProviders::Adyen::Customers::CreateService + when "cashfree" + PaymentProviders::Cashfree::Customers::CreateService when "gocardless" PaymentProviders::Gocardless::Customers::CreateService when "stripe" diff --git a/spec/services/payment_providers/cashfree/customers/create_service_spec.rb b/spec/services/payment_providers/cashfree/customers/create_service_spec.rb new file mode 100644 index 00000000000..dd2f2c256e7 --- /dev/null +++ b/spec/services/payment_providers/cashfree/customers/create_service_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::Customers::CreateService, type: :service do + let(:create_service) { described_class.new(customer:, payment_provider_id:, params:, async:) } + + let(:customer) { create(:customer) } + let(:cashfree_provider) { create(:cashfree_provider, organization: customer.organization) } + let(:payment_provider_id) { cashfree_provider.id } + let(:params) { {provider_customer_id: "id", sync_with_provider: true} } + let(:async) { true } + + describe ".call" do + it "creates a payment_provider_customer without provider_customer_id" do + result = create_service.call + + expect(result).to be_success + expect(result.provider_customer).to be_present + expect(result.provider_customer.provider_customer_id).to be_nil + end + end +end diff --git a/spec/services/payment_providers/create_customer_factory_spec.rb b/spec/services/payment_providers/create_customer_factory_spec.rb index 566a10832ed..0d49b6f0c64 100644 --- a/spec/services/payment_providers/create_customer_factory_spec.rb +++ b/spec/services/payment_providers/create_customer_factory_spec.rb @@ -26,6 +26,15 @@ end end + context "when provider is cashfree" do + let(:provider) { "cashfree" } + let(:payment_provider_id) { create(:cashfree_provider, organization: customer.organization).id } + + it "creates an instance of the cashfree service" do + expect(new_instance).to be_instance_of(PaymentProviders::Cashfree::Customers::CreateService) + end + end + context "when provider is gocardless" do let(:provider) { "gocardless" } let(:payment_provider_id) { create(:gocardless_provider, organization: customer.organization).id } From a3aa8f7ee095203c507c415a7bc0b597518d21e8 Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 2 Dec 2024 10:17:55 +0100 Subject: [PATCH 4/8] Handle code update on payment provider --- .../payment_providers/cashfree_service.rb | 22 +++-- .../cashfree_service_spec.rb | 95 +++++++++++++------ 2 files changed, 78 insertions(+), 39 deletions(-) diff --git a/app/services/payment_providers/cashfree_service.rb b/app/services/payment_providers/cashfree_service.rb index 82c2752f8af..877abeae84c 100644 --- a/app/services/payment_providers/cashfree_service.rb +++ b/app/services/payment_providers/cashfree_service.rb @@ -11,7 +11,7 @@ def create_or_update(**args) organization_id: args[:organization].id, code: args[:code], id: args[:id], - payment_provider_type: 'cashfree' + payment_provider_type: "cashfree" ) cashfree_provider = if payment_provider_result.success? @@ -23,6 +23,8 @@ def create_or_update(**args) ) end + old_code = cashfree_provider.code + cashfree_provider.client_id = args[:client_id] if args.key?(:client_id) cashfree_provider.client_secret = args[:client_secret] if args.key?(:client_secret) cashfree_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) @@ -30,6 +32,10 @@ def create_or_update(**args) cashfree_provider.name = args[:name] if args.key?(:name) cashfree_provider.save! + if payment_provider_code_changed?(cashfree_provider, old_code, args) + cashfree_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations + end + result.cashfree_provider = cashfree_provider result rescue ActiveRecord::RecordInvalid => e @@ -40,17 +46,17 @@ def handle_incoming_webhook(organization_id:, body:, timestamp:, signature:, cod payment_provider_result = PaymentProviders::FindService.call( organization_id:, code:, - payment_provider_type: 'cashfree' + payment_provider_type: "cashfree" ) return payment_provider_result unless payment_provider_result.success? secret_key = payment_provider_result.payment_provider.client_secret data = "#{timestamp}#{body}" - gen_signature = Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', secret_key, data)) + gen_signature = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", secret_key, data)) unless gen_signature == signature - return result.service_failure!(code: 'webhook_error', message: 'Invalid signature') + return result.service_failure!(code: "webhook_error", message: "Invalid signature") end PaymentProviders::Cashfree::HandleEventJob.perform_later(event_json: body) @@ -61,12 +67,12 @@ def handle_incoming_webhook(organization_id:, body:, timestamp:, signature:, cod def handle_event(event_json:) event = JSON.parse(event_json) - event_type = event['type'] + 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') + 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 diff --git a/spec/services/payment_providers/cashfree_service_spec.rb b/spec/services/payment_providers/cashfree_service_spec.rb index 673328ce80e..5673a8956c2 100644 --- a/spec/services/payment_providers/cashfree_service_spec.rb +++ b/spec/services/payment_providers/cashfree_service_spec.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" RSpec.describe PaymentProviders::CashfreeService, type: :service do subject(:cashfree_service) { described_class.new(membership.user) } let(:membership) { create(:membership) } let(:organization) { membership.organization } - let(:code) { 'code_1' } - let(:name) { 'Name 1' } - let(:client_id) { '123456_abc' } - let(:client_secret) { 'cfsk_ma_prod_abc_123456' } + let(:code) { "code_1" } + let(:name) { "Name 1" } + let(:client_id) { "123456_abc" } + let(:client_secret) { "cfsk_ma_prod_abc_123456" } let(:success_redirect_url) { Faker::Internet.url } - describe '.create_or_update' do - it 'creates a cashfree provider' do + describe ".create_or_update" do + it "creates a cashfree provider" do expect do cashfree_service.create_or_update( organization:, @@ -27,14 +27,47 @@ end.to change(PaymentProviders::CashfreeProvider, :count).by(1) end - context 'when organization already have a cashfree provider' do + context "when code was changed" do + let(:new_code) { "updated_code_1" } + let(:cashfree_customer) { create(:cashfree_customer, payment_provider:, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:payment_provider) do + create( + :cashfree_provider, + organization:, + code:, + name:, + client_secret: "secret" + ) + end + + before { cashfree_customer } + + it "updates payment provider codes of all customers" do + result = cashfree_service.create_or_update( + id: payment_provider.id, + organization:, + code: new_code, + name:, + client_secret: "secret" + ) + + aggregate_failures do + expect(result).to be_success + expect(result.cashfree_provider.customers.first.payment_provider_code).to eq(new_code) + end + end + end + + context "when organization already have a cashfree provider" do let(:cashfree_provider) do - create(:cashfree_provider, organization:, client_id: '123456_abc_old', client_secret: 'cfsk_ma_prod_abc_123456_old', code:) + create(:cashfree_provider, organization:, client_id: "123456_abc_old", client_secret: "cfsk_ma_prod_abc_123456_old", code:) end before { cashfree_provider } - it 'updates the existing provider' do + it "updates the existing provider" do result = cashfree_service.create_or_update( organization:, code:, @@ -48,8 +81,8 @@ aggregate_failures do expect(result.cashfree_provider.id).to eq(cashfree_provider.id) - expect(result.cashfree_provider.client_id).to eq('123456_abc') - expect(result.cashfree_provider.client_secret).to eq('cfsk_ma_prod_abc_123456') + expect(result.cashfree_provider.client_id).to eq("123456_abc") + expect(result.cashfree_provider.client_secret).to eq("cfsk_ma_prod_abc_123456") expect(result.cashfree_provider.code).to eq(code) expect(result.cashfree_provider.name).to eq(name) expect(result.cashfree_provider.success_redirect_url).to eq(success_redirect_url) @@ -57,10 +90,10 @@ end end - context 'with validation error' do + context "with validation error" do let(:token) { nil } - it 'returns an error result' do + it "returns an error result" do result = cashfree_service.create_or_update( organization: ) @@ -68,29 +101,29 @@ aggregate_failures do expect(result).not_to be_success expect(result.error).to be_a(BaseService::ValidationFailure) - expect(result.error.messages[:client_id]).to eq(['value_is_mandatory']) - expect(result.error.messages[:client_secret]).to eq(['value_is_mandatory']) + expect(result.error.messages[:client_id]).to eq(["value_is_mandatory"]) + expect(result.error.messages[:client_secret]).to eq(["value_is_mandatory"]) end end end end - describe '.handle_incoming_webhook' do + describe ".handle_incoming_webhook" do let(:cashfree_provider) { create(:cashfree_provider, organization:, client_id:, client_secret:) } let(:body) do - path = Rails.root.join('spec/fixtures/cashfree/event.json') + path = Rails.root.join("spec/fixtures/cashfree/event.json") File.read(path) end before { cashfree_provider } - it 'checks the webhook' do + it "checks the webhook" do result = cashfree_service.handle_incoming_webhook( organization_id: organization.id, body:, - timestamp: '1629271506', - signature: 'MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=' + timestamp: "1629271506", + signature: "MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=" ) expect(result).to be_success @@ -98,26 +131,26 @@ expect(PaymentProviders::Cashfree::HandleEventJob).to have_been_enqueued end - context 'when failing to validate the signature' do - it 'returns an error' do + context "when failing to validate the signature" do + it "returns an error" do result = cashfree_service.handle_incoming_webhook( organization_id: organization.id, body:, - timestamp: '1629271506', - signature: 'signature' + timestamp: "1629271506", + signature: "signature" ) aggregate_failures do expect(result).not_to be_success expect(result.error).to be_a(BaseService::ServiceFailure) - expect(result.error.code).to eq('webhook_error') - expect(result.error.error_message).to eq('Invalid signature') + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Invalid signature") end end end end - describe '.handle_event' do + describe ".handle_event" do let(:payment_service) { instance_double(Invoices::Payments::CashfreeService) } let(:service_result) { BaseService::Result.new } @@ -128,13 +161,13 @@ .and_return(service_result) end - context 'when succeeded payment event' do + context "when succeeded payment event" do let(:event) do - path = Rails.root.join('spec/fixtures/cashfree/event.json') + path = Rails.root.join("spec/fixtures/cashfree/event.json") File.read(path) end - it 'routes the event to an other service' do + it "routes the event to an other service" do cashfree_service.handle_event(event_json: event) expect(Invoices::Payments::CashfreeService).to have_received(:new) From a5681ed515b02cdc41a31b9b540799241cb4eb42 Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 2 Dec 2024 17:08:36 +0100 Subject: [PATCH 5/8] 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 From f1dc226c1725e3d2ffd2cba92659589aff355d0c Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 2 Dec 2024 17:48:49 +0100 Subject: [PATCH 6/8] Fix handling of provider_customer_id --- .../invoices/payments/cashfree_service.rb | 6 +- .../cashfree/payments/create_service.rb | 6 +- .../payments/cashfree_service.rb | 2 +- schema.json | 32 ++------ .../payments/cashfree_service_spec.rb | 76 ------------------- .../cashfree/payments/create_service_spec.rb | 59 ++++++++++++++ 6 files changed, 78 insertions(+), 103 deletions(-) create mode 100644 spec/services/payment_providers/cashfree/payments/create_service_spec.rb diff --git a/app/services/invoices/payments/cashfree_service.rb b/app/services/invoices/payments/cashfree_service.rb index 7640612c828..c7d9331a727 100644 --- a/app/services/invoices/payments/cashfree_service.rb +++ b/app/services/invoices/payments/cashfree_service.rb @@ -75,6 +75,10 @@ def should_process_payment? !!customer&.cashfree_customer&.id end + def increment_payment_attempts + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + end + def client @client ||= LagoHttpClient::Client.new(::PaymentProviders::CashfreeProvider::BASE_URL) end @@ -150,7 +154,7 @@ def update_invoice_payment_status(payment_status:, deliver_webhook: true) def deliver_error_webhook(cashfree_error) DeliverErrorWebhookService.call_async(invoice, { - provider_customer_id: customer.cashfree_customer.provider_customer_id, + provider_customer_id: customer.cashfree_customer.id, provider_error: { message: cashfree_error.error_body, error_code: cashfree_error.error_code diff --git a/app/services/payment_providers/cashfree/payments/create_service.rb b/app/services/payment_providers/cashfree/payments/create_service.rb index 8b508285692..083441f2b5e 100644 --- a/app/services/payment_providers/cashfree/payments/create_service.rb +++ b/app/services/payment_providers/cashfree/payments/create_service.rb @@ -4,7 +4,7 @@ module PaymentProviders module Cashfree module Payments class CreateService < BaseService - include Customers::PaymentProviderFinder + include ::Customers::PaymentProviderFinder PENDING_STATUSES = %w[PARTIALLY_PAID].freeze SUCCESS_STATUSES = %w[PAID].freeze @@ -20,6 +20,10 @@ def initialize(payment:) def call result.payment = payment + + # NOTE: No need to register the payment with Cashfree Payments for the Payment Link feature. + # Simply create a single `Payment` record and update it upon receiving the webhook, which works perfectly fine. + result end diff --git a/app/services/payment_requests/payments/cashfree_service.rb b/app/services/payment_requests/payments/cashfree_service.rb index 792704c5454..d525bc13a40 100644 --- a/app/services/payment_requests/payments/cashfree_service.rb +++ b/app/services/payment_requests/payments/cashfree_service.rb @@ -205,7 +205,7 @@ def create_payment(cashfree_payment) def deliver_error_webhook(cashfree_error) DeliverErrorWebhookService.call_async(payable, { - provider_customer_id: customer.cashfree_customer.provider_customer_id, + provider_customer_id: customer.cashfree_customer.id, provider_error: { message: cashfree_error.error_body, error_code: cashfree_error.error_code diff --git a/schema.json b/schema.json index 51966452b2f..dd7405a603e 100644 --- a/schema.json +++ b/schema.json @@ -2523,9 +2523,7 @@ "kind": "OBJECT", "name": "CashfreeProvider", "description": null, - "interfaces": [ - - ], + "interfaces": [], "possibleTypes": null, "fields": [ { @@ -2538,9 +2536,7 @@ }, "isDeprecated": false, "deprecationReason": null, - "args": [ - - ] + "args": [] }, { "name": "clientSecret", @@ -2552,9 +2548,7 @@ }, "isDeprecated": false, "deprecationReason": null, - "args": [ - - ] + "args": [] }, { "name": "code", @@ -2570,9 +2564,7 @@ }, "isDeprecated": false, "deprecationReason": null, - "args": [ - - ] + "args": [] }, { "name": "id", @@ -2588,9 +2580,7 @@ }, "isDeprecated": false, "deprecationReason": null, - "args": [ - - ] + "args": [] }, { "name": "name", @@ -2606,9 +2596,7 @@ }, "isDeprecated": false, "deprecationReason": null, - "args": [ - - ] + "args": [] }, { "name": "successRedirectUrl", @@ -2620,9 +2608,7 @@ }, "isDeprecated": false, "deprecationReason": null, - "args": [ - - ] + "args": [] } ], "inputFields": null, @@ -11950,9 +11936,7 @@ }, "isDeprecated": false, "deprecationReason": null, - "args": [ - - ] + "args": [] }, { "name": "city", diff --git a/spec/services/invoices/payments/cashfree_service_spec.rb b/spec/services/invoices/payments/cashfree_service_spec.rb index 8a8624a83da..07c45ef7cc4 100644 --- a/spec/services/invoices/payments/cashfree_service_spec.rb +++ b/spec/services/invoices/payments/cashfree_service_spec.rb @@ -24,82 +24,6 @@ ) 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( diff --git a/spec/services/payment_providers/cashfree/payments/create_service_spec.rb b/spec/services/payment_providers/cashfree/payments/create_service_spec.rb new file mode 100644 index 00000000000..aad4123748c --- /dev/null +++ b/spec/services/payment_providers/cashfree/payments/create_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::Payments::CreateService, type: :service do + subject(:create_service) { described_class.new(payment:) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:chasfree_payment_provider) { create(:cashfree_provider, organization:, code:) } + let(:cashfree_customer) { create(:cashfree_customer, customer:, payment_provider: chasfree_payment_provider) } + let(:code) { "stripe_1" } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + let(:payment) do + create( + :payment, + payable: invoice, + status: "pending", + payable_payment_status: "pending", + payment_provider: chasfree_payment_provider, + payment_provider_customer: cashfree_customer, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency + ) + end + + describe ".call" do + before do + chasfree_payment_provider + cashfree_customer + end + + it "returns the payment and keeps it pending" do + result = create_service.call + + expect(result).to be_success + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(chasfree_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") + expect(result.payment.payable_payment_status).to eq("pending") + end + end +end From 6c04e396f2f4b27ac42f8eae8979e36e657dd94d Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Fri, 10 Jan 2025 14:15:15 +0100 Subject: [PATCH 7/8] fix(cashfree): Improve thirdparty provider error handling --- app/controllers/concerns/api_errors.rb | 16 +++++++++ app/services/base_service.rb | 15 ++++++++ .../invoices/payments/cashfree_service.rb | 3 +- .../payments/generate_payment_url_service.rb | 1 + .../payments/cashfree_service_spec.rb | 33 +++++++++++++++++- .../generate_payment_url_service_spec.rb | 34 +++++++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/api_errors.rb b/app/controllers/concerns/api_errors.rb index 102245148bb..b0f9463eff7 100644 --- a/app/controllers/concerns/api_errors.rb +++ b/app/controllers/concerns/api_errors.rb @@ -57,6 +57,20 @@ def method_not_allowed_error(code:) ) end + def thirdpary_error(error:) + render( + json: { + status: 422, + error: 'Unprocessable Entity', + code: 'third_party_error', + error_details: { + third_party: error.third_party, + thirdparty_error: error.error_message + } + } + ) + end + def render_error_response(error_result) case error_result.error when BaseService::NotFoundFailure @@ -69,6 +83,8 @@ def render_error_response(error_result) forbidden_error(code: error_result.error.code) when BaseService::UnauthorizedFailure unauthorized_error(message: error_result.error.message) + when BaseService::ThirdPartyFailure + thirdpary_error(error: error_result.error) else raise(error_result.error) end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index 44a7de04999..33a3756e148 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -93,6 +93,17 @@ def initialize(result, message:) end end + class ThirdPartyFailure < FailedResult + attr_reader :third_party, :error_message + + def initialize(result, third_party:, error_message:) + @third_party = third_party + @error_message = error_message + + super(result, "#{third_party}: #{error_message}") + end + end + class Result < OpenStruct attr_reader :error @@ -150,6 +161,10 @@ def unauthorized_failure!(message: "unauthorized") fail_with_error!(UnauthorizedFailure.new(self, message:)) end + def third_party_failure!(third_party:, error_message:) + fail_with_error!(ThirdPartyFailure.new(self, third_party:, error_message:)) + end + def raise_if_error! return self if success? diff --git a/app/services/invoices/payments/cashfree_service.rb b/app/services/invoices/payments/cashfree_service.rb index c7d9331a727..09982b65566 100644 --- a/app/services/invoices/payments/cashfree_service.rb +++ b/app/services/invoices/payments/cashfree_service.rb @@ -44,7 +44,8 @@ def generate_payment_url result rescue LagoHttpClient::HttpError => e deliver_error_webhook(e) - result.service_failure!(code: e.error_code, message: e.error_body) + + result.third_party_failure!(third_party: "Cashfree", error_message: e.error_body) end private diff --git a/app/services/invoices/payments/generate_payment_url_service.rb b/app/services/invoices/payments/generate_payment_url_service.rb index 03abd0d08bd..bd4d7254cc2 100644 --- a/app/services/invoices/payments/generate_payment_url_service.rb +++ b/app/services/invoices/payments/generate_payment_url_service.rb @@ -22,6 +22,7 @@ def call payment_url_result = Invoices::Payments::PaymentProviders::Factory.new_instance(invoice:).generate_payment_url return payment_url_result unless payment_url_result.success? + return payment_url_result if payment_url_result.error.is_a?(BaseService::ThirdPartyFailure) if payment_url_result.payment_url.blank? return result.single_validation_failure!(error_code: 'payment_provider_error') diff --git a/spec/services/invoices/payments/cashfree_service_spec.rb b/spec/services/invoices/payments/cashfree_service_spec.rb index 07c45ef7cc4..dd9706e4e39 100644 --- a/spec/services/invoices/payments/cashfree_service_spec.rb +++ b/spec/services/invoices/payments/cashfree_service_spec.rb @@ -168,6 +168,7 @@ describe ".generate_payment_url" do let(:payment_links_response) { Net::HTTPResponse.new("1.0", "200", "OK") } + let(:payment_links_body) { {link_url: "https://payments-test.cashfree.com/links//U1mgll3c0e9g"}.to_json } before do cashfree_payment_provider @@ -178,7 +179,7 @@ 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) + .and_return(payment_links_body) end it "generates payment url" do @@ -206,5 +207,35 @@ expect(result.payment_url).to be_nil end end + + context 'when payment url failed to generate' do + let(:payment_links_response) { Net::HTTPResponse.new("1.0", "400", "Bad Request") } + let(:payment_links_body) do + { + message: "Currency USD is not enabled", + code: "link_post_failed", + type: "invalid_request_error" + }.to_json + end + + 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_raise(::LagoHttpClient::HttpError.new(payment_links_response.code, payment_links_body, nil)) + end + + it 'returns a third party error' do + result = cashfree_service.generate_payment_url + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq('Cashfree') + expect(result.error.error_message).to eq(payment_links_body) + end + end end end diff --git a/spec/services/invoices/payments/generate_payment_url_service_spec.rb b/spec/services/invoices/payments/generate_payment_url_service_spec.rb index 7d26d8f26c6..d47a9d726be 100644 --- a/spec/services/invoices/payments/generate_payment_url_service_spec.rb +++ b/spec/services/invoices/payments/generate_payment_url_service_spec.rb @@ -87,5 +87,39 @@ end end end + + context 'when provider service return a third party error' do + let(:payment_provider) { 'cashfree' } + let(:code) { 'cashfree_1' } + + let(:payment_provider_service) { instance_double(PaymentRequests::Payments::CashfreeService) } + + let(:error_result) do + BaseService::Result.new.tap do |result| + result.fail_with_error!( + BaseService::ThirdPartyFailure.new( + result, + third_party: 'Cashfree', + error_message: '{"code: "link_post_failed", "type": "invalid_request_error"}' + ) + ) + end + end + + before do + allow(PaymentRequests::Payments::CashfreeService) + .to receive(:new) + .and_return(payment_provider_service) + + allow(payment_provider_service).to receive(:generate_payment_url) + .and_return(error_result) + end + + it 'returns a third party error' do + result = generate_payment_url_service.call + + expect(result).to eq(error_result) + end + end end end From cc5bb614acb777c57c5dd4351e66dc0337b79864 Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 13 Jan 2025 14:15:05 +0100 Subject: [PATCH 8/8] misc(cashfree): Apply payment refactors --- app/models/payment_providers/cashfree_provider.rb | 4 ++++ .../invoices/payments/cashfree_service.rb | 15 ++------------- .../cashfree/payments/create_service.rb | 4 ---- .../invoices/payments/cashfree_service_spec.rb | 3 ++- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/app/models/payment_providers/cashfree_provider.rb b/app/models/payment_providers/cashfree_provider.rb index 4cb882e4309..f4a1c304ae6 100644 --- a/app/models/payment_providers/cashfree_provider.rb +++ b/app/models/payment_providers/cashfree_provider.rb @@ -8,6 +8,10 @@ class CashfreeProvider < BaseProvider API_VERSION = "2023-08-01" BASE_URL = (Rails.env.production? ? "https://api.cashfree.com/pg/links" : "https://sandbox.cashfree.com/pg/links") + PROCESSING_STATUSES = %w[PARTIALLY_PAID].freeze + SUCCESS_STATUSES = %w[PAID].freeze + FAILED_STATUSES = %w[EXPIRED CANCELLED].freeze + validates :client_id, presence: true validates :client_secret, presence: true validates :success_redirect_url, url: true, allow_nil: true, length: {maximum: 1024} diff --git a/app/services/invoices/payments/cashfree_service.rb b/app/services/invoices/payments/cashfree_service.rb index 09982b65566..c3e599b52dc 100644 --- a/app/services/invoices/payments/cashfree_service.rb +++ b/app/services/invoices/payments/cashfree_service.rb @@ -5,10 +5,6 @@ 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(invoice = nil) @invoice = invoice @@ -28,7 +24,8 @@ def update_payment_status(organization_id:, status:, cashfree_payment:) return result if payment.payable.payment_succeeded? payment.update!(status:) - update_invoice_payment_status(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 rescue BaseService::FailedResult => e @@ -132,14 +129,6 @@ def payment_url_params } 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) @invoice = result.invoice result = Invoices::UpdateService.call( diff --git a/app/services/payment_providers/cashfree/payments/create_service.rb b/app/services/payment_providers/cashfree/payments/create_service.rb index 083441f2b5e..47a497a8b79 100644 --- a/app/services/payment_providers/cashfree/payments/create_service.rb +++ b/app/services/payment_providers/cashfree/payments/create_service.rb @@ -6,10 +6,6 @@ module Payments class CreateService < BaseService include ::Customers::PaymentProviderFinder - PENDING_STATUSES = %w[PARTIALLY_PAID].freeze - SUCCESS_STATUSES = %w[PAID].freeze - FAILED_STATUSES = %w[EXPIRED CANCELLED].freeze - def initialize(payment:) @payment = payment @invoice = payment.payable diff --git a/spec/services/invoices/payments/cashfree_service_spec.rb b/spec/services/invoices/payments/cashfree_service_spec.rb index dd9706e4e39..805c2b0117f 100644 --- a/spec/services/invoices/payments/cashfree_service_spec.rb +++ b/spec/services/invoices/payments/cashfree_service_spec.rb @@ -30,7 +30,8 @@ :payment, payable: invoice, provider_payment_id: invoice.id, - status: "pending" + status: "pending", + payment_provider: cashfree_payment_provider ) end