Skip to content

Commit

Permalink
feat(dunning): Create stripe payment (#2471)
Browse files Browse the repository at this point in the history
## Context

We want to be able to manually request payment of the overdue balance
and send emails for reminders.


https://getlago.canny.io/feature-requests/p/send-reminders-for-overdue-invoices

## Description

The goal of this PR is to create a stripe payment for customers this
payment provider.
  • Loading branch information
ancorcruz authored Aug 23, 2024
1 parent 9f374d3 commit 1ee53cb
Show file tree
Hide file tree
Showing 12 changed files with 765 additions and 10 deletions.
27 changes: 18 additions & 9 deletions app/models/payment_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,30 @@ class PaymentRequest < ApplicationRecord
PAYMENT_STATUS = %i[pending succeeded failed].freeze

enum payment_status: PAYMENT_STATUS, _prefix: :payment

alias_attribute :total_amount_cents, :amount_cents
alias_attribute :currency, :amount_currency

def invoice_ids
applied_invoices.pluck(:invoice_id)
end
end

# == Schema Information
#
# Table name: payment_requests
#
# id :uuid not null, primary key
# amount_cents :bigint default(0), not null
# amount_currency :string not null
# email :string not null
# payment_status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# customer_id :uuid not null
# organization_id :uuid not null
# id :uuid not null, primary key
# amount_cents :bigint default(0), not null
# amount_currency :string not null
# email :string not null
# payment_attempts :integer default(0), not null
# payment_status :integer default("pending"), not null
# ready_for_payment_processing :boolean default(TRUE), not null
# created_at :datetime not null
# updated_at :datetime not null
# customer_id :uuid not null
# organization_id :uuid not null
#
# Indexes
#
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module PaymentRequests
module Payments
class DeliverErrorWebhookService < BaseService
def initialize(payment_request, params)
@payment_request = payment_request
@params = params
end

def call_async
SendWebhookJob.perform_later('payment_request.payment_failure', payment_request, params)

result
end

private

attr_reader :payment_request, :params
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module PaymentRequests
module Payments
module PaymentProviders
class Factory
def self.new_instance(payable:)
service_class(payable.customer&.payment_provider).new(payable)
end

def self.service_class(payment_provider)
case payment_provider&.to_s
when 'stripe'
PaymentRequests::Payments::StripeService
# when 'adyen'
# PaymentRequests::Payments::AdyenService
# when 'gocardless'
# PaymentRequests::Payments::GocardlessService
else
raise(NotImplementedError)
end
end
end
end
end
end
224 changes: 224 additions & 0 deletions app/services/payment_requests/payments/stripe_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# frozen_string_literal: true

module PaymentRequests
module Payments
class StripeService < BaseService
include Customers::PaymentProviderFinder

PENDING_STATUSES = %w[processing requires_capture requires_action requires_confirmation requires_payment_method]
.freeze
SUCCESS_STATUSES = %w[succeeded].freeze
FAILED_STATUSES = %w[canceled].freeze

def initialize(payable = nil)
@payable = payable

super(nil)
end

def create
result.payable = payable
return result unless should_process_payment?

unless payable.total_amount_cents.positive?
update_payable_payment_status(payment_status: :succeeded)
return result
end

increment_payment_attempts

stripe_result = create_stripe_payment
# NOTE: return if payment was not processed
return result unless stripe_result

payment = Payment.new(
payable: payable,
payment_provider_id: stripe_payment_provider.id,
payment_provider_customer_id: customer.stripe_customer.id,
amount_cents: stripe_result.amount,
amount_currency: stripe_result.currency&.upcase,
provider_payment_id: stripe_result.id,
status: stripe_result.status
)
payment.save!

update_payable_payment_status(
payment_status: payable_payment_status(payment.status),
processing: payment.status == 'processing'
)
update_invoices_payment_status(
payment_status: payable_payment_status(payment.status),
processing: payment.status == 'processing'
)

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

deliver_error_webhook(e)
update_payable_payment_status(payment_status: :failed, deliver_webhook: false)
result
rescue Stripe::RateLimitError, Stripe::APIConnectionError
raise # Let the auto-retry process do its own job
rescue Stripe::StripeError => e
deliver_error_webhook(e)
raise
end

private

attr_accessor :payable

delegate :organization, :customer, to: :payable

def success_redirect_url
stripe_payment_provider.success_redirect_url.presence ||
::PaymentProviders::StripeProvider::SUCCESS_REDIRECT_URL
end

def should_process_payment?
return false if payable.payment_succeeded?
return false if stripe_payment_provider.blank?

!!customer&.stripe_customer&.provider_customer_id
end

def stripe_api_key
stripe_payment_provider.secret_key
end

def stripe_payment_method
payment_method_id = customer.stripe_customer.payment_method_id

if payment_method_id
# NOTE: Check if payment method still exists
customer_service = PaymentProviderCustomers::StripeService.new(customer.stripe_customer)
customer_service_result = customer_service.check_payment_method(payment_method_id)
return customer_service_result.payment_method.id if customer_service_result.success?
end

# NOTE: Retrieve list of existing payment_methods
payment_method = Stripe::PaymentMethod.list(
{
customer: customer.stripe_customer.provider_customer_id
},
{
api_key: stripe_api_key
}
).first
customer.stripe_customer.payment_method_id = payment_method&.id
customer.stripe_customer.save!

payment_method&.id
end

def update_payment_method_id
result = Stripe::Customer.retrieve(
customer.stripe_customer.provider_customer_id,
{
api_key: stripe_api_key
}
)
# TODO: stripe customer should be updated/deleted
return if result.deleted?

if (payment_method_id = result.invoice_settings.default_payment_method || result.default_source)
customer.stripe_customer.update!(payment_method_id:)
end
end

def create_stripe_payment
update_payment_method_id

Stripe::PaymentIntent.create(
stripe_payment_payload,
{
api_key: stripe_api_key,
idempotency_key: "#{payable.id}/#{payable.payment_attempts}"
}
)
end

def stripe_payment_payload
{
amount: payable.total_amount_cents,
currency: payable.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,
description:,
metadata: {
lago_customer_id: customer.id,
lago_payment_request_id: payable.id,
lago_invoice_ids: payable.invoice_ids
}
}
end

def description
# TODO: for invoices we define the payment description with the
# invoice number, however, we do no have this kind of identifiers
# on payment requests...
#
# "#{organization.name} - PaymentRequest #{payable.number}"

"#{organization.name} - PaymentRequest 123"
end

def payable_payment_status(payment_status)
return :pending if PENDING_STATUSES.include?(payment_status)
return :succeeded if SUCCESS_STATUSES.include?(payment_status)
return :failed if FAILED_STATUSES.include?(payment_status)

payment_status&.to_sym
end

def update_payable_payment_status(payment_status:, deliver_webhook: true, processing: false)
payable.update!(
payment_status:,
# NOTE: A proper `processing` payment status should be introduced for payment_requests
ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded
)
end

def update_invoices_payment_status(payment_status:, deliver_webhook: true, processing: false)
payable.invoices.each do |invoice|
Invoices::UpdateService.call(
invoice: invoice,
params: {
payment_status:,
# NOTE: A proper `processing` payment status should be introduced for invoices
ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded
},
webhook_notification: deliver_webhook
).raise_if_error!
end
end

def increment_payment_attempts
payable.increment(:payment_attempts)
payable.save!
end

def deliver_error_webhook(stripe_error)
DeliverErrorWebhookService.call_async(payable, {
provider_customer_id: customer.stripe_customer.provider_customer_id,
provider_error: {
message: stripe_error.message,
error_code: stripe_error.code
}
})
end

def stripe_payment_provider
@stripe_payment_provider ||= payment_provider(customer)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

class AddPaymentRetryColumnsToPaymentRequests < ActiveRecord::Migration[7.1]
def change
safety_assured do
change_table :payment_requests, bulk: true do |t|
t.integer :payment_attempts, default: 0, null: false
t.boolean :ready_for_payment_processing, default: true, null: false
end
end
end
end
4 changes: 3 additions & 1 deletion db/schema.rb

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

2 changes: 2 additions & 0 deletions spec/factories/payment_requests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@
amount_currency { "EUR" }
email { Faker::Internet.email }
payment_status { "pending" }
ready_for_payment_processing { true }
payment_attempts { 0 }
end
end
26 changes: 26 additions & 0 deletions spec/models/payment_request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,30 @@
expect(payment_request).not_to be_valid
end
end

describe "#total_amount_cents" do
it "aliases amount_cents" do
expect(payment_request.total_amount_cents).to eq(payment_request.amount_cents)
end
end

describe "#currency" do
it "aliases amount_currency" do
expect(payment_request.currency).to eq(payment_request.amount_currency)
end
end

describe "#invoice_ids" do
let(:payment_request) do
create(:payment_request, invoices:)
end

let(:invoices) do
create_list(:invoice, 2)
end

it "returns a list with the applied invoice ids" do
expect(payment_request.invoice_ids).to eq(invoices.map(&:id))
end
end
end
Loading

0 comments on commit 1ee53cb

Please sign in to comment.