Skip to content

Commit

Permalink
misc(stripe): Refact payment method update webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
vincent-pochet committed Nov 12, 2024
1 parent f564be8 commit d12a15b
Show file tree
Hide file tree
Showing 32 changed files with 615 additions and 413 deletions.
4 changes: 2 additions & 2 deletions app/jobs/invoices/payments/stripe_create_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ class StripeCreateJob < ApplicationJob

unique :until_executed, on_conflict: :log

retry_on Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6
retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6

def perform(invoice)
result = Invoices::Payments::StripeService.new(invoice).create
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ module PaymentProviderCustomers
class StripeCheckoutUrlJob < ApplicationJob
queue_as :providers

retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6
retry_on Stripe::APIError, wait: :polynomially_longer, attempts: 6
retry_on Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::APIError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6
retry_on ActiveJob::DeserializationError

def perform(stripe_customer)
Expand Down
6 changes: 3 additions & 3 deletions app/jobs/payment_provider_customers/stripe_create_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ module PaymentProviderCustomers
class StripeCreateJob < ApplicationJob
queue_as :providers

retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6
retry_on Stripe::APIError, wait: :polynomially_longer, attempts: 6
retry_on Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::APIError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6
retry_on ActiveJob::DeserializationError

def perform(stripe_customer)
Expand Down
4 changes: 2 additions & 2 deletions app/jobs/payment_requests/payments/stripe_create_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ class StripeCreateJob < ApplicationJob

unique :until_executed, on_conflict: :log

retry_on Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6
retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6
retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6

def perform(payable)
result = PaymentRequests::Payments::StripeService.new(payable).create
Expand Down
2 changes: 1 addition & 1 deletion app/services/credit_notes/refunds/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def create_stripe_refund
idempotency_key: credit_note.id
}
)
rescue Stripe::InvalidRequestError => e
rescue ::Stripe::InvalidRequestError => e
deliver_error_webhook(message: e.message, code: e.code)
update_credit_note_status(:failed)

Expand Down
10 changes: 5 additions & 5 deletions app/services/invoices/payments/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,17 @@ def create

result.payment = payment
result
rescue Stripe::AuthenticationError, Stripe::CardError, Stripe::InvalidRequestError, Stripe::PermissionError => e
rescue ::Stripe::AuthenticationError, ::Stripe::CardError, ::Stripe::InvalidRequestError, Stripe::PermissionError => e
# NOTE: Do not mark the invoice as failed if the amount is too small for Stripe
# For now we keep it as pending, the user can still update it manually
return result if e.code == 'amount_too_small'

deliver_error_webhook(e)
update_invoice_payment_status(payment_status: :failed, deliver_webhook: false)
result
rescue Stripe::RateLimitError, Stripe::APIConnectionError
rescue ::Stripe::RateLimitError, Stripe::APIConnectionError
raise # Let the auto-retry process do its own job
rescue Stripe::StripeError => e
rescue ::Stripe::StripeError => e
deliver_error_webhook(e)
raise
end
Expand Down Expand Up @@ -98,7 +98,7 @@ def update_payment_status(organization_id:, provider_payment_id:, status:, metad
def generate_payment_url
return result unless should_process_payment?

res = Stripe::Checkout::Session.create(
res = ::Stripe::Checkout::Session.create(
payment_url_payload,
{
api_key: stripe_api_key
Expand All @@ -108,7 +108,7 @@ def generate_payment_url
result.payment_url = res['url']

result
rescue Stripe::CardError, Stripe::InvalidRequestError, Stripe::AuthenticationError, Stripe::PermissionError => e
rescue ::Stripe::CardError, ::Stripe::InvalidRequestError, ::Stripe::AuthenticationError, Stripe::PermissionError => e
deliver_error_webhook(e)

result.single_validation_failure!(error_code: 'payment_provider_error')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_String_literal: true

module PaymentProviderCustomers
module Stripe
class UpdatePaymentMethodService < BaseService
def initialize(stripe_customer:, payment_method_id:)
@stripe_customer = stripe_customer
@payment_method_id = payment_method_id

super
end

def call
return result.not_found_failure!(resource: 'stripe_customer') unless stripe_customer

stripe_customer.payment_method_id = payment_method_id
stripe_customer.save!

reprocess_pending_invoices

result.stripe_customer = stripe_customer
result
end

private

attr_reader :stripe_customer, :payment_method_id

delegate :customer, to: :stripe_customer

def reprocess_pending_invoices
invoices = customer.invoices
.payment_pending
.where(ready_for_payment_processing: true)
.where(status: 'finalized')

invoices.find_each do |invoice|
Invoices::Payments::StripeCreateJob.perform_later(invoice)
rescue ActiveJob::Uniqueness::JobNotUnique
# NOTE: Payment is already enqueued for processing
Rails.logger.warn("Duplicated payment attempt for invoice #{invoice.id}")
end
end
end
end
end
79 changes: 13 additions & 66 deletions app/services/payment_provider_customers/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,58 +31,19 @@ def create
def update
return result if !stripe_payment_provider || stripe_customer.provider_customer_id.blank?

Stripe::Customer.update(stripe_customer.provider_customer_id, stripe_update_payload, {api_key:})
::Stripe::Customer.update(stripe_customer.provider_customer_id, stripe_update_payload, {api_key:})
result
rescue Stripe::InvalidRequestError, Stripe::PermissionError => e
rescue ::Stripe::InvalidRequestError, ::Stripe::PermissionError => e
deliver_error_webhook(e)

result.service_failure!(code: 'stripe_error', message: e.message)
rescue Stripe::AuthenticationError => e
rescue ::Stripe::AuthenticationError => e
deliver_error_webhook(e)

message = ['Stripe authentication failed.', e.message.presence].compact.join(' ')
result.unauthorized_failure!(message:)
end

def update_payment_method(organization_id:, stripe_customer_id:, payment_method_id:, metadata: {})
@stripe_customer = PaymentProviderCustomers::StripeCustomer
.joins(:customer)
.where(customers: {organization_id:})
.find_by(provider_customer_id: stripe_customer_id)
return handle_missing_customer(organization_id, metadata) unless stripe_customer

stripe_customer.payment_method_id = payment_method_id
stripe_customer.save!

reprocess_pending_invoices(customer)

result.stripe_customer = stripe_customer
result
rescue ActiveRecord::RecordInvalid => e
result.record_validation_failure!(record: e.record)
end

def update_provider_default_payment_method(organization_id:, stripe_customer_id:, payment_method_id:, metadata: {})
return result.not_found_failure!(resource: 'stripe_customer') unless stripe_customer_id

@stripe_customer = PaymentProviderCustomers::StripeCustomer
.joins(:customer)
.where(customers: {organization_id:})
.find_by(provider_customer_id: stripe_customer_id)
return handle_missing_customer(organization_id, metadata) unless stripe_customer

Stripe::Customer.update(
stripe_customer_id,
{invoice_settings: {default_payment_method: payment_method_id}},
{api_key:}
)

result.payment_method = payment_method_id
result
rescue Stripe::InvalidRequestError => e
result.service_failure!(code: 'stripe_error', message: e.message)
end

def delete_payment_method(organization_id:, stripe_customer_id:, payment_method_id:, metadata: {})
@stripe_customer = PaymentProviderCustomers::StripeCustomer
.joins(:customer)
Expand All @@ -100,12 +61,12 @@ def delete_payment_method(organization_id:, stripe_customer_id:, payment_method_
end

def check_payment_method(payment_method_id)
payment_method = Stripe::Customer.new(id: stripe_customer.provider_customer_id)
payment_method = ::Stripe::Customer.new(id: stripe_customer.provider_customer_id)
.retrieve_payment_method(payment_method_id, {}, {api_key:})

result.payment_method = payment_method
result
rescue Stripe::InvalidRequestError
rescue ::Stripe::InvalidRequestError
# NOTE: The payment method is no longer valid
stripe_customer.update!(payment_method_id: nil)

Expand All @@ -116,7 +77,7 @@ def generate_checkout_url(send_webhook: true)
return result unless customer # NOTE: Customer is nil when deleted.
return result if customer.organization.webhook_endpoints.none? && send_webhook && payment_provider(customer)

res = Stripe::Checkout::Session.create(
res = ::Stripe::Checkout::Session.create(
checkout_link_params,
{
api_key:
Expand All @@ -134,10 +95,10 @@ def generate_checkout_url(send_webhook: true)
end

result
rescue Stripe::InvalidRequestError, Stripe::PermissionError => e
rescue ::Stripe::InvalidRequestError, ::Stripe::PermissionError => e
deliver_error_webhook(e)
result
rescue Stripe::AuthenticationError => e
rescue ::Stripe::AuthenticationError => e
deliver_error_webhook(e)

message = ['Stripe authentication failed.', e.message.presence].compact.join(' ')
Expand Down Expand Up @@ -177,23 +138,23 @@ def success_redirect_url
end

def create_stripe_customer
Stripe::Customer.create(
::Stripe::Customer.create(
stripe_create_payload,
{
api_key:,
idempotency_key: [customer.id, customer.updated_at.to_i].join('-')
}
)
rescue Stripe::InvalidRequestError, Stripe::PermissionError => e
rescue ::Stripe::InvalidRequestError, ::Stripe::PermissionError => e
deliver_error_webhook(e)
nil
rescue Stripe::AuthenticationError => e
rescue ::Stripe::AuthenticationError => e
deliver_error_webhook(e)

message = ['Stripe authentication failed.', e.message.presence].compact.join(' ')
result.unauthorized_failure!(message:)
rescue Stripe::IdempotencyError
stripe_customers = Stripe::Customer.list({email: customer.email}, {api_key:})
rescue ::Stripe::IdempotencyError
stripe_customers = ::Stripe::Customer.list({email: customer.email}, {api_key:})
return stripe_customers.first if stripe_customers.count == 1

# NOTE: Multiple stripe customers with the same email,
Expand Down Expand Up @@ -255,20 +216,6 @@ def deliver_error_webhook(stripe_error)
)
end

def reprocess_pending_invoices(customer)
invoices = customer.invoices
.payment_pending
.where(ready_for_payment_processing: true)
.where(status: 'finalized')

invoices.find_each do |invoice|
Invoices::Payments::StripeCreateJob.perform_later(invoice)
rescue ActiveJob::Uniqueness::JobNotUnique
# NOTE: Payment is already enqueued for processing
Rails.logger.warn("Duplicated payment attempt for invoice #{invoice.id}")
end
end

def handle_missing_customer(organization_id, metadata)
# NOTE: Stripe customer was not created from lago
return result unless metadata&.key?(:lago_customer_id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def call
rescue ::Stripe::AuthenticationError => e
deliver_error_webhook(action: 'payment_provider.register_webhook', error: e)
result
rescue ::Stripe::InvalidRequestError => e
rescue ::Stripe::PermissionError => e
raise if e.message != "You have reached the maximum of 16 test webhook endpoints."

deliver_error_webhook(action: 'payment_provider.register_webhook', error: e)
Expand Down
36 changes: 8 additions & 28 deletions app/services/payment_providers/stripe_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,35 +93,15 @@ def handle_event(organization:, event_json:)

case event.type
when 'setup_intent.succeeded'
service = PaymentProviderCustomers::StripeService.new

service
.update_provider_default_payment_method(
organization_id: organization.id,
stripe_customer_id: event.data.object.customer,
payment_method_id: event.data.object.payment_method,
metadata: event.data.object.metadata.to_h.symbolize_keys
).raise_if_error!

service
.update_payment_method(
organization_id: organization.id,
stripe_customer_id: event.data.object.customer,
payment_method_id: event.data.object.payment_method,
metadata: event.data.object.metadata.to_h.symbolize_keys
).raise_if_error!
PaymentProviders::Webhooks::Stripe::SetupIntentSucceededService.call(
organization_id: organization.id,
event_json:
).raise_if_error!
when 'customer.updated'
payment_method_id = event.data.object.invoice_settings.default_payment_method ||
event.data.object.default_source

PaymentProviderCustomers::StripeService
.new
.update_payment_method(
organization_id: organization.id,
stripe_customer_id: event.data.object.id,
payment_method_id:,
metadata: event.data.object.metadata.to_h.symbolize_keys
).raise_if_error!
PaymentProviders::Webhooks::Stripe::CustomerUpdatedService.call(
organization_id: organization.id,
event_json:
).raise_if_error!
when 'charge.succeeded'
payment_service_klass(event)
.new.update_payment_status(
Expand Down
20 changes: 20 additions & 0 deletions app/services/payment_providers/webhooks/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,26 @@ def initialize(organization_id:, event_json:)
private

attr_reader :organization, :event_json

def event
@event ||= ::Stripe::Event.construct_from(JSON.parse(event_json))
end

def metadata
@metadata ||= event.data.object.metadata.to_h.symbolize_keys
end

def handle_missing_customer
# NOTE: Stripe customer was not created from lago
return result unless metadata&.key?(:lago_customer_id)

# NOTE: Customer does not belong to this lago instance or
# exists but does not belong to the organizations
# (Happens when the Stripe API key is shared between organizations)
return result if Customer.find_by(id: metadata[:lago_customer_id], organization_id: organization.id).nil?

result.not_found_failure!(resource: 'stripe_customer')
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ def call

private

def event
@event ||= ::Stripe::Event.construct_from(JSON.parse(event_json))
end

def payment_dispute_lost_at
Time.zone.at(event.created)
end
Expand Down
Loading

0 comments on commit d12a15b

Please sign in to comment.