diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 67fc93b3407..2c344499ff6 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -20,6 +20,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::GocardlessService.new.handle_incoming_webhook( 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 14b273d31d9..00745d5e855 100644 --- a/app/graphql/types/customers/object.rb +++ b/app/graphql/types/customers/object.rb @@ -113,6 +113,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 a382f8a6b78..313834e9a9c 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -45,10 +45,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 233a81bdbbf..0ceaaaab17c 100644 --- a/app/graphql/types/organizations/current_organization_type.rb +++ b/app/graphql/types/organizations/current_organization_type.rb @@ -45,6 +45,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/invoices/payments/cashfree_create_job.rb b/app/jobs/invoices/payments/cashfree_create_job.rb new file mode 100644 index 00000000000..ccf8c79d91a --- /dev/null +++ b/app/jobs/invoices/payments/cashfree_create_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class CashfreeCreateJob < ApplicationJob + queue_as 'providers' + + unique :until_executed + + def perform(invoice) + result = Invoices::Payments::CashfreeService.new(invoice).create + result.raise_if_error! + end + end + end +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 b841f7241e6..8daaf086f44 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -47,12 +47,13 @@ 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' has_one :xero_customer, class_name: 'IntegrationCustomers::XeroCustomer' - 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 }, @@ -121,6 +122,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 54b8acf5026..305dbc90fc5 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -38,6 +38,7 @@ class Organization < ApplicationRecord 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 :netsuite_integrations, class_name: 'Integrations::NetsuiteIntegration' @@ -106,6 +107,8 @@ def payment_provider(provider) stripe_payment_provider when 'gocardless' gocardless_payment_provider + when 'cashfree' + cashfree_payment_provider when 'adyen' adyen_payment_provider 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 9fea8081c7e..beb86dd52c9 100644 --- a/app/serializers/v1/customer_serializer.rb +++ b/app/serializers/v1/customer_serializer.rb @@ -68,6 +68,9 @@ def billing_configuration when :gocardless configuration[:provider_customer_id] = model.gocardless_customer&.provider_customer_id configuration.merge!(model.gocardless_customer&.settings || {}) + when :cashfree + configuration[:provider_customer_id] = model.cashfree_customer&.provider_customer_id + configuration.merge!(model.cashfree_customer&.settings || {}) when :adyen configuration[:provider_customer_id] = model.adyen_customer&.provider_customer_id configuration.merge!(model.adyen_customer&.settings || {}) diff --git a/app/services/customers/create_service.rb b/app/services/customers/create_service.rb index 39dd9725943..c25de4154b1 100644 --- a/app/services/customers/create_service.rb +++ b/app/services/customers/create_service.rb @@ -277,7 +277,7 @@ def handle_api_billing_configuration(customer, params, new_customer) if billing.key?(:payment_provider) customer.payment_provider = nil - if %w[stripe gocardless adyen].include?(billing[:payment_provider]) + if %w[stripe gocardless cashfree adyen].include?(billing[:payment_provider]) customer.payment_provider = billing[:payment_provider] end end @@ -304,6 +304,8 @@ def create_or_update_provider_customer(customer, billing_configuration = {}) PaymentProviderCustomers::StripeCustomer when 'gocardless' PaymentProviderCustomers::GocardlessCustomer + when 'cashfree' + PaymentProviderCustomers::CashfreeCustomer when 'adyen' PaymentProviderCustomers::AdyenCustomer end diff --git a/app/services/customers/update_service.rb b/app/services/customers/update_service.rb index a65087305ea..aed02d19de2 100644 --- a/app/services/customers/update_service.rb +++ b/app/services/customers/update_service.rb @@ -186,6 +186,12 @@ def create_or_update_provider_customer(customer, payment_provider, billing_confi return unless handle_provider_customer update_gocardless_customer(customer, billing_configuration) + when 'cashfree' + handle_provider_customer ||= customer.cashfree_customer&.provider_customer_id.present? + + return unless handle_provider_customer + + update_cashfree_customer(customer, billing_configuration) when 'adyen' handle_provider_customer ||= customer.adyen_customer&.provider_customer_id.present? @@ -219,6 +225,18 @@ def update_gocardless_customer(customer, billing_configuration) customer.gocardless_customer&.reload end + def update_cashfree_customer(customer, billing_configuration) + create_result = PaymentProviderCustomers::CreateService.new(customer).create_or_update( + customer_class: PaymentProviderCustomers::CashfreeCustomer, + payment_provider_id: payment_provider(customer)&.id, + params: billing_configuration + ) + create_result.raise_if_error! + + # NOTE: Create service is modifying an other instance of the provider customer + customer.cashfree_customer&.reload + end + def update_adyen_customer(customer, billing_configuration) create_result = PaymentProviderCustomers::CreateService.new(customer).create_or_update( customer_class: PaymentProviderCustomers::AdyenCustomer, diff --git a/app/services/invoices/payments/cashfree_service.rb b/app/services/invoices/payments/cashfree_service.rb new file mode 100644 index 00000000000..c27e979c086 --- /dev/null +++ b/app/services/invoices/payments/cashfree_service.rb @@ -0,0 +1,178 @@ +# 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 create + result.invoice = invoice + return result unless should_process_payment? + + unless invoice.total_amount_cents.positive? + update_invoice_payment_status(payment_status: :succeeded) + return result + end + + increment_payment_attempts + + # 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. + payment = 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.upcase, + provider_payment_id: invoice.id, + status: :pending + ) + payment.save! + + result.payment = payment + result + 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 increment_payment_attempts + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + 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/create_service.rb b/app/services/invoices/payments/create_service.rb index f26e3c8f7cc..cbe2c98496d 100644 --- a/app/services/invoices/payments/create_service.rb +++ b/app/services/invoices/payments/create_service.rb @@ -13,6 +13,8 @@ def call case payment_provider when :stripe Invoices::Payments::StripeCreateJob.perform_later(invoice) + when :cashfree + Invoices::Payments::CashfreeCreateJob.perform_later(invoice) when :gocardless Invoices::Payments::GocardlessCreateJob.perform_later(invoice) when :adyen 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/create_service.rb b/app/services/payment_provider_customers/create_service.rb index 12ce2c71bb8..a4fcade4163 100644 --- a/app/services/payment_provider_customers/create_service.rb +++ b/app/services/payment_provider_customers/create_service.rb @@ -65,10 +65,12 @@ def create_customer_on_provider_service(async) return PaymentProviderCustomers::AdyenCreateJob.perform_later(result.provider_customer) if async PaymentProviderCustomers::AdyenCreateJob.perform_now(result.provider_customer) - else + elsif result.provider_customer.type == 'PaymentProviderCustomers::GocardlessCustomer' return PaymentProviderCustomers::GocardlessCreateJob.perform_later(result.provider_customer) if async PaymentProviderCustomers::GocardlessCreateJob.perform_now(result.provider_customer) + elsif result.provider_customer.type == 'PaymentProviderCustomers::CashfreeCustomer' + # INFO: Cashfree payment provider does not support customer creation 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_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/config/routes.rb b/config/routes.rb index 346acdf4087..e0cf972c3c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,6 +86,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 1cdc3c30553..52aa0f744f4 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 """ @@ -286,6 +302,15 @@ enum BillingTimeEnum { calendar } +type CashfreeProvider { + clientId: String + clientSecret: String + code: String! + id: ID! + name: String! + successRedirectUrl: String +} + type Charge { billableMetric: BillableMetric! chargeModel: ChargeModelEnum! @@ -3001,6 +3026,7 @@ type CurrentOrganization { adyenPaymentProviders: [AdyenProvider!] apiKey: String billingConfiguration: OrganizationBillingConfiguration + cashfreePaymentProviders: [CashfreeProvider!] city: String country: CountryCode createdAt: ISO8601DateTime! @@ -4392,6 +4418,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 """ @@ -5154,6 +5190,16 @@ type Mutation { input: UpdateBillableMetricInput! ): BillableMetric + """ + Update Cashfree payment provider + """ + updateCashfreePaymentProvider( + """ + Parameters for UpdateCashfreePaymentProvider + """ + input: UpdateCashfreePaymentProviderInput! + ): CashfreeProvider + """ Update an existing coupon """ @@ -5497,7 +5543,7 @@ type OverdueBalanceCollection { metadata: CollectionMetadata! } -union PaymentProvider = AdyenProvider | GocardlessProvider | StripeProvider +union PaymentProvider = AdyenProvider | CashfreeProvider | GocardlessProvider | StripeProvider """ PaymentProviderCollection type @@ -5761,6 +5807,7 @@ enum ProviderPaymentMethodsEnum { enum ProviderTypeEnum { adyen + cashfree gocardless stripe } @@ -7368,6 +7415,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 3b587b9e81d..6b31f2671c0 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", @@ -2505,6 +2604,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", @@ -11291,6 +11499,28 @@ ] }, + { + "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, @@ -21428,6 +21658,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", @@ -23673,6 +23932,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", @@ -25375,6 +25663,11 @@ "name": "AdyenProvider", "ofType": null }, + { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + }, { "kind": "OBJECT", "name": "GocardlessProvider", @@ -28326,6 +28619,12 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "cashfree", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "adyen", "description": null, @@ -34829,6 +35128,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 acedcaa8c91..995b3fd9d68 100644 --- a/spec/graphql/types/organizations/current_organization_type_spec.rb +++ b/spec/graphql/types/organizations/current_organization_type_spec.rb @@ -37,6 +37,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') } end 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 0cfdda8f964..c16a7aab914 100644 --- a/spec/requests/webhooks_controller_spec.rb +++ b/spec/requests/webhooks_controller_spec.rb @@ -223,4 +223,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