Skip to content

Commit

Permalink
feat(stripe): Improve support of Indian card (#2690)
Browse files Browse the repository at this point in the history
- Add new logic for payment from Indian card to support 3DS auth
- Send a new webhook when a payment requires a 3DS auth
- All Indian payments are done with `off_session: false`
- Add a new `provider_payment_data` to support extra informations from
providers about the payment
  • Loading branch information
jdenquin authored Oct 16, 2024
1 parent 08d76e4 commit 52dff29
Show file tree
Hide file tree
Showing 14 changed files with 303 additions and 72 deletions.
1 change: 1 addition & 0 deletions app/jobs/send_webhook_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions app/models/payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions app/serializers/v1/payments/requires_action_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
38 changes: 32 additions & 6 deletions app/services/invoices/payments/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -190,28 +195,29 @@ 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}"
}
)
end

def stripe_payment_payload
def payment_intent_payload
{
amount: invoice.total_amount_cents,
currency: invoice.currency.downcase,
customer: customer.stripe_customer.provider_customer_id,
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,
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions app/services/webhooks/payments/requires_action_service.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@

config.hosts << 'api.lago.dev'
config.hosts << 'api'
config.hosts << 'lago.ngrok.dev'

config.license_url = 'http://license:3000'

Expand Down
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions spec/factories/payments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions spec/jobs/send_webhook_job_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions spec/scenarios/wallets/topup_with_open_invoices_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)}",
Expand Down Expand Up @@ -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)}",
Expand Down
31 changes: 31 additions & 0 deletions spec/serializers/v1/payments/requires_action_serializer_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 52dff29

Please sign in to comment.