openfoodfoundation/openfoodnetwork

View on GitHub
app/models/spree/payment.rb

Summary

Maintainability
A
0 mins
Test Coverage
# 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

    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'

    has_many :offsets, -> { where("source_type = 'Spree::Payment' AND amount < 0").completed },
             class_name: "Spree::Payment", foreign_key: :source_id,
             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, if: :profiles_supported?

    # 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(cvv_response_message: 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
    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 can_credit?
      credit_allowed.positive?
    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
      return [] unless payment_source&.respond_to?(:actions)

      payment_source.actions.select do |action|
        !payment_source.respond_to?("can_#{action}?") ||
          payment_source.__send__("can_#{action}?", self)
      end
    end

    def resend_authorization_email!
      return unless requires_authorization?

      PaymentMailer.authorize_payment(self).deliver_later
    end

    def payment_source
      res = source.is_a?(Payment) ? source.source : source
      res || payment_method
    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(:cvv_response_message, 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 profiles_supported?
      payment_method.respond_to?(:payment_profiles_supported?) &&
        payment_method.payment_profiles_supported?
    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.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