diff --git a/Gemfile b/Gemfile index 05e4acc389e..b1fca7c8232 100644 --- a/Gemfile +++ b/Gemfile @@ -63,7 +63,6 @@ gem "lograge" gem "logstash-event" # HTTP and Multipart support -gem "httparty" gem "multipart-post" gem "mutex_m" diff --git a/Gemfile.lock b/Gemfile.lock index 6118e09f063..68b3f90c70f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -337,10 +337,6 @@ GEM hashie (5.0.0) highline (3.1.2) reline - httparty (0.23.1) - csv - mini_mime (>= 1.0.0) - multi_xml (>= 0.5.2) httpclient (2.8.3) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -1004,7 +1000,6 @@ DEPENDENCIES graphql graphql-pagination guard-rspec - httparty i18n-tasks! jwt kaminari-activerecord diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 01a29c790bd..dcfce6ca30a 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -41,7 +41,7 @@ def flutterwave organization_id: params[:organization_id], code: params[:code].presence, body: request.body.read, - signature: request.headers["verif-hash"] + secret: request.headers["verif-hash"] ) unless result.success? diff --git a/app/services/integrations/aggregator/base_service.rb b/app/services/integrations/aggregator/base_service.rb index 5216bf29c37..ba90c88d26e 100644 --- a/app/services/integrations/aggregator/base_service.rb +++ b/app/services/integrations/aggregator/base_service.rb @@ -58,9 +58,8 @@ def provider_key def throttle!(*providers) providers.each do |provider_name| if provider == provider_name.to_s - unless Throttling.for(provider_name.to_sym).check(:client, throttle_key) - raise BaseService::ThrottlingError.new(provider_name:) - end + raise BaseService::ThrottlingError.new(provider_name:) \ + unless Throttling.for(provider_name.to_sym).check(:client, throttle_key) end end end diff --git a/app/services/invoices/payments/flutterwave_service.rb b/app/services/invoices/payments/flutterwave_service.rb index 366c2ff9a62..274a6458b31 100644 --- a/app/services/invoices/payments/flutterwave_service.rb +++ b/app/services/invoices/payments/flutterwave_service.rb @@ -60,16 +60,17 @@ def payment_url parsed_response["data"]["link"] end - def create_checkout_session body = { - amount: invoice.total_amount_cents / 100.0, - tx_ref: invoice.id, - currency: invoice.currency.upcase, - redirect_url: success_redirect_url, - customer: customer_params, - customizations: customizations_params, - configuration: configuration_params, - meta: meta_params - } + def create_checkout_session + body = { + amount: Money.from_cents(invoice.total_amount_cents, invoice.currency).to_f, + tx_ref: invoice.id, + currency: invoice.currency.upcase, + redirect_url: success_redirect_url, + customer: customer_params, + customizations: customizations_params, + configuration: configuration_params, + meta: meta_params + } http_client.post_with_response(body, headers) end diff --git a/app/services/payment_providers/flutterwave/handle_event_service.rb b/app/services/payment_providers/flutterwave/handle_event_service.rb index 7213ca1abaa..f0fd9ce9a0d 100644 --- a/app/services/payment_providers/flutterwave/handle_event_service.rb +++ b/app/services/payment_providers/flutterwave/handle_event_service.rb @@ -20,7 +20,11 @@ def call return result unless service_class - service_class.call!(organization_id: organization.id, event_json:) + begin + service_class.call!(organization_id: organization.id, event_json:) + rescue => e + Rails.logger.error("Flutterwave event processing error: #{e.message}") + end result end diff --git a/app/services/payment_providers/flutterwave/handle_incoming_webhook_service.rb b/app/services/payment_providers/flutterwave/handle_incoming_webhook_service.rb index b250a57c1d2..bbcd7da932f 100644 --- a/app/services/payment_providers/flutterwave/handle_incoming_webhook_service.rb +++ b/app/services/payment_providers/flutterwave/handle_incoming_webhook_service.rb @@ -4,32 +4,34 @@ module PaymentProviders module Flutterwave class HandleIncomingWebhookService < BaseService Result = BaseResult[:event] - def initialize(organization_id:, body:, signature:, code: nil) + def initialize(organization_id:, body:, secret:, code: nil) @organization_id = organization_id @body = body - @signature = signature + @secret = secret @code = code super end def call - organization = Organization.find_by(id: organization_id) - payment_provider_result = PaymentProviders::FindService.call( organization_id:, code:, payment_provider_type: "flutterwave" ) return payment_provider_result unless payment_provider_result.success? + webhook_secret = payment_provider_result.payment_provider.webhook_secret - return result.service_failure!(code: "webhook_error", message: "Missing webhook secret") if webhook_secret.blank? + return result.service_failure!(code: "webhook_error", message: "Webhook secret is missing") if webhook_secret.blank? - unless webhook_secret == signature - return result.service_failure!(code: "webhook_error", message: "Invalid signature") + unless webhook_secret == secret + return result.service_failure!(code: "webhook_error", message: "Invalid webhook secret") end - PaymentProviders::Flutterwave::HandleEventJob.perform_later(organization:, event: body) + PaymentProviders::Flutterwave::HandleEventJob.perform_later( + organization: payment_provider_result.payment_provider.organization, + event: body + ) result.event = body result @@ -37,7 +39,7 @@ def call private - attr_reader :organization_id, :body, :signature, :code + attr_reader :organization_id, :body, :secret, :code end end end diff --git a/app/services/payment_providers/flutterwave/webhooks/charge_completed_service.rb b/app/services/payment_providers/flutterwave/webhooks/charge_completed_service.rb index c0e98d7a916..e8f5f2cf114 100644 --- a/app/services/payment_providers/flutterwave/webhooks/charge_completed_service.rb +++ b/app/services/payment_providers/flutterwave/webhooks/charge_completed_service.rb @@ -14,7 +14,6 @@ class ChargeCompletedService < BaseService def initialize(organization_id:, event_json:) @organization_id = organization_id @event_json = event_json - super end @@ -22,9 +21,16 @@ def call return result unless SUCCESS_STATUSES.include?(transaction_status) return result if provider_payment_id.nil? + # Validate payable_type first to raise NameError for invalid types + payment_service_class + verified_transaction = verify_transaction return result unless verified_transaction - payment_service_class.new(nil).update_payment_status( + + payable = find_payable + return result unless payable + + payment_service_class.new(payable:).update_payment_status( organization_id:, status: verified_transaction[:status], flutterwave_payment: PaymentProviders::FlutterwaveProvider::FlutterwavePayment.new( @@ -69,6 +75,15 @@ def payment_service_class end end + def find_payable + case payable_type + when "Invoice" + Invoice.find_by(id: provider_payment_id) + when "PaymentRequest" + PaymentRequest.find_by(id: provider_payment_id) + end + end + def verify_transaction Organization.find(organization_id) payment_provider_result = PaymentProviders::FindService.call( @@ -112,6 +127,7 @@ def build_metadata(verified_transaction) lago_invoice_id: provider_payment_id, lago_payable_type: payable_type, flutterwave_transaction_id: verified_transaction[:id], + flw_ref: verified_transaction[:reference], reference: verified_transaction[:reference], amount: verified_transaction[:amount], currency: verified_transaction[:currency], diff --git a/app/services/payment_providers/flutterwave_service.rb b/app/services/payment_providers/flutterwave_service.rb index c5353c87173..91d9b593668 100644 --- a/app/services/payment_providers/flutterwave_service.rb +++ b/app/services/payment_providers/flutterwave_service.rb @@ -26,7 +26,6 @@ def create_or_update(**args) flutterwave_provider.code = args[:code] if args.key?(:code) flutterwave_provider.name = args[:name] if args.key?(:name) flutterwave_provider.save! - if payment_provider_code_changed?(flutterwave_provider, old_code, args) flutterwave_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations end @@ -36,11 +35,5 @@ def create_or_update(**args) rescue ActiveRecord::RecordInvalid => e result.record_validation_failure!(record: e.record) end - - private - - def payment_provider_code_changed?(provider, old_code, args) - old_code != args[:code] - end end end diff --git a/app/services/payment_requests/payments/flutterwave_service.rb b/app/services/payment_requests/payments/flutterwave_service.rb index 561bee6ba0a..6aeb57330a2 100644 --- a/app/services/payment_requests/payments/flutterwave_service.rb +++ b/app/services/payment_requests/payments/flutterwave_service.rb @@ -67,7 +67,7 @@ def payment_url def create_checkout_session body = { - amount: payable.total_amount_cents / 100.0, + amount: Money.from_cents(payable.total_amount_cents, payable.currency).to_f, tx_ref: "lago_payment_request_#{payable.id}", currency: payable.currency.upcase, redirect_url: success_redirect_url, @@ -137,14 +137,13 @@ def http_client def deliver_error_webhook(http_error) return unless payable.organization.webhook_endpoints.any? - SendWebhookJob.perform_later( "payment_request.payment_failure", payable, provider_customer_id: flutterwave_customer&.provider_customer_id, provider_error: { message: http_error.message, - error_code: http_error.code + error_code: http_error.error_code } ) end diff --git a/config/database.yml b/config/database.yml index bdfd49eef07..67fa8aa864a 100644 --- a/config/database.yml +++ b/config/database.yml @@ -4,17 +4,25 @@ default: &default development: primary: <<: *default - url: <%= ENV['DATABASE_URL'] %> + host: db + username: lago + password: changeme + database: lago + port: 5432 events: <<: *default - url: <%= ENV['DATABASE_URL'] %> + host: db + username: lago + password: changeme + database: lago + port: 5432 clickhouse: adapter: clickhouse - database: <%= ENV.fetch('LAGO_CLICKHOUSE_DATABASE', 'default') %> - host: <%= ENV.fetch('LAGO_CLICKHOUSE_HOST', 'clickhouse') %> - port: <%= ENV.fetch('LAGO_CLICKHOUSE_PORT', 8123) %> - username: <%= ENV.fetch('LAGO_CLICKHOUSE_USERNAME', 'default') %> - password: <%= ENV.fetch('LAGO_CLICKHOUSE_PASSWORD', 'default') %> + database: default + host: clickhouse + port: 8123 + username: default + password: default migrations_paths: db/clickhouse_migrate debug: true database_tasks: <% if ENV['LAGO_CLICKHOUSE_MIGRATIONS_ENABLED'].present? %> true <% else %> false <% end %> @@ -86,4 +94,4 @@ production: ssl: <%= ENV.fetch('LAGO_CLICKHOUSE_SSL', false) %> migrations_paths: db/clickhouse_migrate debug: false - database_tasks: <% if ENV['LAGO_CLICKHOUSE_MIGRATIONS_ENABLED'].present? %> true <% else %> false <% end %> + database_tasks: <% if ENV['LAGO_CLICKHOUSE_MIGRATIONS_ENABLED'].present? %> true <% else %> false <% end %> \ No newline at end of file diff --git a/spec/factories/payment_provider_customers.rb b/spec/factories/payment_provider_customers.rb index 91ce4f2b8e6..5da92214156 100644 --- a/spec/factories/payment_provider_customers.rb +++ b/spec/factories/payment_provider_customers.rb @@ -34,6 +34,13 @@ customer organization { customer.organization } + provider_customer_id { SecureRandom.uuid } + end + factory :flutterwave_customer, class: "PaymentProviderCustomers::FlutterwaveCustomer" do + customer + organization { customer.organization } + payment_provider { association(:flutterwave_provider, organization: organization) } + provider_customer_id { SecureRandom.uuid } end end diff --git a/spec/jobs/payment_providers/flutterwave/handle_event_job_spec.rb b/spec/jobs/payment_providers/flutterwave/handle_event_job_spec.rb new file mode 100644 index 00000000000..5fbb6e99f69 --- /dev/null +++ b/spec/jobs/payment_providers/flutterwave/handle_event_job_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Flutterwave::HandleEventJob do + subject(:handle_event_job) { described_class.new } + + let(:organization) { create(:organization) } + let(:event_json) { {event: "charge.completed", data: {}}.to_json } + + describe "#perform" do + it "calls the HandleEventService" do + allow(PaymentProviders::Flutterwave::HandleEventService) + .to receive(:call!) + + handle_event_job.perform(organization:, event: event_json) + + expect(PaymentProviders::Flutterwave::HandleEventService) + .to have_received(:call!) + .with(organization:, event_json: event_json) + end + end + + describe "queue configuration" do + context "when SIDEKIQ_PAYMENTS is true" do + before { ENV["SIDEKIQ_PAYMENTS"] = "true" } + after { ENV.delete("SIDEKIQ_PAYMENTS") } + + it "uses the payments queue" do + expect(described_class.queue_name).to eq("payments") + end + end + + context "when SIDEKIQ_PAYMENTS is false or not set" do + before { ENV.delete("SIDEKIQ_PAYMENTS") } + + it "uses the providers queue" do + expect(described_class.queue_name).to eq("providers") + end + end + end +end diff --git a/spec/requests/webhooks_controller_spec.rb b/spec/requests/webhooks_controller_spec.rb index d8e50aae24f..31ab36a5061 100644 --- a/spec/requests/webhooks_controller_spec.rb +++ b/spec/requests/webhooks_controller_spec.rb @@ -327,4 +327,116 @@ end end end + + describe "POST /flutterwave" do + let(:organization) { create(:organization) } + + let(:flutterwave_provider) do + create(:flutterwave_provider, organization:) + end + + let(:body) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + charged_amount: 10000, + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + }, + card: { + first_6digits: "123456", + last_4digits: "7889", + issuer: "VERVE FIRST CITY MONUMENT BANK PLC", + country: "NG", + type: "VERVE" + }, + meta: { + lago_invoice_id: "12345", + lago_payable_type: "Invoice" + } + } + } + end + + let(:result) do + result = BaseService::Result.new + result.body = body + result + end + + before do + allow(PaymentProviders::Flutterwave::HandleIncomingWebhookService).to receive(:call) + .with( + organization_id: organization.id, + code: nil, + body: body.to_json, + secret: "test_signature" + ) + .and_return(result) + end + + it "handles flutterwave webhooks" do + post( + "/webhooks/flutterwave/#{flutterwave_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "verif-hash" => "test_signature" + } + ) + + expect(response).to have_http_status(:success) + + expect(PaymentProviders::Flutterwave::HandleIncomingWebhookService).to have_received(:call) + end + + context "when failing to handle flutterwave event" do + let(:result) do + BaseService::Result.new.service_failure!(code: "webhook_error", message: "Invalid payload") + end + + it "returns bad request status" do + post( + "/webhooks/flutterwave/#{flutterwave_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "verif-hash" => "test_signature" + } + ) + + expect(response).to have_http_status(:bad_request) + expect(PaymentProviders::Flutterwave::HandleIncomingWebhookService).to have_received(:call) + end + end + + context "when service raises an unexpected error" do + before do + allow(PaymentProviders::Flutterwave::HandleIncomingWebhookService).to receive(:call) + .and_raise(StandardError.new("Unexpected error")) + end + + it "raises the error" do + expect do + post( + "/webhooks/flutterwave/#{flutterwave_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "verif-hash" => "test_signature" + } + ) + end.to raise_error(StandardError, "Unexpected error") + end + end + end end diff --git a/spec/services/invoices/payments/flutterwave_service_spec.rb b/spec/services/invoices/payments/flutterwave_service_spec.rb new file mode 100644 index 00000000000..d5b628d3d0e --- /dev/null +++ b/spec/services/invoices/payments/flutterwave_service_spec.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::FlutterwaveService do + subject(:flutterwave_service) { described_class.new(invoice) } + + let(:organization) { create(:organization, name: "Test Organization") } + let(:customer) { create(:customer, organization: organization, email: "customer@example.com", name: "John Doe") } + let(:flutterwave_provider) { create(:flutterwave_provider, organization: organization, secret_key: "FLWSECK_TEST-secret") } + let(:flutterwave_customer) { create(:flutterwave_customer, customer: customer, payment_provider: flutterwave_provider) } + let(:invoice) { create(:invoice, organization: organization, customer: customer, total_amount_cents: 50000, currency: "USD", number: "INV-001") } + + before do + flutterwave_customer + end + + describe "#update_payment_status" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: { + payment_type: "one-time", + lago_invoice_id: invoice.id + } + ) + end + + context "when creating a new payment" do + it "creates a new payment and updates invoice status" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(result.payment).to be_a(Payment) + expect(result.payment.provider_payment_id).to eq("flw_payment_123") + expect(result.payment.amount_cents).to eq(invoice.total_due_amount_cents) + expect(result.payment.payable).to eq(invoice) + expect(result.invoice).to eq(invoice) + end + + it "increments payment attempts on the invoice" do + expect { flutterwave_service.update_payment_status(organization_id: organization.id, status: :succeeded, flutterwave_payment: flutterwave_payment) } + .to change { invoice.reload.payment_attempts }.by(1) + end + + it "sets correct payment details" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + payment = result.payment + expect(payment.organization_id).to eq(organization.id) + expect(payment.payment_provider_id).to eq(flutterwave_provider.id) + expect(payment.payment_provider_customer_id).to eq(flutterwave_customer.id) + expect(payment.amount_currency).to eq("USD") + end + end + + context "when updating existing payment" do + let(:existing_payment) do + create(:payment, + organization: organization, + payable: invoice, + payment_provider: flutterwave_provider, + provider_payment_id: "flw_payment_123", + status: :pending) + end + + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: {payment_type: "recurring"} + ) + end + + before { existing_payment } + + it "updates the existing payment status" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(result.payment.id).to eq(existing_payment.id) + expect(result.payment.status).to eq("succeeded") + end + + it "updates invoice payment status" do + allow(Invoices::UpdateService).to receive(:call).and_return(BaseService::Result.new) + + flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + expect(Invoices::UpdateService).to have_received(:call) do |args| + expect(args[:invoice]).to eq(invoice) + expect(args[:params][:payment_status]).to eq("succeeded") + expect(args[:params][:ready_for_payment_processing]).to be false + end + end + end + + context "when payment is not found" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "nonexistent_payment", + metadata: {payment_type: "recurring"} + ) + end + + it "returns not found failure" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + + context "when payment already succeeded" do + let(:succeeded_invoice) { create(:invoice, organization: organization, customer: customer, payment_status: :succeeded) } + let(:existing_payment) do + create(:payment, + organization: organization, + payable: succeeded_invoice, + payment_provider: flutterwave_provider, + provider_payment_id: "flw_payment_123", + status: :succeeded) + end + + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: {payment_type: "recurring"} + ) + end + + before { existing_payment } + + it "returns early without processing" do + service = described_class.new(succeeded_invoice) + allow(service).to receive(:update_invoice_payment_status) + + result = service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(service).not_to have_received(:update_invoice_payment_status) + end + end + + context "when payment status calculation includes total paid amount" do + let(:existing_payment1) { create(:payment, payable: invoice, amount_cents: 20000, payable_payment_status: :succeeded) } + let(:existing_payment2) { create(:payment, payable: invoice, amount_cents: 15000, payable_payment_status: :succeeded) } + + before do + existing_payment1 + existing_payment2 + allow(Invoices::UpdateService).to receive(:call).and_return(BaseService::Result.new) + end + + it "calculates total paid amount correctly" do + flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(Invoices::UpdateService).to have_received(:call) do |args| + expect(args[:params][:total_paid_amount_cents]).to eq(85000) # 20000 + 15000 + 50000 (new payment) + end + end + end + end + + describe "#generate_payment_url" do + let(:payment_intent) { double } + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:successful_response) do + instance_double("HTTPResponse", body: { + status: "success", + data: { + link: "https://checkout.flutterwave.com/v3/hosted/pay/test_link" + } + }.to_json) + end + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(successful_response) + end + + it "creates a checkout session and returns payment URL" do + result = flutterwave_service.generate_payment_url(payment_intent) + + expect(result).to be_success + expect(result.payment_url).to eq("https://checkout.flutterwave.com/v3/hosted/pay/test_link") + end + + it "sends correct parameters to Flutterwave API" do + flutterwave_service.generate_payment_url(payment_intent) + + expect(http_client).to have_received(:post_with_response) do |body, headers| + expect(body[:amount]).to eq(500.0) + expect(body[:tx_ref]).to eq(invoice.id) + expect(body[:currency]).to eq("USD") + expect(body[:customer][:email]).to eq("customer@example.com") + expect(body[:customer][:name]).to eq("John Doe") + expect(body[:customizations][:title]).to eq("Test Organization - Invoice Payment") + expect(body[:customizations][:description]).to eq("Payment for Invoice #INV-001") + expect(body[:meta][:lago_customer_id]).to eq(customer.id) + expect(body[:meta][:lago_invoice_id]).to eq(invoice.id) + expect(body[:meta][:lago_organization_id]).to eq(organization.id) + expect(body[:meta][:lago_invoice_number]).to eq("INV-001") + expect(body[:meta][:payment_type]).to eq("one-time") + + expect(headers["Authorization"]).to eq("Bearer FLWSECK_TEST-secret") + expect(headers["Content-Type"]).to eq("application/json") + expect(headers["Accept"]).to eq("application/json") + end + end + + context "when HTTP client raises an error" do + let(:http_error) { LagoHttpClient::HttpError.new(500, "Connection failed", "https://api.example.com") } + + before do + allow(http_client).to receive(:post_with_response).and_raise(http_error) + end + + it "returns third party failure" do + result = flutterwave_service.generate_payment_url(payment_intent) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Flutterwave") + end + end + end + + describe "private methods" do + describe "#create_checkout_session" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(instance_double("HTTPResponse", body: '{"data": {"link": "test_link"}}')) + end + + it "uses correct API endpoint" do + flutterwave_service.send(:create_checkout_session) + + expect(LagoHttpClient::Client).to have_received(:new).with("#{flutterwave_provider.api_url}/payments") + end + + it "handles different currencies correctly" do + invoice.update!(currency: "NGN", total_amount_cents: 1000000) + + flutterwave_service.send(:create_checkout_session) + + expect(http_client).to have_received(:post_with_response) do |body| + expect(body[:amount]).to eq(10000.0) + expect(body[:currency]).to eq("NGN") + end + end + end + + describe "#increment_payment_attempts" do + it "increments payment attempts on invoice" do + expect { flutterwave_service.send(:increment_payment_attempts) } + .to change { invoice.reload.payment_attempts }.by(1) + end + end + + describe "#update_invoice_payment_status" do + let(:payment) { create(:payment, payable: invoice) } + + before do + flutterwave_service.instance_variable_set(:@result, BaseService::Result.new.tap { |r| r.invoice = invoice }) + allow(Invoices::UpdateService).to receive(:call).and_return(BaseService::Result.new) + end + + it "calls invoice update service with correct parameters" do + flutterwave_service.send(:update_invoice_payment_status, payment_status: :succeeded) + + expect(Invoices::UpdateService).to have_received(:call) do |args| + expect(args[:invoice]).to eq(invoice) + expect(args[:params][:payment_status]).to eq(:succeeded) + expect(args[:params][:ready_for_payment_processing]).to be false + expect(args[:webhook_notification]).to be true + end + end + + context "when payment status is not succeeded" do + it "sets ready_for_payment_processing to true" do + flutterwave_service.send(:update_invoice_payment_status, payment_status: :failed) + + expect(Invoices::UpdateService).to have_received(:call) do |args| + expect(args[:params][:ready_for_payment_processing]).to be true + end + end + 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 fc59f23f6a8..26e588905d9 100644 --- a/spec/services/invoices/payments/payment_providers/factory_spec.rb +++ b/spec/services/invoices/payments/payment_providers/factory_spec.rb @@ -47,5 +47,13 @@ expect(factory_service.class.to_s).to eq("Invoices::Payments::MoneyhashService") end end + + context "when flutterwave" do + let(:payment_provider) { "flutterwave" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("Invoices::Payments::FlutterwaveService") + end + end end end diff --git a/spec/services/payment_provider_customers/flutterwave_service_spec.rb b/spec/services/payment_provider_customers/flutterwave_service_spec.rb new file mode 100644 index 00000000000..d868855fc89 --- /dev/null +++ b/spec/services/payment_provider_customers/flutterwave_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::FlutterwaveService do + subject(:flutterwave_service) { described_class.new(flutterwave_customer) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization: organization) } + let(:flutterwave_provider) { create(:flutterwave_provider, organization: organization) } + let(:flutterwave_customer) { create(:flutterwave_customer, customer: customer, payment_provider: flutterwave_provider) } + + describe "#create" do + it "returns success with the flutterwave customer" do + result = flutterwave_service.create + + expect(result).to be_success + expect(result.flutterwave_customer).to eq(flutterwave_customer) + end + end + + describe "#update" do + it "returns success" do + result = flutterwave_service.update + + expect(result).to be_success + end + end + + describe "#generate_checkout_url" do + it "returns not allowed failure" do + result = flutterwave_service.generate_checkout_url + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("feature_not_supported") + end + + context "when send_webhook is false" do + it "returns not allowed failure" do + result = flutterwave_service.generate_checkout_url(send_webhook: false) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("feature_not_supported") + end + end + end + + describe "private methods" do + describe "#customer" do + it "delegates to flutterwave_customer" do + expect(flutterwave_service.send(:customer)).to eq(customer) + 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 0d49b6f0c64..7cb5c768628 100644 --- a/spec/services/payment_providers/create_customer_factory_spec.rb +++ b/spec/services/payment_providers/create_customer_factory_spec.rb @@ -43,5 +43,14 @@ expect(new_instance).to be_instance_of(PaymentProviders::Gocardless::Customers::CreateService) end end + + context "when provider is flutterwave" do + let(:provider) { "flutterwave" } + let(:payment_provider_id) { create(:flutterwave_provider, organization: customer.organization).id } + + it "creates an instance of the flutterwave service" do + expect(new_instance).to be_instance_of(PaymentProviders::Flutterwave::Customers::CreateService) + end + end end end diff --git a/spec/services/payment_providers/flutterwave/handle_event_service_spec.rb b/spec/services/payment_providers/flutterwave/handle_event_service_spec.rb index e84549b3905..01c5fd2e7ef 100644 --- a/spec/services/payment_providers/flutterwave/handle_event_service_spec.rb +++ b/spec/services/payment_providers/flutterwave/handle_event_service_spec.rb @@ -31,11 +31,19 @@ allow(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) .to receive(:call!) - handle_event_service.call + result = handle_event_service.call expect(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) .to have_received(:call!) .with(organization_id: organization.id, event_json:) + expect(result).to be_success + end + + it "returns success even if the service raises an error" do + allow(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .to receive(:call!).and_raise(StandardError.new("Service error")) + + expect { handle_event_service.call }.not_to raise_error end end @@ -58,5 +66,32 @@ expect(result).to be_success end end + + context "when event_json is invalid JSON" do + let(:event_json) { "invalid json" } + + it "raises a JSON parse error" do + expect { handle_event_service.call }.to raise_error(JSON::ParserError) + end + end + + context "when event key is missing" do + let(:payload) do + { + data: {} + } + end + + it "does not call any webhook service" do + allow(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .to receive(:call!) + + result = handle_event_service.call + + expect(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .not_to have_received(:call!) + expect(result).to be_success + end + end end end diff --git a/spec/services/payment_providers/flutterwave/handle_incoming_webhook_service_spec.rb b/spec/services/payment_providers/flutterwave/handle_incoming_webhook_service_spec.rb index 9b89b0b9da6..6c45791671f 100644 --- a/spec/services/payment_providers/flutterwave/handle_incoming_webhook_service_spec.rb +++ b/spec/services/payment_providers/flutterwave/handle_incoming_webhook_service_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe PaymentProviders::Flutterwave::HandleIncomingWebhookService do - subject(:webhook_service) { described_class.new(organization_id:, body:, signature:, code:) } + subject(:webhook_service) { described_class.new(organization_id:, body:, secret:, code:) } let(:organization) { create(:organization) } let(:organization_id) { organization.id } @@ -11,7 +11,7 @@ let(:webhook_secret) { "webhook_secret_hash" } let(:code) { flutterwave_provider.code } let(:body) { payload.to_json } - let(:signature) { Digest::SHA256.hexdigest(webhook_secret) } + let(:secret) { webhook_secret } let(:payload) do { @@ -35,7 +35,7 @@ end describe "#call" do - context "when signature is valid" do + context "when secret is valid" do it "enqueues the webhook processing job" do expect { webhook_service.call }.to have_enqueued_job(PaymentProviders::Flutterwave::HandleEventJob) end @@ -47,25 +47,30 @@ end end - context "when signature is invalid" do - let(:signature) { "invalid_signature" } + context "when secret is invalid" do + let(:secret) { "invalid_secret" } it "returns service failure" do result = webhook_service.call expect(result).not_to be_success expect(result.error.code).to eq("webhook_error") - expect(result.error.message).to eq("Invalid signature") + expect(result.error.message).to eq("webhook_error: Invalid webhook secret") end end context "when webhook secret is missing" do let(:webhook_secret) { nil } + let(:secret) { nil } + + before do + flutterwave_provider.update!(settings: flutterwave_provider.settings.merge("webhook_secret" => nil)) + end it "returns service failure" do result = webhook_service.call expect(result).not_to be_success expect(result.error.code).to eq("webhook_error") - expect(result.error.message).to eq("Missing webhook secret") + expect(result.error.message).to eq("webhook_error: Webhook secret is missing") end end diff --git a/spec/services/payment_providers/flutterwave/webhooks/charge_completed_service_spec.rb b/spec/services/payment_providers/flutterwave/webhooks/charge_completed_service_spec.rb new file mode 100644 index 00000000000..2ad44cbdfd3 --- /dev/null +++ b/spec/services/payment_providers/flutterwave/webhooks/charge_completed_service_spec.rb @@ -0,0 +1,526 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService do + subject(:charge_completed_service) { described_class.new(organization_id: organization.id, event_json:) } + + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, organization:) } + let(:payment_request) { create(:payment_request, organization:) } + let(:flutterwave_provider) { create(:flutterwave_provider, organization:) } + let(:event_json) { payload.to_json } + + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + device_fingerprint: "a42937f4a73ce8bb8b8df14e63a2df31", + amount: 10000, + currency: "NGN", + charged_amount: 10000, + app_fee: 140, + merchant_fee: 0, + processor_response: "Approved by Financial Institution", + auth_model: "PIN", + ip: "197.210.64.96", + narration: "CARD Transaction", + status: "successful", + payment_type: "card", + created_at: "2020-07-06T19:17:04.000Z", + account_id: 17321, + customer: { + id: 215604089, + name: "John Doe", + phone_number: nil, + email: "customer@example.com", + created_at: "2020-07-06T19:17:04.000Z" + }, + card: { + first_6digits: "123456", + last_4digits: "7889", + issuer: "VERVE FIRST CITY MONUMENT BANK PLC", + country: "NG", + type: "VERVE", expiry: "02/23" + }, + meta: { + lago_invoice_id: invoice.id, + lago_payable_type: "Invoice" + } + } + } + end + + let(:verification_response) do + { + "status" => "success", + "data" => { + "id" => 285959875, + "tx_ref" => "lago_invoice_12345", + "flw_ref" => "LAGO/FLW270177170", + "amount" => 10000, + "currency" => "NGN", + "charged_amount" => 10000, + "status" => "successful", + "payment_type" => "card", + "customer" => { + "id" => 215604089, + "name" => "John Doe", + "email" => "customer@example.com" + }, + "card" => { + "first_6digits" => "123456", + "last_4digits" => "7889", + "issuer" => "VERVE FIRST CITY MONUMENT BANK PLC", + "country" => "NG", + "type" => "VERVE" + } + } + } + end + + before do + allow(PaymentProviders::FindService) + .to receive(:call) + .with(organization_id: organization.id, payment_provider_type: "flutterwave") + .and_return(double(success?: true, payment_provider: flutterwave_provider)) # rubocop:disable RSpec/VerifiedDoubles + end + + describe "#call" do + context "when transaction status is successful" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:payment_service) { instance_double(Invoices::Payments::FlutterwaveService) } + + before do + invoice + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response) + allow(Invoices::Payments::FlutterwaveService).to receive(:new).and_return(payment_service) + allow(payment_service).to receive(:update_payment_status).and_return(instance_double("BaseService::Result", raise_if_error!: nil)) + end + + it "verifies the transaction and updates payment status" do + result = charge_completed_service.call + + expect(http_client).to have_received(:get).with( + headers: { + "Authorization" => "Bearer #{flutterwave_provider.secret_key}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + ) + expect(payment_service).to have_received(:update_payment_status) + expect(result).to be_success + end + + it "builds correct metadata" do + charge_completed_service.call + + expect(payment_service).to have_received(:update_payment_status) do |args| + metadata = args[:flutterwave_payment].metadata + expect(metadata[:lago_invoice_id]).to eq(invoice.id) + expect(metadata[:lago_payable_type]).to eq("Invoice") + expect(metadata[:flutterwave_transaction_id]).to eq(285959875) + expect(metadata[:amount]).to eq(10000) + expect(metadata[:currency]).to eq("NGN") + expect(metadata[:flw_ref]).to eq("lago_invoice_12345") + end + end + end + + context "when transaction status is not successful" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 408136545, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/SM31570678271", + device_fingerprint: "7852b6c97d67edce50a5f1e540719e39", + amount: 100000, + currency: "NGN", + charged_amount: 100000, + app_fee: 1400, + merchant_fee: 0, + processor_response: "invalid token supplied", + auth_model: "PIN", + ip: "72.140.222.142", + narration: "CARD Transaction", + status: "failed", + payment_type: "card", + created_at: "2021-04-16T14:52:37.000Z", + account_id: 82913, + customer: { + id: 255128611, + name: "Test User", + phone_number: nil, + email: "test@example.com", + created_at: "2021-04-16T14:52:37.000Z" + }, + card: { + first_6digits: "536613", + last_4digits: "8816", + issuer: "MASTERCARD ACCESS BANK PLC CREDIT", + country: "NG", + type: "MASTERCARD", + expiry: "12/21" + }, + meta: { + lago_invoice_id: "12345", + lago_payable_type: "Invoice" + } + }, + "event.type": "CARD_TRANSACTION" + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + end + + it "does not process the transaction" do + result = charge_completed_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when provider payment id is nil" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + } + # tx_ref is missing, which should cause the service to skip processing + } + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + end + + it "does not process the transaction" do + result = charge_completed_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when transaction verification fails" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:failed_response) do + { + "status" => "error", + "message" => "Transaction not found" + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(failed_response) + allow(Invoices::Payments::FlutterwaveService).to receive(:new) + end + + it "does not update payment status" do + result = charge_completed_service.call + + expect(Invoices::Payments::FlutterwaveService).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when payment provider is not found" do + before do + allow(PaymentProviders::FindService) + .to receive(:call) + .and_return(double(success?: false)) # rubocop:disable RSpec/VerifiedDoubles + allow(LagoHttpClient::Client).to receive(:new) + end + + it "does not process the transaction" do + result = charge_completed_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when HTTP error occurs during verification" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_raise(LagoHttpClient::HttpError.new(500, "Connection failed", "https://api.flutterwave.com")) + allow(Rails.logger).to receive(:error) + allow(Invoices::Payments::FlutterwaveService).to receive(:new) + end + + it "logs the error and does not update payment status" do + result = charge_completed_service.call + + expect(Rails.logger).to have_received(:error).with("Error verifying Flutterwave transaction: HTTP 500 - URI: https://api.flutterwave.com.\nError: Connection failed\nResponse headers: {}") + expect(Invoices::Payments::FlutterwaveService).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when payable type is PaymentRequest" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_payment_request_12345", + flw_ref: "LAGO/FLW270177170", + device_fingerprint: "a42937f4a73ce8bb8b8df14e63a2df31", + amount: 50000, + currency: "NGN", + charged_amount: 50000, + app_fee: 700, + merchant_fee: 0, + processor_response: "Approved by Financial Institution", + auth_model: "PIN", + ip: "197.210.64.96", + narration: "CARD Transaction", + status: "successful", + payment_type: "card", + created_at: "2020-07-06T19:17:04.000Z", + account_id: 17321, + customer: { + id: 215604089, + name: "John Doe", + phone_number: nil, + email: "customer@example.com", + created_at: "2020-07-06T19:17:04.000Z" + }, + card: { + first_6digits: "123456", + last_4digits: "7889", + issuer: "VERVE FIRST CITY MONUMENT BANK PLC", + country: "NG", + type: "VERVE", + expiry: "02/23" + }, meta: { + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest" + } + } + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:payment_service) { instance_double(PaymentRequests::Payments::FlutterwaveService) } + + before do + payment_request + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response) + allow(PaymentRequests::Payments::FlutterwaveService).to receive(:new).and_return(payment_service) + allow(payment_service).to receive(:update_payment_status).and_return(instance_double("BaseService::Result", raise_if_error!: nil)) + end + + it "uses the PaymentRequest service" do + charge_completed_service.call + + expect(PaymentRequests::Payments::FlutterwaveService).to have_received(:new).with(payable: payment_request) + expect(payment_service).to have_received(:update_payment_status) + end + end + + context "when payable type is invalid" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + charged_amount: 10000, + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + }, meta: { + lago_invoice_id: invoice.id, + lago_payable_type: "InvalidType" + } + } + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + invoice # Create the invoice so find_payable doesn't fail first + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response) + end + + it "raises a NameError" do + expect { charge_completed_service.call }.to raise_error(NameError, "Invalid lago_payable_type: InvalidType") + end + end + + context "when transaction has different currency precision" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 100.50, + currency: "USD", + charged_amount: 100.50, + app_fee: 1.40, + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + }, + meta: { + lago_invoice_id: invoice.id, + lago_payable_type: "Invoice" + } + } + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:payment_service) { instance_double(Invoices::Payments::FlutterwaveService) } + let(:verification_response_usd) do + { + "status" => "success", + "data" => { + "id" => 285959875, + "tx_ref" => "lago_invoice_12345", + "amount" => 100.50, + "currency" => "USD", + "charged_amount" => 100.50, + "status" => "successful" + } + } + end + + before do + invoice + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response_usd) + allow(Invoices::Payments::FlutterwaveService).to receive(:new).and_return(payment_service) + allow(payment_service).to receive(:update_payment_status).and_return(instance_double("BaseService::Result", raise_if_error!: nil)) + end + + it "handles decimal amounts correctly" do + charge_completed_service.call + + expect(payment_service).to have_received(:update_payment_status) do |args| + metadata = args[:flutterwave_payment].metadata + expect(metadata[:amount]).to eq(100.50) + expect(metadata[:currency]).to eq("USD") + end + end + end + + context "when webhook contains event.type field" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + }, + meta: { + lago_invoice_id: invoice.id, + lago_payable_type: "Invoice" + } + }, + "event.type": "CARD_TRANSACTION" + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:payment_service) { instance_double(Invoices::Payments::FlutterwaveService) } + + before do + invoice + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response) + allow(Invoices::Payments::FlutterwaveService).to receive(:new).and_return(payment_service) + allow(payment_service).to receive(:update_payment_status).and_return(instance_double("BaseService::Result", raise_if_error!: nil)) + end + + it "processes the webhook normally" do + result = charge_completed_service.call + + expect(payment_service).to have_received(:update_payment_status) + expect(result).to be_success + end + end + + context "when meta field is missing" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + } + } + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return({"status" => "error"}) + end + + it "does not process the transaction" do + result = charge_completed_service.call + + expect(result).to be_success + end + end + end +end diff --git a/spec/services/payment_providers/flutterwave_service_spec.rb b/spec/services/payment_providers/flutterwave_service_spec.rb new file mode 100644 index 00000000000..66c4bc09e50 --- /dev/null +++ b/spec/services/payment_providers/flutterwave_service_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::FlutterwaveService do + subject(:flutterwave_service) { described_class.new } + + let(:organization) { create(:organization) } + + describe "#create_or_update" do + let(:args) do + { + organization: organization, + code: "flutterwave_1", + name: "Flutterwave Provider", + secret_key: "FLWSECK_TEST-test_secret_key", + success_redirect_url: "https://example.com/success" + } + end + + context "when creating a new provider" do + it "creates a new flutterwave provider" do + result = flutterwave_service.create_or_update(**args) + + expect(result).to be_success + expect(result.flutterwave_provider).to be_a(PaymentProviders::FlutterwaveProvider) + expect(result.flutterwave_provider.organization_id).to eq(organization.id) + expect(result.flutterwave_provider.code).to eq("flutterwave_1") + expect(result.flutterwave_provider.name).to eq("Flutterwave Provider") + expect(result.flutterwave_provider.secret_key).to eq("FLWSECK_TEST-test_secret_key") + expect(result.flutterwave_provider.success_redirect_url).to eq("https://example.com/success") + end + end + + context "when updating an existing provider" do + let(:existing_provider) do + create( + :flutterwave_provider, + organization: organization, + code: "flutterwave_1", + name: "Old Name", + secret_key: "old_secret_key", + success_redirect_url: "https://old.example.com" + ) + end + + before { existing_provider } + + it "updates the existing provider" do + result = flutterwave_service.create_or_update(**args) + + expect(result).to be_success + expect(result.flutterwave_provider.id).to eq(existing_provider.id) + expect(result.flutterwave_provider.name).to eq("Flutterwave Provider") + expect(result.flutterwave_provider.secret_key).to eq("FLWSECK_TEST-test_secret_key") + expect(result.flutterwave_provider.success_redirect_url).to eq("https://example.com/success") + end + + context "when code is updated" do + let(:customer) { create(:customer, organization: organization, payment_provider_code: "flutterwave_1") } + let!(:flutterwave_customer) do + create(:flutterwave_customer, customer: customer, payment_provider: existing_provider) + end + + let(:args) do + { + organization: organization, + id: existing_provider.id, + code: "flutterwave_2", + name: "Updated Flutterwave Provider", + secret_key: "FLWSECK_TEST-updated_secret_key", + success_redirect_url: "https://updated.example.com" + } + end + + it "updates the provider and associated customer codes" do + result = flutterwave_service.create_or_update(**args) + + expect(result).to be_success + expect(result.flutterwave_provider.code).to eq("flutterwave_2") + flutterwave_customer.reload + expect(flutterwave_customer.customer.payment_provider_code).to eq("flutterwave_2") + end + end + end + + context "when partial update with only specific fields" do + let(:existing_provider) do + create( + :flutterwave_provider, + organization: organization, + code: "flutterwave_1", + name: "Original Name", + secret_key: "original_secret_key", + success_redirect_url: "https://original.example.com" + ) + end + + before { existing_provider } + + it "updates only the provided fields" do + partial_args = { + organization: organization, + id: existing_provider.id, + name: "Updated Name Only" + } + + result = flutterwave_service.create_or_update(**partial_args) + + expect(result).to be_success + expect(result.flutterwave_provider.name).to eq("Updated Name Only") + expect(result.flutterwave_provider.secret_key).to eq("original_secret_key") # unchanged + expect(result.flutterwave_provider.success_redirect_url).to eq("https://original.example.com") # unchanged + end + end + + context "when validation fails" do + let(:args) do + { + organization: organization, + code: "flutterwave_test", + name: "", # Invalid empty name + secret_key: "FLWSECK_TEST-test_secret_key" + } + end + + it "returns a failure result with validation errors" do + result = flutterwave_service.create_or_update(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + + context "when finding existing provider by code" do + let(:existing_provider) do + create( + :flutterwave_provider, + organization: organization, + code: "flutterwave_1" + ) + end + + before { existing_provider } + + it "finds and updates the existing provider by code" do + update_args = args.merge(code: "flutterwave_1", name: "Updated via Code") + + result = flutterwave_service.create_or_update(**update_args) + + expect(result).to be_success + expect(result.flutterwave_provider.id).to eq(existing_provider.id) + expect(result.flutterwave_provider.name).to eq("Updated via Code") + end + end + end +end diff --git a/spec/services/payment_requests/payments/flutterwave_service_spec.rb b/spec/services/payment_requests/payments/flutterwave_service_spec.rb new file mode 100644 index 00000000000..949dce3b5c6 --- /dev/null +++ b/spec/services/payment_requests/payments/flutterwave_service_spec.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::FlutterwaveService do + subject(:flutterwave_service) { described_class.new(payable: payment_request) } + + let(:organization) { create(:organization, name: "Test Organization") } + let(:customer) { create(:customer, organization: organization, email: "customer@example.com", name: "John Doe") } + let(:flutterwave_provider) { create(:flutterwave_provider, organization: organization, secret_key: "FLWSECK_TEST-secret") } + let(:flutterwave_customer) { create(:flutterwave_customer, customer: customer, payment_provider: flutterwave_provider) } + let(:invoice) { create(:invoice, organization: organization, customer: customer, total_amount_cents: 50000, currency: "USD") } + let(:payment_request) { create(:payment_request, organization: organization, customer: customer, total_amount_cents: 50000, currency: "USD") } + + before do + flutterwave_customer + payment_request.invoices << invoice + end + + describe "#call" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:successful_response) do + { + "status" => "success", + "data" => { + "link" => "https://checkout.flutterwave.com/v3/hosted/pay/test_link" + } + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(successful_response) + end + + it "creates a checkout session and returns payment URL" do + result = flutterwave_service.call + + expect(result).to be_success + expect(result.payment_url).to eq("https://checkout.flutterwave.com/v3/hosted/pay/test_link") + end + + it "sends correct parameters to Flutterwave API" do + flutterwave_service.call + + expect(http_client).to have_received(:post_with_response) do |body, headers| + expect(body[:amount]).to eq(500.0) + expect(body[:tx_ref]).to eq("lago_payment_request_#{payment_request.id}") + expect(body[:currency]).to eq("USD") + expect(body[:customer][:email]).to eq("customer@example.com") + expect(body[:customer][:name]).to eq("John Doe") + expect(body[:customizations][:title]).to eq("Test Organization - Payment Request") + expect(body[:meta][:lago_customer_id]).to eq(customer.id) + expect(body[:meta][:lago_payment_request_id]).to eq(payment_request.id) + expect(body[:meta][:lago_organization_id]).to eq(organization.id) + + expect(headers["Authorization"]).to eq("Bearer FLWSECK_TEST-secret") + expect(headers["Content-Type"]).to eq("application/json") + expect(headers["Accept"]).to eq("application/json") + end + end + + context "when HTTP client raises an error" do + let(:http_error) { LagoHttpClient::HttpError.new(500, "Connection failed", "https://api.example.com") } + + before do + allow(http_client).to receive(:post_with_response).and_raise(http_error) + allow(SendWebhookJob).to receive(:perform_later) + end + + it "delivers error webhook and returns service failure" do + result = flutterwave_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("action_script_runtime_error") + expect(result.error.message).to include("Connection failed") + end + + it "sends webhook notification about payment failure" do + flutterwave_service.call + + expect(SendWebhookJob).to have_received(:perform_later).with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: flutterwave_customer.provider_customer_id, + provider_error: { + message: "HTTP 500 - URI: https://api.example.com.\nError: Connection failed\nResponse headers: {}", + error_code: 500 + } + ) + end + end + end + + describe "#update_payment_status" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: { + payment_type: "one-time", + lago_payable_id: payment_request.id + } + ) + end + + context "when creating a new payment" do + it "creates a new payment and updates payment request status" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(result.payment).to be_a(Payment) + expect(result.payment.provider_payment_id).to eq("flw_payment_123") + expect(result.payment.amount_cents).to eq(50000) + expect(result.payment.payable).to eq(payment_request) + expect(result.payable).to eq(payment_request) + end + + it "increments payment attempts on the payment request" do + expect { flutterwave_service.update_payment_status(organization_id: organization.id, status: :succeeded, flutterwave_payment: flutterwave_payment) } + .to change { payment_request.reload.payment_attempts }.by(1) + end + end + + context "when updating existing payment" do + let(:existing_payment) do + create(:payment, + organization: organization, + payable: payment_request, + payment_provider: flutterwave_provider, + provider_payment_id: "flw_payment_123", + status: :pending) + end + + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: {payment_type: "recurring"} + ) + end + + before { existing_payment } + + it "updates the existing payment status" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(result.payment.id).to eq(existing_payment.id) + expect(result.payment.status).to eq("succeeded") + end + end + + context "when payment is not found" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "nonexistent_payment", + metadata: {payment_type: "recurring"} + ) + end + + it "returns not found failure" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + + context "when payment already succeeded" do + let(:succeeded_payment_request) { create(:payment_request, organization: organization, customer: customer, payment_status: :succeeded) } + let(:existing_payment) do + create(:payment, + organization: organization, + payable: succeeded_payment_request, + payment_provider: flutterwave_provider, + provider_payment_id: "flw_payment_123", + status: :succeeded) + end + + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: {payment_type: "recurring"} + ) + end + + before { existing_payment } + + it "returns early without processing" do + service = described_class.new(payable: succeeded_payment_request) + allow(service).to receive(:update_payable_payment_status) + + result = service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(service).not_to have_received(:update_payable_payment_status) + end + end + + context "when payment fails" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: { + payment_type: "one-time", + lago_payable_id: payment_request.id + } + ) + end + + before do + mailer_with_double = instance_double("PaymentRequestMailer") + mailer_message_double = instance_double("ActionMailer::MessageDelivery") + + allow(PaymentRequestMailer).to receive(:with).and_return(mailer_with_double) + allow(mailer_with_double).to receive(:requested).and_return(mailer_message_double) + allow(mailer_message_double).to receive(:deliver_later) + end + + it "sends payment failure email" do + flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :failed, + flutterwave_payment: flutterwave_payment + ) + expect(PaymentRequestMailer).to have_received(:with).with(payment_request: payment_request) + end + end + end + + describe "private methods" do + describe "#create_checkout_session" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return({"data" => {"link" => "test_link"}}) + end + + it "uses correct currency conversion" do + payment_request.update!(currency: "NGN", total_amount_cents: 1000000) # 10,000 NGN + + flutterwave_service.call + + expect(http_client).to have_received(:post_with_response) do |body| + expect(body[:amount]).to eq(10000.0) + expect(body[:currency]).to eq("NGN") + end + end + + it "includes correct meta parameters" do + flutterwave_service.call + + expect(http_client).to have_received(:post_with_response) do |body| + meta = body[:meta] + expect(meta[:lago_customer_id]).to eq(customer.id) + expect(meta[:lago_payment_request_id]).to eq(payment_request.id) + expect(meta[:lago_organization_id]).to eq(organization.id) + expect(meta[:lago_invoice_ids]).to eq(invoice.id.to_s) + end + 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 b357cd42673..620d5d7fc01 100644 --- a/spec/services/payment_requests/payments/payment_providers/factory_spec.rb +++ b/spec/services/payment_requests/payments/payment_providers/factory_spec.rb @@ -47,5 +47,13 @@ expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::MoneyhashService") end end + + context "when flutterwave" do + let(:payment_provider) { "flutterwave" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::FlutterwaveService") + end + end end end