mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-03-26 05:55:15 +00:00
At closer inspection, almost all logic around which payment actions to display involves only the state of the payment. So I moved the logic there. We now have one list of all possible actions supported by the UX. Then payment methods can declare a list of supported actions. If that's conditional, they can implement the conditions themselves. The payment model itself then still filters the actions based on its state.
298 lines
10 KiB
Ruby
298 lines
10 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "spree/localized_number"
|
|
|
|
module Spree
|
|
class Payment < ApplicationRecord
|
|
include Spree::Payment::Processing
|
|
extend Spree::LocalizedNumber
|
|
|
|
self.belongs_to_required_by_default = false
|
|
|
|
localize_number :amount
|
|
|
|
# All possible actions offered to shop owners.
|
|
ACTIONS = %w[
|
|
capture_and_complete_order
|
|
void
|
|
credit
|
|
resend_authorization_email
|
|
].freeze
|
|
IDENTIFIER_CHARS = (('A'..'Z').to_a + ('0'..'9').to_a - %w(0 1 I O)).freeze
|
|
|
|
delegate :line_items, to: :order
|
|
delegate :currency, to: :order
|
|
|
|
belongs_to :order, class_name: 'Spree::Order'
|
|
belongs_to :source, polymorphic: true
|
|
belongs_to :payment_method, class_name: "Spree::PaymentMethod", inverse_of: :payments
|
|
|
|
has_many :offsets, -> { where("source_type = 'Spree::Payment' AND amount < 0").completed },
|
|
class_name: "Spree::Payment", foreign_key: :source_id,
|
|
inverse_of: :source,
|
|
dependent: :restrict_with_exception
|
|
has_many :log_entries, as: :source, dependent: :destroy
|
|
|
|
has_one :adjustment, as: :adjustable, dependent: :destroy
|
|
|
|
validate :validate_source
|
|
after_initialize :build_source
|
|
before_create :set_unique_identifier
|
|
|
|
# invalidate previously entered payments
|
|
after_create :invalidate_old_payments
|
|
after_save :create_payment_profile
|
|
|
|
# update the order totals, etc.
|
|
after_save :ensure_correct_adjustment, :update_order
|
|
|
|
# Skips the validation of the source (for example, credit card) of the payment.
|
|
#
|
|
# This is used in refunds as the validation of the card can fail but the refund can go through,
|
|
# we trust the payment gateway in these cases. For example, Stripe is accepting refunds with
|
|
# source cards that were valid when the payment was placed but are now expired, and we
|
|
# consider them invalid.
|
|
attr_accessor :skip_source_validation
|
|
attr_accessor :source_attributes
|
|
|
|
scope :from_credit_card, -> { where(source_type: 'Spree::CreditCard') }
|
|
scope :with_state, ->(s) { where(state: s.to_s) }
|
|
scope :completed, -> { with_state('completed') }
|
|
scope :incomplete, -> { where(state: %w(checkout pending requires_authorization)) }
|
|
scope :checkout, -> { with_state('checkout') }
|
|
scope :pending, -> { with_state('pending') }
|
|
scope :failed, -> { with_state('failed') }
|
|
scope :valid, -> { where.not(state: %w(failed invalid)) }
|
|
scope :void, -> { with_state('void') }
|
|
scope :authorization_action_required, -> { where.not(redirect_auth_url: nil) }
|
|
scope :requires_authorization, -> { with_state("requires_authorization") }
|
|
scope :with_payment_intent, ->(code) { where(response_code: code) }
|
|
|
|
# order state machine (see http://github.com/pluginaweek/state_machine/tree/master for details)
|
|
state_machine initial: :checkout do
|
|
# With card payments, happens before purchase or authorization happens
|
|
event :started_processing do
|
|
transition from: [:checkout, :pending, :completed, :processing, :requires_authorization],
|
|
to: :processing
|
|
end
|
|
# When processing during checkout fails
|
|
event :failure do
|
|
transition from: [:pending, :processing, :requires_authorization], to: :failed
|
|
end
|
|
# With card payments this represents authorizing the payment
|
|
event :pend do
|
|
transition from: [:checkout, :processing], to: :pending
|
|
end
|
|
# With card payments this represents completing a purchase or capture transaction
|
|
event :complete do
|
|
transition from: [:processing, :pending, :checkout, :requires_authorization], to: :completed
|
|
end
|
|
event :void do
|
|
transition from: [:pending, :completed, :requires_authorization, :checkout], to: :void
|
|
end
|
|
# when the card brand isnt supported
|
|
event :invalidate do
|
|
transition from: [:checkout], to: :invalid
|
|
end
|
|
event :require_authorization do
|
|
transition from: [:checkout, :processing], to: :requires_authorization
|
|
end
|
|
event :fail_authorization do
|
|
transition from: [:requires_authorization], to: :failed
|
|
end
|
|
event :complete_authorization do
|
|
transition from: [:requires_authorization], to: :completed
|
|
end
|
|
event :resume do
|
|
transition from: [:void], to: :checkout
|
|
end
|
|
|
|
after_transition to: :completed, do: :set_captured_at
|
|
after_transition do |payment, transition|
|
|
# Catch any exceptions to prevent any rollback potentially
|
|
# preventing payment from going through
|
|
ActiveSupport::Notifications.instrument(
|
|
"ofn.payment_transition", payment: payment, event: transition.to
|
|
)
|
|
rescue StandardError => e
|
|
Rails.logger.fatal "ActiveSupport::Notification.instrument failed params: " \
|
|
"<event_type:ofn.payment_transition> " \
|
|
"<payment_id:#{payment.id}> " \
|
|
"<event:#{transition.to}>"
|
|
Alert.raise(
|
|
e,
|
|
metadata: {
|
|
event_type: "ofn.payment_transition", payment_id: payment.id, event: transition.to
|
|
}
|
|
)
|
|
end
|
|
end
|
|
|
|
# Allows by passing the default scope on Spree::PaymentMethod. It's needed to link payment
|
|
# to internal payment method.
|
|
# Using ->{ unscoped } on the association doesn't work presumably because the default scope
|
|
# is not a simple `where`.
|
|
def payment_method
|
|
Spree::PaymentMethod.unscoped { super }
|
|
end
|
|
|
|
def money
|
|
Spree::Money.new(amount, currency:)
|
|
end
|
|
alias display_amount money
|
|
|
|
def offsets_total
|
|
offsets.pluck(:amount).sum
|
|
end
|
|
|
|
def credit_allowed
|
|
amount - offsets_total
|
|
end
|
|
|
|
def build_source
|
|
return if source_attributes.nil?
|
|
return unless payment_method&.payment_source_class
|
|
|
|
self.source = payment_method.payment_source_class.new(source_attributes)
|
|
source.payment_method_id = payment_method.id
|
|
source.user_id = order.user_id if order
|
|
end
|
|
|
|
def actions
|
|
supported = ACTIONS & payment_method.actions.to_a
|
|
supported.select { public_send("can_#{it}?") }
|
|
end
|
|
|
|
# Indicates whether its possible to capture the payment
|
|
def can_capture_and_complete_order?
|
|
return false if requires_authorization?
|
|
|
|
pending? || checkout?
|
|
end
|
|
|
|
# Indicates whether its possible to void the payment.
|
|
def can_void?
|
|
!void?
|
|
end
|
|
|
|
# Indicates whether its possible to credit the payment. Note that most gateways require that the
|
|
# payment be settled first which generally happens within 12-24 hours of the transaction.
|
|
def can_credit?
|
|
return false unless completed?
|
|
return false unless order.payment_state == "credit_owed"
|
|
|
|
credit_allowed.positive?
|
|
end
|
|
|
|
def can_resend_authorization_email?
|
|
requires_authorization?
|
|
end
|
|
|
|
def resend_authorization_email!
|
|
return unless requires_authorization?
|
|
|
|
PaymentMailer.authorize_payment(self).deliver_later
|
|
end
|
|
|
|
def ensure_correct_adjustment
|
|
revoke_adjustment_eligibility if ['failed', 'invalid', 'void'].include?(state)
|
|
return if adjustment.try(:finalized?)
|
|
|
|
if adjustment
|
|
adjustment.originator = payment_method
|
|
adjustment.label = adjustment_label
|
|
adjustment.save
|
|
elsif !processing_refund? && payment_method.present?
|
|
payment_method.create_adjustment(adjustment_label, self, true)
|
|
adjustment.reload
|
|
end
|
|
end
|
|
|
|
def adjustment_label
|
|
I18n.t('payment_method_fee')
|
|
end
|
|
|
|
def clear_authorization_url
|
|
update_attribute(:redirect_auth_url, nil)
|
|
end
|
|
|
|
private
|
|
|
|
def processing_refund?
|
|
amount.negative?
|
|
end
|
|
|
|
# Don't charge fees for invalid or failed payments.
|
|
# This is called twice for failed payments, because the persistence of the 'failed'
|
|
# state is acheived through some trickery using an after_rollback callback on the
|
|
# payment model. See Spree::Payment#persist_invalid
|
|
def revoke_adjustment_eligibility
|
|
return unless adjustment.try(:reload)
|
|
return if adjustment.finalized?
|
|
|
|
adjustment.update(
|
|
eligible: false,
|
|
state: "finalized"
|
|
)
|
|
end
|
|
|
|
def validate_source
|
|
if source && !skip_source_validation && !source.valid?
|
|
source.errors.each do |error|
|
|
field_name =
|
|
I18n.t("activerecord.attributes.#{source.class.to_s.underscore}.#{error.attribute}")
|
|
errors.add(Spree.t(source.class.to_s.demodulize.underscore),
|
|
"#{field_name} #{error.message}")
|
|
end
|
|
end
|
|
errors.blank?
|
|
end
|
|
|
|
def create_payment_profile
|
|
return unless source.is_a?(CreditCard)
|
|
return unless source.try(:save_requested_by_customer?)
|
|
return unless source.number || source.gateway_payment_profile_id
|
|
return unless source.gateway_customer_profile_id.nil?
|
|
|
|
payment_method.try(:create_profile, self)
|
|
rescue ActiveMerchant::ConnectionError => e
|
|
gateway_error e
|
|
end
|
|
|
|
# Makes newly entered payments invalidate previously entered payments so the most recent payment
|
|
# is used by the gateway.
|
|
def invalidate_old_payments
|
|
order.payments.incomplete.where.not(id:).each do |payment|
|
|
# Using update_column skips validations and so it skips validate_source. As we are just
|
|
# invalidating past payments here, we don't want to validate all of them at this stage.
|
|
payment.update_columns(
|
|
state: 'invalid',
|
|
updated_at: Time.zone.now
|
|
)
|
|
payment.ensure_correct_adjustment
|
|
end
|
|
end
|
|
|
|
def update_order
|
|
OrderManagement::Order::Updater.new(order).after_payment_update(self)
|
|
end
|
|
|
|
def set_captured_at
|
|
update_column(:captured_at, Time.zone.now)
|
|
end
|
|
|
|
# Necessary because some payment gateways will refuse payments with
|
|
# duplicate IDs. We *were* using the Order number, but that's set once and
|
|
# is unchanging. What we need is a unique identifier on a per-payment basis,
|
|
# and this is it. Related to #1998.
|
|
# See https://github.com/spree/spree/issues/1998#issuecomment-12869105
|
|
def set_unique_identifier
|
|
self.identifier = generate_identifier while self.class.where(identifier:).exists?
|
|
end
|
|
|
|
def generate_identifier
|
|
Array.new(8){ IDENTIFIER_CHARS.sample }.join
|
|
end
|
|
end
|
|
end
|