diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 51c0f697b68..2a3d44b3433 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::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 e03d88017f3..c8c06b275ff 100644 --- a/app/graphql/types/customers/object.rb +++ b/app/graphql/types/customers/object.rb @@ -126,6 +126,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 bcff7f12e6e..bfbb8dd6a8c 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -47,10 +47,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..c498ebd529c --- /dev/null +++ b/app/jobs/payment_providers/cashfree/handle_event_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + class HandleEventJob < ApplicationJob + queue_as "providers" + + def perform(organization:, event:) + PaymentProviders::Cashfree::HandleEventService.call!( + organization:, + event_json: event + ) + end + end + end +end diff --git a/app/models/customer.rb b/app/models/customer.rb index ae84f011496..11725ffa1cb 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 }, @@ -151,6 +152,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..2ca86d22399 --- /dev/null +++ b/app/models/payment_provider_customers/cashfree_customer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class CashfreeCustomer < BaseCustomer + end +end + +# == Schema Information +# +# 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 +# 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 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) +# +# 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..4cb882e4309 --- /dev/null +++ b/app/models/payment_providers/cashfree_provider.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module PaymentProviders + class CashfreeProvider < BaseProvider + 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") + + 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 +# deleted_at :datetime +# 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 WHERE (deleted_at IS NULL) +# 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 76a7b98fe54..98bb1998bac 100644 --- a/app/serializers/v1/customer_serializer.rb +++ b/app/serializers/v1/customer_serializer.rb @@ -71,6 +71,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/invoices/payments/cashfree_service.rb b/app/services/invoices/payments/cashfree_service.rb new file mode 100644 index 00000000000..c7d9331a727 --- /dev/null +++ b/app/services/invoices/payments/cashfree_service.rb @@ -0,0 +1,166 @@ +# 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 + 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.invoice = payment.payable + return result if payment.payable.payment_succeeded? + + payment.update!(status:) + update_invoice_payment_status(payment_status: invoice_payment_status(status)) + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + 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 + + private + + attr_accessor :invoice + + 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 + 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 + + 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 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.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/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/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/payments/create_service.rb b/app/services/payment_providers/cashfree/payments/create_service.rb new file mode 100644 index 00000000000..083441f2b5e --- /dev/null +++ b/app/services/payment_providers/cashfree/payments/create_service.rb @@ -0,0 +1,38 @@ +# 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 + + # 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 + + 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/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 new file mode 100644 index 00000000000..99bff00047c --- /dev/null +++ b/app/services/payment_providers/cashfree_service.rb @@ -0,0 +1,70 @@ +# 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 + + 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) + cashfree_provider.code = args[:code] if args.key?(:code) + 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 + result.record_validation_failure!(record: e.record) + 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:, + 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(organization:, event: body) + + result.event = body + result + 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/app/services/payment_providers/create_payment_factory.rb b/app/services/payment_providers/create_payment_factory.rb index 56f30805643..93c30642a34 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/app/services/payment_requests/payments/cashfree_service.rb b/app/services/payment_requests/payments/cashfree_service.rb new file mode 100644 index 00000000000..d525bc13a40 --- /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.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/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 de53ad5dd75..20d8800dd77 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! @@ -3124,6 +3149,7 @@ type CurrentOrganization { apiKey: String appliedDunningCampaign: DunningCampaign billingConfiguration: OrganizationBillingConfiguration + cashfreePaymentProviders: [CashfreeProvider!] city: String country: CountryCode createdAt: ISO8601DateTime! @@ -4798,6 +4824,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 """ @@ -5686,6 +5722,16 @@ type Mutation { input: UpdateBillableMetricInput! ): BillableMetric + """ + Update Cashfree payment provider + """ + updateCashfreePaymentProvider( + """ + Parameters for UpdateCashfreePaymentProvider + """ + input: UpdateCashfreePaymentProviderInput! + ): CashfreeProvider + """ Update an existing coupon """ @@ -6078,7 +6124,7 @@ type OverdueBalanceCollection { metadata: CollectionMetadata! } -union PaymentProvider = AdyenProvider | GocardlessProvider | StripeProvider +union PaymentProvider = AdyenProvider | CashfreeProvider | GocardlessProvider | StripeProvider """ PaymentProviderCollection type @@ -6356,6 +6402,7 @@ enum ProviderPaymentMethodsEnum { enum ProviderTypeEnum { adyen + cashfree gocardless stripe } @@ -8166,6 +8213,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 0044043c0e3..a9579ffe320 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,101 @@ "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", @@ -11630,6 +11824,26 @@ "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, @@ -23098,6 +23312,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", @@ -25716,6 +25959,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", @@ -27447,6 +27719,11 @@ "name": "AdyenProvider", "ofType": null }, + { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + }, { "kind": "OBJECT", "name": "GocardlessProvider", @@ -30316,6 +30593,12 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "cashfree", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "adyen", "description": null, @@ -37813,6 +38096,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/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/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/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 65fd6f86315..634f3c33dac 100644 --- a/spec/requests/webhooks_controller_spec.rb +++ b/spec/requests/webhooks_controller_spec.rb @@ -1,23 +1,23 @@ # frozen_string_literal: true -require 'rails_helper' +require "rails_helper" RSpec.describe WebhooksController, type: :request do - describe 'POST /stripe' do + describe "POST /stripe" do let(:organization) { create(:organization) } let(:stripe_provider) do create( :stripe_provider, organization:, - webhook_secret: 'secrests' + webhook_secret: "secrests" ) end let(:stripe_service) { instance_double(PaymentProviders::StripeService) } let(:event) do - path = Rails.root.join('spec/fixtures/stripe/payment_intent_event.json') + path = Rails.root.join("spec/fixtures/stripe/payment_intent_event.json") JSON.parse(File.read(path)) end @@ -39,13 +39,13 @@ .and_return(result) end - it 'handle stripe webhooks' do + it "handle stripe webhooks" do post( "/webhooks/stripe/#{stripe_provider.organization_id}", params: event.to_json, headers: { - 'HTTP_STRIPE_SIGNATURE' => 'signature', - 'Content-Type' => 'application/json' + "HTTP_STRIPE_SIGNATURE" => "signature", + "Content-Type" => "application/json" } ) @@ -54,18 +54,18 @@ .to have_received(:call) end - context 'when failing to handle stripe event' do + context "when failing to handle stripe 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/stripe/#{stripe_provider.organization_id}", params: event.to_json, headers: { - 'HTTP_STRIPE_SIGNATURE' => 'signature', - 'Content-Type' => 'application/json' + "HTTP_STRIPE_SIGNATURE" => "signature", + "Content-Type" => "application/json" } ) @@ -76,27 +76,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 @@ -106,18 +106,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" } ) @@ -126,18 +126,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" } ) @@ -148,7 +148,7 @@ end end - describe 'POST /adyen' do + describe "POST /adyen" do let(:organization) { create(:organization) } let(:adyen_provider) do @@ -156,7 +156,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 @@ -171,17 +171,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" } ) @@ -189,17 +189,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" } ) @@ -208,4 +208,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/payment_link_event_payment.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/cashfree_service_spec.rb b/spec/services/invoices/payments/cashfree_service_spec.rb new file mode 100644 index 00000000000..07c45ef7cc4 --- /dev/null +++ b/spec/services/invoices/payments/cashfree_service_spec.rb @@ -0,0 +1,210 @@ +# 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 ".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/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/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/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/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 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 new file mode 100644 index 00000000000..74a4061ec13 --- /dev/null +++ b/spec/services/payment_providers/cashfree_service_spec.rb @@ -0,0 +1,152 @@ +# 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 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:) + 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/payment_link_event_payment.json") + JSON.parse(File.read(path)).to_json # NOTE: Ensure valid sha256 signature + 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 +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 } 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