diff --git a/app/jobs/send_webhook_job.rb b/app/jobs/send_webhook_job.rb index 18a2ab6235f..497095d5298 100644 --- a/app/jobs/send_webhook_job.rb +++ b/app/jobs/send_webhook_job.rb @@ -34,6 +34,7 @@ class SendWebhookJob < ApplicationJob 'credit_note.generated' => Webhooks::CreditNotes::GeneratedService, 'credit_note.provider_refund_failure' => Webhooks::CreditNotes::PaymentProviderRefundFailureService, 'integration.provider_error' => Webhooks::Integrations::ProviderErrorService, + 'payment.requires_action' => Webhooks::Payments::RequiresActionService, 'payment_provider.error' => Webhooks::PaymentProviders::ErrorService, 'payment_request.created' => Webhooks::PaymentRequests::CreatedService, "payment_request.payment_failure" => Webhooks::PaymentProviders::PaymentRequestPaymentFailureService, diff --git a/app/models/payment.rb b/app/models/payment.rb index 4da9935d0cb..e1560f916b7 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -27,6 +27,7 @@ def should_sync_payment? # amount_cents :bigint not null # amount_currency :string not null # payable_type :string default("Invoice"), not null +# provider_payment_data :jsonb # status :string not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/serializers/v1/payments/requires_action_serializer.rb b/app/serializers/v1/payments/requires_action_serializer.rb new file mode 100644 index 00000000000..e785833cdc1 --- /dev/null +++ b/app/serializers/v1/payments/requires_action_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module V1 + module Payments + class RequiresActionSerializer < ModelSerializer + def serialize + { + lago_payable_id: model.payable.id, + lago_customer_id: model.payable.customer.id, + status: model.status, + external_customer_id: model.payable.customer.external_id, + provider_customer_id: options[:provider_customer_id], + payment_provider_code: model.payment_provider.code, + payment_provider_type: model.payment_provider.type, + provider_payment_id: model.provider_payment_id, + next_action: model.provider_payment_data + } + end + end + end +end diff --git a/app/services/invoices/payments/stripe_service.rb b/app/services/invoices/payments/stripe_service.rb index 89c1d5e0932..68606da16a7 100644 --- a/app/services/invoices/payments/stripe_service.rb +++ b/app/services/invoices/payments/stripe_service.rb @@ -27,7 +27,7 @@ def create increment_payment_attempts - stripe_result = create_stripe_payment + stripe_result = create_payment_intent # NOTE: return if payment was not processed return result unless stripe_result @@ -40,6 +40,9 @@ def create provider_payment_id: stripe_result.id, status: stripe_result.status ) + + payment.provider_payment_data = stripe_result.next_action if stripe_result.status == 'requires_action' + payment.save! update_invoice_payment_status( @@ -49,6 +52,8 @@ def create Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) if payment.should_sync_payment? + handle_requires_action(payment) if payment.status == 'requires_action' + result.payment = payment result rescue Stripe::AuthenticationError, Stripe::CardError, Stripe::InvalidRequestError, Stripe::PermissionError => e @@ -190,11 +195,11 @@ def update_payment_method_id end end - def create_stripe_payment + def create_payment_intent update_payment_method_id Stripe::PaymentIntent.create( - stripe_payment_payload, + payment_intent_payload, { api_key: stripe_api_key, idempotency_key: "#{invoice.id}/#{invoice.payment_attempts}" @@ -202,7 +207,7 @@ def create_stripe_payment ) end - def stripe_payment_payload + def payment_intent_payload { amount: invoice.total_amount_cents, currency: invoice.currency.downcase, @@ -210,8 +215,9 @@ def stripe_payment_payload payment_method: stripe_payment_method, payment_method_types: customer.stripe_customer.provider_payment_methods, confirm: true, - off_session: true, - error_on_requires_action: true, + off_session: off_session?, + return_url: success_redirect_url, + error_on_requires_action: error_on_requires_action?, description:, metadata: { lago_customer_id: customer.id, @@ -307,6 +313,26 @@ def handle_missing_payment(organization_id, metadata) result.not_found_failure!(resource: 'stripe_payment') end + # NOTE: Due to RBI limitation, all indians payment should be off_session + # to permit 3D secure authentication + # https://docs.stripe.com/india-recurring-payments + def off_session? + invoice.customer.country != 'IN' + end + + # NOTE: Same as off_session? + def error_on_requires_action? + invoice.customer.country != 'IN' + end + + def handle_requires_action(payment) + params = { + provider_customer_id: customer.stripe_customer.provider_customer_id + } + + SendWebhookJob.perform_later('payment.requires_action', payment, params) + end + def stripe_payment_provider @stripe_payment_provider ||= payment_provider(customer) end diff --git a/app/services/webhooks/payments/requires_action_service.rb b/app/services/webhooks/payments/requires_action_service.rb new file mode 100644 index 00000000000..0223595cb9f --- /dev/null +++ b/app/services/webhooks/payments/requires_action_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Webhooks + module Payments + class RequiresActionService < Webhooks::BaseService + private + + def current_organization + @current_organization ||= object.payable.organization + end + + def object_serializer + ::V1::Payments::RequiresActionSerializer.new( + object, + root_name: object_type, + provider_customer_id: options[:provider_customer_id] + ) + end + + def webhook_type + 'payment.requires_action' + end + + def object_type + 'payment' + end + end + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index 5fdfa82a291..ff91f95eeba 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -56,7 +56,6 @@ config.hosts << 'api.lago.dev' config.hosts << 'api' - config.hosts << 'lago.ngrok.dev' config.license_url = 'http://license:3000' diff --git a/db/migrate/20241014000100_add_provider_payment_data_to_payments.rb b/db/migrate/20241014000100_add_provider_payment_data_to_payments.rb new file mode 100644 index 00000000000..d5457788d0e --- /dev/null +++ b/db/migrate/20241014000100_add_provider_payment_data_to_payments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddProviderPaymentDataToPayments < ActiveRecord::Migration[7.1] + def change + add_column :payments, :provider_payment_data, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index f72847446af..aaed31b1cd9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -976,6 +976,7 @@ t.datetime "updated_at", null: false t.string "payable_type", default: "Invoice", null: false t.uuid "payable_id" + t.jsonb "provider_payment_data", default: {} t.index ["invoice_id"], name: "index_payments_on_invoice_id" t.index ["payable_type", "payable_id"], name: "index_payments_on_payable_type_and_payable_id" t.index ["payment_provider_customer_id"], name: "index_payments_on_payment_provider_customer_id" diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb index b609faa9fcd..0c382fa3574 100644 --- a/spec/factories/payments.rb +++ b/spec/factories/payments.rb @@ -10,5 +10,14 @@ amount_currency { 'EUR' } provider_payment_id { SecureRandom.uuid } status { 'pending' } + + trait :requires_action do + status { 'requires_action' } + provider_payment_data do + { + redirect_to_url: {url: 'https://foo.bar'} + } + end + end end end diff --git a/spec/jobs/send_webhook_job_spec.rb b/spec/jobs/send_webhook_job_spec.rb index 8ec36a53ad8..e14d1c0c730 100644 --- a/spec/jobs/send_webhook_job_spec.rb +++ b/spec/jobs/send_webhook_job_spec.rb @@ -501,4 +501,29 @@ expect(webhook_service).to have_received(:call) end end + + context 'when webhook_type is payment.requires_action' do + let(:webhook_service) { instance_double(Webhooks::Payments::RequiresActionService) } + let(:payment) { create(:payment, :requires_action) } + let(:webhook_options) do + { + provider_customer_id: 'customer_id' + } + end + + before do + allow(Webhooks::Payments::RequiresActionService) + .to receive(:new) + .with(object: payment, options: webhook_options) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it 'calls the webhook payment.requires_action' do + send_webhook_job.perform_now('payment.requires_action', payment, webhook_options) + + expect(Webhooks::Payments::RequiresActionService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end end diff --git a/spec/scenarios/wallets/topup_with_open_invoices_spec.rb b/spec/scenarios/wallets/topup_with_open_invoices_spec.rb index 6f6e89ae450..e1b2113f090 100644 --- a/spec/scenarios/wallets/topup_with_open_invoices_spec.rb +++ b/spec/scenarios/wallets/topup_with_open_invoices_spec.rb @@ -44,7 +44,7 @@ setup_stripe_for(customer:) - allow_any_instance_of(::Invoices::Payments::StripeService).to receive(:create_stripe_payment) # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(::Invoices::Payments::StripeService).to receive(:create_payment_intent) # rubocop:disable RSpec/AnyInstance .and_return( Stripe::PaymentIntent.construct_from( id: "ch_#{SecureRandom.hex(6)}", @@ -73,7 +73,7 @@ context 'when there is a payment failure' do it 'keeps the invoice invisible' do setup_stripe_for(customer:) - allow_any_instance_of(::Invoices::Payments::StripeService).to receive(:create_stripe_payment) # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(::Invoices::Payments::StripeService).to receive(:create_payment_intent) # rubocop:disable RSpec/AnyInstance .and_return( Stripe::PaymentIntent.construct_from( id: "ch_#{SecureRandom.hex(6)}", diff --git a/spec/serializers/v1/payments/requires_action_serializer_spec.rb b/spec/serializers/v1/payments/requires_action_serializer_spec.rb new file mode 100644 index 00000000000..a5fdd54271d --- /dev/null +++ b/spec/serializers/v1/payments/requires_action_serializer_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ::V1::Payments::RequiresActionSerializer do + subject(:serializer) { described_class.new(payment, params) } + + let(:payment) { create(:payment, :requires_action) } + let(:params) do + { + root_name: 'payment', + provider_customer_id: payment.payment_provider_customer.id + } + end + + it 'serializes the object' do + result = JSON.parse(serializer.to_json) + + aggregate_failures do + expect(result['payment']['lago_payable_id']).to eq(payment.payable.id) + expect(result['payment']['lago_customer_id']).to eq(payment.payable.customer.id) + expect(result['payment']['status']).to eq(payment.status) + expect(result['payment']['external_customer_id']).to eq(payment.payable.customer.external_id) + expect(result['payment']['provider_customer_id']).to eq(payment.payment_provider_customer.id) + expect(result['payment']['payment_provider_code']).to eq(payment.payment_provider.code) + expect(result['payment']['payment_provider_type']).to eq(payment.payment_provider.type) + expect(result['payment']['provider_payment_id']).to eq(payment.provider_payment_id) + expect(result['payment']['next_action']).to eq(payment.provider_payment_data) + end + end +end diff --git a/spec/services/invoices/payments/stripe_service_spec.rb b/spec/services/invoices/payments/stripe_service_spec.rb index cfcc4ae1b76..8418acf93cf 100644 --- a/spec/services/invoices/payments/stripe_service_spec.rb +++ b/spec/services/invoices/payments/stripe_service_spec.rb @@ -35,6 +35,15 @@ File.read(Rails.root.join('spec/fixtures/stripe/customer_retrieve_response.json')) end + let(:stripe_payment_intent) do + Stripe::PaymentIntent.construct_from( + id: 'ch_123456', + status: payment_status, + amount: invoice.total_amount_cents, + currency: invoice.currency + ) + end + let(:payment_status) { 'succeeded' } before do @@ -42,14 +51,7 @@ stripe_customer allow(Stripe::PaymentIntent).to receive(:create) - .and_return( - Stripe::PaymentIntent.construct_from( - id: 'ch_123456', - status: payment_status, - amount: invoice.total_amount_cents, - currency: invoice.currency - ) - ) + .and_return(stripe_payment_intent) allow(SegmentTrackJob).to receive(:perform_later) allow(Invoices::PrepaidCreditJob).to receive(:perform_later) @@ -313,6 +315,97 @@ expect(Stripe::PaymentIntent).to have_received(:create) end end + + context 'when customers country is IN' do + let(:payment_status) { 'requires_action' } + + let(:stripe_payment_intent) do + Stripe::PaymentIntent.construct_from( + id: 'ch_123456', + status: payment_status, + amount: invoice.total_amount_cents, + currency: invoice.currency, + next_action: { + redirect_to_url: {url: 'https://foo.bar'} + } + ) + end + + before do + customer.update(country: 'IN') + end + + it 'creates a stripe payment and payment with requires_action status' do + result = stripe_service.create + + expect(result).to be_success + + aggregate_failures do + expect(result.payment.status).to eq('requires_action') + expect(result.payment.provider_payment_data).not_to be_empty + end + end + + it 'has enqueued a SendWebhookJob' do + result = stripe_service.create + + expect(SendWebhookJob).to have_been_enqueued + .with( + 'payment.requires_action', + result.payment, + provider_customer_id: stripe_customer.provider_customer_id + ) + end + end + + context 'with #payment_intent_payload' do + let(:payment_intent_payload) { stripe_service.__send__(:payment_intent_payload) } + let(:payload) do + { + amount: invoice.total_amount_cents, + currency: invoice.currency.downcase, + customer: customer.stripe_customer.provider_customer_id, + payment_method: customer.stripe_customer.payment_method_id, + payment_method_types: customer.stripe_customer.provider_payment_methods, + confirm: true, + off_session: true, + return_url: stripe_service.__send__(:success_redirect_url), + error_on_requires_action: true, + description: stripe_service.__send__(:description), + metadata: { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + } + end + + it 'returns the payload' do + expect(payment_intent_payload).to eq(payload) + end + + context 'when customers country is IN' do + before do + payload[:off_session] = false + payload[:error_on_requires_action] = false + customer.update!(country: 'IN') + end + + it 'returns the payload' do + expect(payment_intent_payload).to eq(payload) + end + end + end + + context 'with #description' do + let(:description_call) { stripe_service.__send__(:description) } + let(:description) { "#{organization.name} - Invoice #{invoice.number}" } + + it 'returns the description' do + expect(description_call).to eq(description) + end + end end describe '#generate_payment_url' do @@ -349,67 +442,43 @@ expect(Stripe::Checkout::Session).not_to have_received(:create) end end - end - describe '#payment_url_payload' do - subject(:payment_url_payload_call) { stripe_service.__send__(:payment_url_payload) } - - let(:payload) do - { - line_items: [ - { - quantity: 1, - price_data: { - currency: invoice.currency.downcase, - unit_amount: invoice.total_amount_cents, - product_data: { - name: invoice.number + context 'with #payment_url_payload' do + let(:payment_url_payload) { stripe_service.__send__(:payment_url_payload) } + let(:payload) do + { + line_items: [ + { + quantity: 1, + price_data: { + currency: invoice.currency.downcase, + unit_amount: invoice.total_amount_cents, + product_data: { + name: invoice.number + } } } - } - ], - mode: 'payment', - success_url:, - customer: customer.stripe_customer.provider_customer_id, - payment_method_types: customer.stripe_customer.provider_payment_methods, - payment_intent_data: { - description:, - metadata: { - lago_customer_id: customer.id, - lago_invoice_id: invoice.id, - invoice_issuing_date: invoice.issuing_date.iso8601, - invoice_type: invoice.invoice_type, - payment_type: 'one-time' + ], + mode: 'payment', + success_url: stripe_service.__send__(:success_redirect_url), + customer: customer.stripe_customer.provider_customer_id, + payment_method_types: customer.stripe_customer.provider_payment_methods, + payment_intent_data: { + description: stripe_service.__send__(:description), + metadata: { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type, + payment_type: 'one-time' + } } } - } - end - - let(:success_url) { stripe_service.__send__(:success_redirect_url) } - let(:description) { stripe_service.__send__(:description) } - - before do - stripe_payment_provider - stripe_customer - end - - it 'returns payload' do - expect(subject).to eq(payload) - end - end - - describe '#description' do - subject(:description_call) { stripe_service.__send__(:description) } - - let(:description) { "#{organization.name} - Invoice #{invoice.number}" } - - before do - stripe_payment_provider - stripe_customer - end + end - it 'returns description' do - expect(subject).to eq(description) + it 'returns the payload' do + expect(payment_url_payload).to eq(payload) + end end end diff --git a/spec/services/webhooks/payments/requires_action_service_spec.rb b/spec/services/webhooks/payments/requires_action_service_spec.rb new file mode 100644 index 00000000000..af6160edb7a --- /dev/null +++ b/spec/services/webhooks/payments/requires_action_service_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Webhooks::Payments::RequiresActionService do + subject(:webhook_service) { described_class.new(object: payment, options: webhook_options) } + + let(:payment) { create(:payment, :requires_action) } + let(:webhook_options) { {provider_customer_id: 'customer_id'} } + + it_behaves_like 'creates webhook', 'payment.requires_action', 'payment' +end