Class: Payment

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable, PgSearch::Model
Defined in:
app/models/payment.rb

Overview

== Schema Information

Table name: payments
Database name: primary

id :bigint not null, primary key
account_holder_type :string
account_type :string
address_line1_check :string
address_zip_check :string
amazon_pay_status :string
amount :decimal(, )
approval_notification_sent :boolean
authorization_code :string
authorization_reference :string
authorization_type :string
billing_address_city :string
billing_address_country :string
billing_address_line1 :string
billing_address_line2 :string
billing_address_state :string
billing_address_zip :string
brand :string
bread_token :string
capture_before :datetime
card_country :string
card_expires_on :date
card_identifier :string
card_type :string
category :string
currency :string
cvc_check :string
date :date
email :string
exp_month :integer
exp_year :integer
first_name :string
fraud_review_done :boolean
http_accept_language :string
http_user_agent :string
issuer_number :string
last4 :string
last_name :string
name :string
payment_approved :boolean
paypal_email :string
paypal_metadata :jsonb
paypal_token :string
plaid_expected_settlement_date :date
plaid_public_token :string
po_number :string
radar_network_status :string
radar_reason :string
radar_risk_level :string
radar_seller_message :string
radar_type :string
reference :string
remote_ip_address :string
routing_number :string
send_authorization_email :boolean
shipping_address_city :string
shipping_address_country :string
shipping_address_line1 :string
shipping_address_line2 :string
shipping_address_name :string
shipping_address_state :string
shipping_address_zip :string
skip_auto_receipt :boolean default(FALSE)
skip_minfraud :boolean
state :string default("pending")
stripe_capabilities :jsonb
test :boolean
uploads_count :integer
zip_code :string
created_at :datetime not null
updated_at :datetime not null
account_id :integer
amazon_pay_charge_id :string
amazon_pay_charge_permission_id :string
amazon_pay_checkout_session_id :string
creator_id :integer
credit_memo_id :integer
customer_id :integer
delivery_id :integer
invoice_id :integer
order_id :integer
paypal_payer_id :string
paypal_transaction_id :string
plaid_account_id :string
plaid_transfer_id :string
plaid_transfer_intent_id :string
rma_id :integer
stripe_payment_intent_id :string
transaction_id :string
updater_id :integer
vault_id :string
vpo_contact_id :integer

Indexes

by_did_ctry_st (delivery_id,category,state)
by_iid_st_at (invoice_id,state,authorization_type)
by_oid_ctry_st (order_id,category,state)
idx_state_category (state,category)
index_payments_on_account_id (account_id)
index_payments_on_authorization_type_and_authorization_code (authorization_type,authorization_code) WHERE ((authorization_type IS NOT NULL) AND (authorization_code IS NOT NULL))
index_payments_on_creator_id (creator_id)
index_payments_on_credit_memo_id (credit_memo_id)
index_payments_on_customer_id (customer_id)
index_payments_on_order_id_and_state (order_id,state)
index_payments_on_paypal_payer_id (paypal_payer_id)
index_payments_on_paypal_transaction_id (paypal_transaction_id)
index_payments_on_rma_id (rma_id)
index_payments_on_stripe_payment_intent_id (stripe_payment_intent_id) WHERE (stripe_payment_intent_id IS NOT NULL)
index_payments_on_transaction_id (transaction_id)
index_payments_on_updater_id (updater_id)
index_payments_on_vault_id (vault_id)
index_payments_on_vpo_contact_id (vpo_contact_id)

Foreign Keys

payments_credit_memo_id_fkey (credit_memo_id => credit_memos.id)
payments_customer_id_fkey (customer_id => parties.id) ON DELETE => cascade
payments_delivery_id_fk (delivery_id => deliveries.id) ON DELETE => nullify
payments_invoice_id_fkey (invoice_id => invoices.id)
payments_order_id_fk (order_id => orders.id) ON DELETE => cascade

Defined Under Namespace

Classes: DailyIssuesDigestWorker, DuplicateChargeGuard, OrderProcessor, PaypalStatusResult, StrategyResolver, StripeRefundReconciliationWorker

Constant Summary collapse

ADV_REPL =
'Advance Replacement'
CHECK =
'Check'
CREDIT_CARD =
'Credit Card'
CREDIT_CARD_TERMINAL =
'Credit Card Terminal'
BREAD =
'Bread'
PLAID =
'Plaid'
AMAZON_PAY =
'Amazon Pay'
PO =
'Purchase Order'
VPO =
'Verbal Purchase Order'
ECHECK =
'eCheck'
PAYPAL =
'PayPal'
PAYPAL_INVOICE =
'PayPal Invoice'
RMA_CREDIT =
'RMA Credit'
CASH =
'Cash'
STORE_CREDIT =
'Store Credit'
WIRE =
'Wire Transfer'
ACCOUNT_HOLDER_TYPES =
%w[personal business]
ACCOUNT_TYPES =
%w[checking savings]
PAYPAL_MIN_SIGNATURE_REQUIRED =
750.00
ECHECK_MIN_AMOUNT_WITHOUT_SUPERVISION =
2000.00
CATEGORIES_REQUIRING_REVIEW =
[PAYPAL_INVOICE, RMA_CREDIT, ECHECK, CHECK, CASH, WIRE]
CATEGORIES_NOT_ALLOWING_CAPTURE =
[PO, VPO, ECHECK, WIRE]
PAYPAL_OVER_CAPTURE_PERCENT =
BigDecimal('1.15')
PAYPAL_OVER_CAPTURE_MAX_INCREASE =
BigDecimal('75')
PAYPAL_HONOR_PERIOD =
3.days
PAYPAL_REAUTH_BUFFER =
12.hours
PAYPAL_MAX_AUTH_DAYS =
29
PAYPAL_REAUTH_EARLIEST_DAY =
4

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has one collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#account_holder_typeObject (readonly)



186
# File 'app/models/payment.rb', line 186

validates :account_type, :account_holder_type, presence: { if: proc { |pp| pp.vault_id.blank? && pp.authorization_type == 'check' } }

#account_numberObject

Returns the value of attribute account_number.



212
213
214
# File 'app/models/payment.rb', line 212

def 
  @account_number
end

#account_typeObject (readonly)



186
# File 'app/models/payment.rb', line 186

validates :account_type, :account_holder_type, presence: { if: proc { |pp| pp.vault_id.blank? && pp.authorization_type == 'check' } }

#address_cityObject

Returns the value of attribute address_city.



212
213
214
# File 'app/models/payment.rb', line 212

def address_city
  @address_city
end

#address_countryObject

Returns the value of attribute address_country.



212
213
214
# File 'app/models/payment.rb', line 212

def address_country
  @address_country
end

#address_idObject

Returns the value of attribute address_id.



212
213
214
# File 'app/models/payment.rb', line 212

def address_id
  @address_id
end

#address_line1Object

Returns the value of attribute address_line1.



212
213
214
# File 'app/models/payment.rb', line 212

def address_line1
  @address_line1
end

#address_line2Object

Returns the value of attribute address_line2.



212
213
214
# File 'app/models/payment.rb', line 212

def address_line2
  @address_line2
end

#address_stateObject

Returns the value of attribute address_state.



212
213
214
# File 'app/models/payment.rb', line 212

def address_state
  @address_state
end

#address_zipObject

Returns the value of attribute address_zip.



212
213
214
# File 'app/models/payment.rb', line 212

def address_zip
  @address_zip
end

#amountObject (readonly)



182
# File 'app/models/payment.rb', line 182

validates :category, :amount, :currency, :state, presence: true

#amount_to_captureObject

Returns the value of attribute amount_to_capture.



212
213
214
# File 'app/models/payment.rb', line 212

def amount_to_capture
  @amount_to_capture
end

#bread_tokenObject

Returns the value of attribute bread_token.



212
213
214
# File 'app/models/payment.rb', line 212

def bread_token
  @bread_token
end

#card_tokenObject

Returns the value of attribute card_token.



212
213
214
# File 'app/models/payment.rb', line 212

def card_token
  @card_token
end

#categoryObject (readonly)



182
# File 'app/models/payment.rb', line 182

validates :category, :amount, :currency, :state, presence: true

Returns the value of attribute consent_channel.



212
213
214
# File 'app/models/payment.rb', line 212

def consent_channel
  @consent_channel
end

#currencyObject (readonly)



182
# File 'app/models/payment.rb', line 182

validates :category, :amount, :currency, :state, presence: true

#emailObject (readonly)



206
# File 'app/models/payment.rb', line 206

validates :email, email_format: true

#error_codesObject

Returns the value of attribute error_codes.



212
213
214
# File 'app/models/payment.rb', line 212

def error_codes
  @error_codes
end

#issuer_numberObject

Returns the value of attribute issuer_number.



212
213
214
# File 'app/models/payment.rb', line 212

def issuer_number
  @issuer_number
end

#last_responseObject

Returns the value of attribute last_response.



212
213
214
# File 'app/models/payment.rb', line 212

def last_response
  @last_response
end

#paypal_emailObject (readonly)



187
# File 'app/models/payment.rb', line 187

validates :paypal_email, presence: { if: proc { |pp| pp.authorization_type == 'paypal_invoice' } }

#po_numberObject (readonly)



184
# File 'app/models/payment.rb', line 184

validates :po_number, presence: { if: proc { |pp| pp.category == PO } }

#rma_idObject (readonly)



185
# File 'app/models/payment.rb', line 185

validates :rma_id, presence: { if: proc { |pp| pp.category == ADV_REPL } }

#stateObject (readonly)



182
# File 'app/models/payment.rb', line 182

validates :category, :amount, :currency, :state, presence: true

#store_addressObject

Returns the value of attribute store_address.



212
213
214
# File 'app/models/payment.rb', line 212

def store_address
  @store_address
end

#store_cardObject

Returns the value of attribute store_card.



212
213
214
# File 'app/models/payment.rb', line 212

def store_card
  @store_card
end

#store_card_nameObject

Returns the value of attribute store_card_name.



212
213
214
# File 'app/models/payment.rb', line 212

def store_card_name
  @store_card_name
end

#vpo_contact_idObject (readonly)



183
# File 'app/models/payment.rb', line 183

validates :vpo_contact_id, presence: { if: proc { |pp| pp.category == VPO } }

Class Method Details

.all_amazon_pay_capturedActiveRecord::Relation<Payment>

A relation of Payments that are all amazon pay captured. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



234
# File 'app/models/payment.rb', line 234

scope :all_amazon_pay_captured, -> { all_captured.where(authorization_type: 'amazon_pay') }

.all_authorizedActiveRecord::Relation<Payment>

A relation of Payments that are all authorized. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



237
# File 'app/models/payment.rb', line 237

scope :all_authorized, -> { where(state: 'authorized') }

.all_capturedActiveRecord::Relation<Payment>

A relation of Payments that are all captured. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



229
# File 'app/models/payment.rb', line 229

scope :all_captured, -> { where(state: 'captured') }

.all_cc_capturedActiveRecord::Relation<Payment>

A relation of Payments that are all cc captured. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



231
# File 'app/models/payment.rb', line 231

scope :all_cc_captured, -> { all_captured.where(authorization_type: %w[credit_card paypal]) }

.all_check_capturedActiveRecord::Relation<Payment>

A relation of Payments that are all check captured. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



232
# File 'app/models/payment.rb', line 232

scope :all_check_captured, -> { all_captured.where(authorization_type: 'check') }

.all_collect_capturedActiveRecord::Relation<Payment>

A relation of Payments that are all collect captured. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



236
# File 'app/models/payment.rb', line 236

scope :all_collect_captured, -> { all_captured.where(authorization_type: 'credit_card', reference: 'Collect Card Reader') }

.all_paypal_invoice_capturedActiveRecord::Relation<Payment>

A relation of Payments that are all paypal invoice captured. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



235
# File 'app/models/payment.rb', line 235

scope :all_paypal_invoice_captured, -> { all_captured.where(authorization_type: 'paypal_invoice') }

.all_plaid_capturedActiveRecord::Relation<Payment>

A relation of Payments that are all plaid captured. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



233
# File 'app/models/payment.rb', line 233

scope :all_plaid_captured, -> { all_captured.where(authorization_type: 'plaid') }

.amazon_paymentsActiveRecord::Relation<Payment>

A relation of Payments that are amazon payments. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



219
# File 'app/models/payment.rb', line 219

scope :amazon_payments, -> { where(category: AMAZON_PAY) }

.bread_paymentsActiveRecord::Relation<Payment>

A relation of Payments that are bread payments. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



218
# File 'app/models/payment.rb', line 218

scope :bread_payments, -> { where(category: BREAD) }

.can_be_refundedActiveRecord::Relation<Payment>

A relation of Payments that are can be refunded. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



238
# File 'app/models/payment.rb', line 238

scope :can_be_refunded, -> { where(state: %w[captured partially_refunded]) }

.cc_paypal_bread_amazonActiveRecord::Relation<Payment>

A relation of Payments that are cc paypal bread amazon. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



230
# File 'app/models/payment.rb', line 230

scope :cc_paypal_bread_amazon, -> { where(authorization_type: %w[credit_card paypal bread, amazon_pay]) }

.check_paymentsActiveRecord::Relation<Payment>

A relation of Payments that are check payments. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



221
# File 'app/models/payment.rb', line 221

scope :check_payments, -> { where(category: CHECK) }

.credit_cardsActiveRecord::Relation<Payment>

A relation of Payments that are credit cards. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



225
# File 'app/models/payment.rb', line 225

scope :credit_cards, -> { where(category: CREDIT_CARD) }

.echeck_payment_review(amount, customer, order) ⇒ Hash{Symbol=>Boolean,Array<String>}

Decide whether an eCheck of amount for customer/order requires
manual approval. Enforces daily company-wide and per-customer caps,
the $10k upper bound, and routes through CustomerFinancials#request_credit
for credit-limit checks.

Parameters:

Returns:

  • (Hash{Symbol=>Boolean,Array<String>})


860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
# File 'app/models/payment.rb', line 860

def self.echeck_payment_review(amount, customer, order)
  res = {}
  fails = 0
  fail_reasons = []
  pass_reasons = []

  if Payment.where(category: ECHECK).all_authorized.where('payments.created_at > ?', Time.current.at_midnight).count > 5
    fails += 1
    fail_reasons << 'Limit exceeded for number of automatic eCheck approvals in one day, company wide (5).'
  end
  if Payment.joins(:order).where(category: ECHECK, orders: { customer_id: order.customer_id }).all_authorized.where('payments.created_at > ?',
                                                                                                                    Time.current.at_midnight).count > 2
    fails += 1
    fail_reasons << 'Limit exceeded for number of automatic eCheck approvals in one day, per customer (2).'
  end

  if amount < 100
    pass_reasons = ['Amount less than $100']
  else
    if amount > 10_000
      fails += 1
      fail_reasons << 'Amount greater than $10,000'
    end
    rc_res = customer.request_credit(amount)
    if rc_res[:approved] == false
      fails += rc_res[:fails]
      fail_reasons += rc_res[:fail_reasons]
    else
      pass_reasons = rc_res[:pass_reasons]
    end
  end
  if fails > 0
    res[:required] = true
    res[:fail_reasons] = fail_reasons
  else
    res[:required] = false
    res[:pass_reasons] = pass_reasons
  end
  res
end

.expiredActiveRecord::Relation<Payment>

A relation of Payments that are expired. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



227
# File 'app/models/payment.rb', line 227

scope :expired, -> { where(state: 'expired') }

.non_voidedActiveRecord::Relation<Payment>

A relation of Payments that are non voided. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



239
# File 'app/models/payment.rb', line 239

scope :non_voided, -> { where.not(state: %w[voided expired]) }

.orderOrder?

NOTE: this class-level shadow is highly suspect — it would call
itself recursively. Kept here because it is referenced from older
reports; treat as effectively dead until reviewed.

Returns:



1164
1165
1166
# File 'app/models/payment.rb', line 1164

def self.order
  legacy_order || order
end

.payment_options(customer, order = nil, currency = nil) ⇒ Array<String>

Categories the customer is allowed to pick from in the new-payment
form. Adds PO/VPO when the customer has terms, ADV_REPL when an RMA
has credit available, RMA_CREDIT for precreated-RMA orders, ECHECK
for USD orders, and STORE_CREDIT only when the customer actually has
an applicable CreditMemo.

Parameters:

  • customer (Customer)
  • order (Order, nil) (defaults to: nil)
  • currency (String, nil) (defaults to: nil)

    used to enable ECHECK without an order

Returns:

  • (Array<String>)

    sorted category labels



335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'app/models/payment.rb', line 335

def self.payment_options(customer, order = nil, currency = nil)
  payment_options = [CHECK, WIRE, CREDIT_CARD, PAYPAL, AMAZON_PAY, PAYPAL_INVOICE, CASH]
  if customer.has_terms?
    payment_options << PO
    # Use exists? instead of any? for better performance (single COUNT query vs loading records)
    payment_options << VPO if customer.contacts.verbal_po_contacts.exists?
  end
  payment_options << ADV_REPL if order && order.rma.present? && order.rma.credit_available > 0
  payment_options << RMA_CREDIT if order && order.precreate_rma?
  payment_options << ECHECK if (order && order.currency == 'USD') || currency == 'USD'
  # Use exists? and check store credit in single condition to avoid loading all credit memos
  payment_options << STORE_CREDIT if customer.available_store_credit.to_d > 0 && customer.credit_memos.available_to_apply.exists?
  payment_options.sort
end

.paypal_invoicesActiveRecord::Relation<Payment>

A relation of Payments that are paypal invoices. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



226
# File 'app/models/payment.rb', line 226

scope :paypal_invoices, -> { where(category: PAYPAL_INVOICE) }

.paypal_paymentsActiveRecord::Relation<Payment>

A relation of Payments that are paypal payments. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



223
# File 'app/models/payment.rb', line 223

scope :paypal_payments, -> { where(category: PAYPAL) }

.plaid_paymentsActiveRecord::Relation<Payment>

A relation of Payments that are plaid payments. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



220
# File 'app/models/payment.rb', line 220

scope :plaid_payments, -> { where(category: PLAID) }

.po_searchActiveRecord::Relation<Payment>

A relation of Payments that are po search. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



228
# File 'app/models/payment.rb', line 228

scope :po_search, ->(term) { where(Payment[:po_number].matches("%#{term}%")) }

.purchase_ordersActiveRecord::Relation<Payment>

A relation of Payments that are purchase orders. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



224
# File 'app/models/payment.rb', line 224

scope :purchase_orders, -> { where.not(order_id: nil).where(category: [PO, VPO]) }

.wire_paymentsActiveRecord::Relation<Payment>

A relation of Payments that are wire payments. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



222
# File 'app/models/payment.rb', line 222

scope :wire_payments, -> { where(category: WIRE) }

Instance Method Details

#accountAccount

Returns:

See Also:



166
# File 'app/models/payment.rb', line 166

belongs_to :account, optional: true

#all_pi_siblingsActiveRecord::Relation<Payment>

All other payments (any state) sharing this PI. Used when reconciling
external Stripe captures across split orders.

Returns:

  • (ActiveRecord::Relation<Payment>)


413
414
415
416
417
418
# File 'app/models/payment.rb', line 413

def all_pi_siblings
  return Payment.none unless stripe_payment_intent_id.present?

  Payment.where(stripe_payment_intent_id: stripe_payment_intent_id)
         .where.not(id: id)
end

#amount_captured_on_paypalFloat, false

Amount PayPal reports captured against this authorization (dollar
value, not cents). False for non-PayPal payments.

Returns:

  • (Float, false)


1152
1153
1154
1155
1156
1157
# File 'app/models/payment.rb', line 1152

def amount_captured_on_paypal
  return false if authorization_type != 'paypal'

  auth_details = Payment::Apis::Paypal.get_authorization_details(self).parse
  auth_details["amount"]["value"].to_f
end

#amount_captured_on_stripeFloat, false

Cents-or-dollars amount Stripe reports as captured on the upstream
PI/charge. Returns false for non-CC payments or on lookup failure
so callers can next unless.

Returns:

  • (Float, false)


1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
# File 'app/models/payment.rb', line 1127

def amount_captured_on_stripe
  return false if authorization_type != 'credit_card'

  obj = stripe_payment_object
  return false unless obj

  if Payment::Apis::Stripe.payment_intent?(authorization_code)
    obj.amount_received.to_f
  else
    obj.try(:amount_captured).to_f rescue false
  end
end

#attempt_paypal_reauthorization_if_needed(auth_expiration) ⇒ Payment::PaypalStatusResult

Decide whether to reauthorize this PayPal payment now. Returns
early with :still_valid when the auth or honor period still has
capture buffer; flips to expired and notifies AR/admin if past the
29-day hard limit; otherwise calls
Payment::Gateways::Paypal#reauthorize and surfaces the outcome.

Parameters:

  • auth_expiration (Time, nil)

    PayPal-reported expiry

Returns:



655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
# File 'app/models/payment.rb', line 655

def attempt_paypal_reauthorization_if_needed(auth_expiration)
  if auth_expiration.present? && auth_expiration > PAYPAL_REAUTH_BUFFER.from_now
    sync_capture_before_from_paypal_expiry(auth_expiration)
    hours_left = ((auth_expiration - Time.current) / 1.hour).round(1)
    return PaypalStatusResult.new(status: :still_valid, message: "PayPal authorization still valid (#{hours_left} hours remaining, expires #{auth_expiration.strftime('%b %d %H:%M %Z')})")
  end

  honor_deadline = capture_before || (created_at + PAYPAL_HONOR_PERIOD)
  if honor_deadline > PAYPAL_REAUTH_BUFFER.from_now
    hours_left = ((honor_deadline - Time.current) / 1.hour).round(1)
    return PaypalStatusResult.new(status: :still_valid, message: "Honor period still valid (#{hours_left} hours remaining, capture by #{honor_deadline.strftime('%b %d %H:%M %Z')})")
  end

  if created_at < PAYPAL_MAX_AUTH_DAYS.days.ago
    # No per-payment email — accounting picks this up via the nightly
    # Payment::DailyIssuesDigestWorker, which scans for PayPal
    # payments that transitioned to `expired` in the last 24 hours.
    logger.error("Payment #{id}: PayPal authorization past #{PAYPAL_MAX_AUTH_DAYS}-day limit, cannot reauthorize")
    order.cr_hold if order.present?
    payment_expired!
    return PaypalStatusResult.new(status: :expired, message: "Authorization past #{PAYPAL_MAX_AUTH_DAYS}-day limit")
  end

  days_since_auth = ((Time.current - created_at) / 1.day).floor
  if days_since_auth < PAYPAL_REAUTH_EARLIEST_DAY
    return PaypalStatusResult.new(status: :still_valid, message: "Too early to reauthorize (day #{days_since_auth} of #{PAYPAL_REAUTH_EARLIEST_DAY} minimum). Authorization is still capturable.")
  end

  res = Payment::Gateways::Paypal.new(self).reauthorize(amount)
  if res.success
    PaypalStatusResult.new(status: :reauthorized, message: 'Successfully reauthorized')
  else
    # No per-payment email — accounting picks this up via the nightly
    # Payment::DailyIssuesDigestWorker, which scans for
    # action='reauthorization' OrderTransactions with success=false
    # in the last 24 hours.
    logger.error("#{Time.current}: PAYPAL REAUTHORIZATION ERROR: Problem reauthorizing paypal payment.")
    PaypalStatusResult.new(status: :reauth_failed, message: res.message || 'Reauthorization failed')
  end
end

#auth_codeString?

Card-network auth code (the 6-digit code the issuer returned on
capture). Read from the first capture transaction's params.

Returns:

  • (String, nil)


1044
1045
1046
1047
# File 'app/models/payment.rb', line 1044

def auth_code
  capture = transactions.where(action: 'capture', success: true).first
  capture.nil? ? nil : capture&.params&.[]('auth_code')
end

#auth_urlString?

Deep link to the auth/PI on the upstream gateway dashboard
(PayPal, Stripe live, or Stripe test). Returns nil when there is
no auth yet.

Returns:

  • (String, nil)


1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
# File 'app/models/payment.rb', line 1386

def auth_url
  return nil if authorization_code.nil?

  if authorization_type == 'paypal'
    host = test? ? "www.sandbox.paypal.com" : "www.paypal.com"
    "https://#{host}/activity/payment/#{authorization_code}"
  elsif test?
    "https://dashboard.stripe.com/test/payments/#{authorization_code}"
  else
    "https://dashboard.stripe.com/payments/#{authorization_code}"
  end
end

#authorization_reviewHash{Symbol=>Boolean,Array<String>}

Decide whether this payment needs Accounting approval before being
treated as authorized. Returns a hash with :required and lists
of :fail_reasons/:pass_reasons strings used by the CRM review
UI.

Returns:

  • (Hash{Symbol=>Boolean,Array<String>})


495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
# File 'app/models/payment.rb', line 495

def authorization_review
  res = {}
  case category
  when PAYPAL_INVOICE, RMA_CREDIT
    res[:required] = true
    res[:fail_reasons] = ["#{category} payment requires Accounting approval"]
  when ECHECK
    # This branch gates *order release* on the order's total authorized
    # eChecks. A receipt-form eCheck (paying invoices directly) has no
    # order to release, so there is nothing to review — and dereferencing
    # `order` here raised `NoMethodError: undefined method 'payments' for
    # nil` when viewing /payments/:id/transactions (AppSignal, payment
    # 285034: orderless eCheck for customer 98660).
    if order
      echeck_total = order.payments.all_authorized.where(category: ECHECK).sum(:amount)
      echeck_res = Payment.echeck_payment_review(echeck_total, order.customer, order)
      res[:required] = echeck_res[:required]
      res[:fail_reasons] = echeck_res[:fail_reasons]
      res[:pass_reasons] = echeck_res[:pass_reasons]
    else
      res[:required] = false
    end
  when CHECK, WIRE
    res[:required] = true
    res[:fail_reasons] = ['All Check/Wire payments requires Accounting approval']
  when CASH
    if order.nil?
      # Orderless cash (receipt-form payment): no order release to gate.
      res[:required] = false
    elsif order.is_warehouse_pickup?
      if amount > 100
        # pickups where cash amount is greater than $100 require authorization
        res[:required] = true
        res[:fail_reasons] = ["Pickup order with #{category} payment more than $100 requires Accounting approval"]
      else
        res[:required] = false
        res[:pass_reasons] = ["Pickup order with #{category} payment more than $100 requires Accounting approval"]
      end
    else
      # if it's not a pickup, then cash always require authorization
      res[:required] = true
      res[:fail_reasons] = ["Non-pickup order with #{category} payment requires Accounting approval"]
    end
  else
    # any other payment method doesn't require authorization
    res[:required] = false
  end
  res
end

#authorization_review_required?Boolean

Returns:

  • (Boolean)


485
486
487
# File 'app/models/payment.rb', line 485

def authorization_review_required?
  CATEGORIES_REQUIRING_REVIEW.include?(category)
end

#automatically_authorized?Boolean

Returns:

  • (Boolean)


545
546
547
# File 'app/models/payment.rb', line 545

def automatically_authorized?
  authorization_review_required? and authorization_review[:required] == false
end

#available_to_refundBigDecimal, Float

Cash still available to refund: captured minus already refunded.

Returns:

  • (BigDecimal, Float)


1171
1172
1173
# File 'app/models/payment.rb', line 1171

def available_to_refund
  total_captured - total_refunded
end

#billing_addressAddress

Build a transient Address from the captured billing fields
(billing_address_* columns). Used for AVS displays and receipts.

Returns:



1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
# File 'app/models/payment.rb', line 1413

def billing_address
  Address.new(
    street1: billing_address_line1,
    street2: billing_address_line2,
    city: billing_address_city,
    zip: billing_address_zip,
    state_code: State.code_for_string(billing_address_state),
    country_iso3: Country.iso3_for_string(billing_address_country)
  )
end

#can_be_voided?Boolean

Returns:

  • (Boolean)


393
394
395
# File 'app/models/payment.rb', line 393

def can_be_voided?
  (authorized? and !order.editing_locked? and category != 'Credit Card Terminal')
end

#capture_deadlineActiveSupport::TimeWithZone?

Hard deadline by which the auth must be captured (or reauthorized);
delegates to the StrategyResolver for gateway-specific
rules.

Returns:

  • (ActiveSupport::TimeWithZone, nil)


389
390
391
# File 'app/models/payment.rb', line 389

def capture_deadline
  stripe_resolver.authorization_deadline
end

#captured_on_paypal?Boolean

Returns:

  • (Boolean)


1140
1141
1142
1143
1144
1145
1146
# File 'app/models/payment.rb', line 1140

def captured_on_paypal?
  return false if authorization_type != 'paypal'

  auth_details = Payment::Apis::Paypal.get_authorization_details(self).parse
  auth_status = auth_details['status']
  auth_status == 'CAPTURED'
end

#captured_on_stripe?Boolean

Returns:

  • (Boolean)


1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
# File 'app/models/payment.rb', line 1109

def captured_on_stripe?
  return false if authorization_type != 'credit_card'

  obj = stripe_payment_object
  return false unless obj

  if Payment::Apis::Stripe.payment_intent?(authorization_code)
    obj.status == 'succeeded'
  else
    obj.try(:captured?) rescue false
  end
end

#check_cc_payment_statusvoid

This method returns an undefined value.

Reconcile a Stripe credit-card payment with the upstream Payment
Intent: void on canceled, sync capture on succeeded, trigger
reauthorization when nearing the deadline, and fall back to legacy
charge handling when the object isn't a PI.



714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
# File 'app/models/payment.rb', line 714

def check_cc_payment_status
  return unless authorized?
  return unless authorization_type == 'credit_card'

  obj = stripe_payment_object
  return unless obj

  pi_status = Payment::Apis::Stripe.payment_intent?(authorization_code) ? obj.status : nil

  case pi_status
  when 'canceled'
    logger.info("Payment #{id}: PI #{stripe_payment_intent_id} canceled on Stripe, voiding")
    reason = obj.cancellation_reason == 'expired' ? 'Authorization expired on Stripe' : 'PI canceled on Stripe'
    transactions.create!(
      amount: 0,
      action: 'void',
      success: true,
      reference: stripe_payment_intent_id || authorization_code,
      message: "AUTO-VOID: #{reason} (status: canceled, reason: #{obj.cancellation_reason})",
      test: false
    )
    payment_voided!

  when 'succeeded'
    # PI was finalized — either captured or released externally
    sync_external_capture(obj)

  when 'requires_capture'
    # PI still open — check if approaching reauth deadline
    if stripe_resolver.needs_reauthorization?.reauth_needed
      Payment::Gateways::CreditCard.new(self).reauthorize
    end

  else
    # Legacy charge objects or unknown status — fall back to old behavior
    if obj.try(:captured?)
      transaction = OrderTransaction.new(
        amount: obj.try(:amount_captured).to_f,
        action: 'capture',
        success: true,
        reference: authorization_code,
        message: "THIS PAYMENT WAS MANUALLY CAPTURED ON THE PAYMENT PLATFORM",
        params: obj.to_hash,
        test: false
      )
      transactions.push(transaction)
      payment_captured!
    elsif stripe_resolver.needs_reauthorization?.reauth_needed
      Payment::Gateways::CreditCard.new(self).reauthorize
    end
  end
end

#check_paypal_invoice_payment_statusBoolean?

Check PayPal for whether a hosted invoice has been paid yet; if so
capture locally and (when every PayPal-invoice payment on the order
is captured) release the order.

Returns:

  • (Boolean, nil)

    true when paid/captured, false when still
    awaiting payment



818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
# File 'app/models/payment.rb', line 818

def check_paypal_invoice_payment_status
  if authorized?
    response = Payment::Apis::Paypal.get_invoice_details(authorization_code)
    res = JSON.parse(response.to_s)
    invoice_status = res['status']
    if invoice_status.present? && invoice_status == 'PAID'
      Payment::Gateways::PaypalInvoice.new(self).capture(res)
      order.reload
      order.release_order if order.payments.paypal_invoices.all?(&:captured?)
      true
    else
      # Paypal invoice has not been paid yet
      false
    end
  elsif captured?
    # If the payment is captured do nothing. We could verify that the receipt has been created, etc.
    true
  end
end

#check_paypal_payment_statusPayment::PaypalStatusResult

Re-poll PayPal for the current state of this authorization and
reconcile the local payment: capture externally, void, expire,
decline, or attempt a reauthorization, depending on what PayPal
reports. Used by the daily PayPal sync worker.



564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
# File 'app/models/payment.rb', line 564

def check_paypal_payment_status
  return PaypalStatusResult.new(status: :not_authorized, message: 'Payment is not in authorized state') unless authorized?

  response = Payment::Apis::Paypal.get_authorization(authorization_code)
  auth_status = response['status']
  auth_expiration = response['expiration_time']&.to_time

  sync_paypal_authorization_expiry(auth_expiration)

  case auth_status
  when 'CAPTURED'
    sync_paypal_external_capture(response)
    PaypalStatusResult.new(status: :captured, message: 'Payment was captured externally on PayPal')
  when 'VOIDED'
    logger.info("Payment #{id}: PayPal authorization voided externally")
    transactions.create!(
      amount: 0,
      action: 'void',
      success: true,
      reference: authorization_code,
      message: "AUTO-VOID: PayPal authorization voided externally",
      test: false
    )
    payment_voided!
    PaypalStatusResult.new(status: :voided, message: 'Authorization was voided on PayPal')
  when 'EXPIRED'
    logger.info("Payment #{id}: PayPal authorization expired")
    transactions.create!(
      amount: 0,
      action: 'void',
      success: true,
      reference: authorization_code,
      message: "AUTO-VOID: PayPal authorization expired",
      test: false
    )
    payment_expired!
    PaypalStatusResult.new(status: :expired, message: 'Authorization has expired')
  when 'DENIED'
    logger.info("Payment #{id}: PayPal authorization denied")
    transaction_declined!
    PaypalStatusResult.new(status: :denied, message: 'Authorization was denied')
  when 'CREATED', 'PENDING'
    attempt_paypal_reauthorization_if_needed(auth_expiration)
  else
    PaypalStatusResult.new(status: :unknown, message: "Unknown PayPal status: #{auth_status}")
  end
end

#communication_resourceOrder

Resource the audit/comm subsystem keys off — for payments, that's
the parent Order.

Returns:

See Also:

  • http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html ActiveRecord::Associations


323
# File 'app/models/payment.rb', line 323

belongs_to :order, optional: true

#credit_card_vaultCreditCardVault



164
# File 'app/models/payment.rb', line 164

belongs_to :credit_card_vault, primary_key: 'vault_id', foreign_key: 'vault_id', optional: true

#credit_memoCreditMemo

Returns:

See Also:



165
# File 'app/models/payment.rb', line 165

belongs_to :credit_memo, optional: true

CRM order URL for this payment, swallowing routing errors so audit
exports never blow up on a bad route.

Returns:

  • (String)


1003
1004
1005
1006
1007
# File 'app/models/payment.rb', line 1003

def crm_link
  UrlHelper.instance.order_path(order)
rescue StandardError
  ''
end

#currency_symbolString

Returns currency symbol used in CRM/invoice displays.

Returns:

  • (String)

    currency symbol used in CRM/invoice displays



1176
1177
1178
# File 'app/models/payment.rb', line 1176

def currency_symbol
  Money::Currency.new(currency).symbol
end

#customerCustomer

Returns:

See Also:



161
# File 'app/models/payment.rb', line 161

belongs_to :customer, optional: true

#deep_dupPayment

Clone the payment, including transactions/uploads via deep_clone,
but drop the Delivery association so the copy can be re-attached
on the target shipment without violating the unique pairing.

Returns:



317
318
319
# File 'app/models/payment.rb', line 317

def deep_dup
  deep_clone(except: :delivery_id)
end

#default_cc_options(ip_address = nil, email = nil) ⇒ Hash

ActiveMerchant-style options hash used when authorizing/capturing
against the card gateway from an order context. Includes order
metadata and the Stripe customer id when charging a stored vault
card.

Parameters:

  • ip_address (String, nil) (defaults to: nil)
  • email (String, nil) (defaults to: nil)

Returns:

  • (Hash)


1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
# File 'app/models/payment.rb', line 1337

def default_cc_options(ip_address = nil, email = nil)
  options = {
    description: (order.reference_number.present? ? "Order #{order.reference_number}" : "Order ID #{order.id}"),
    statement_description: "WarmlyYours #{order.reference_number}",
    currency:,
    shipping_address: order.shipping_address.format_for_payment_gateway(true),
    metadata: {
      email:,
      ip: ip_address,
      order_id: order.id,
      payment_id: id
    }
  }
  # only pass the customer as an option if we detect a vault_id, otherwise is will just charge the first card stored on the customer account
  options[:customer] = order.customer.stripe_customer_id if vault_id.present?
  options
end

#default_echeck_options(_ip_address = nil) ⇒ Hash

Forte-formatted options hash used when authorizing an eCheck.
_ip_address is retained for signature compatibility with
#default_cc_options.

Returns:

  • (Hash)


1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
# File 'app/models/payment.rb', line 1369

def default_echeck_options(_ip_address = nil)
  {
    # customer: order.customer_id,
    # ip: ip_address,
    # email: self.authorization.email,
    # shipping_address: order.shipping_address.format_for_payment_gateway(true),
    billing_address: order.billing_address.format_for_forte(true),
    order_id: order.reference_number
    # description: "Order #{order.reference_number}"
  }
end

#default_paypal_optionsHash

Minimal options hash for PayPal API calls (just currency for now).

Returns:

  • (Hash)


1358
1359
1360
1361
1362
# File 'app/models/payment.rb', line 1358

def default_paypal_options
  {
    currency: currency
  }
end

#deliveryDelivery

Returns:

See Also:



167
# File 'app/models/payment.rb', line 167

belongs_to :delivery, inverse_of: :payments, optional: true

#detect_fraud(force_new_report: false) ⇒ Order::FraudDetector::Result?

Run the fraud detector for credit card / PayPal / eCheck payments.
Skipped for non-eligible categories and SmartService orders.
Errors are reported to AppSignal but never raised, so authorize
transitions don't fail because the fraud report did.

Parameters:

  • force_new_report (Boolean) (defaults to: false)

    re-run even if a report exists

Returns:



1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
# File 'app/models/payment.rb', line 1016

def detect_fraud(force_new_report: false)
  return nil if order.nil?
  return nil unless category.in?(['Credit Card', 'PayPal', 'eCheck'])
  return nil if order.belongs_to_smartservice_group?

  begin
    Order::FraudDetector.new(order, self).process(force_new_report:)
  rescue StandardError => e
    ErrorReporting.error(e, "FraudDetector Error - Unable to process new FraudDetector request for Payment ID:#{id}")
    nil
  end
end

#does_not_allow_capture?Boolean

Returns:

  • (Boolean)


981
982
983
# File 'app/models/payment.rb', line 981

def does_not_allow_capture?
  category.in?(CATEGORIES_NOT_ALLOWING_CAPTURE)
end

#email_collection_for_selectArray<String>

Email options for the "send receipt to" select on the payment form:
every email on the customer plus the address captured on the
payment itself, deduped.

Returns:

  • (Array<String>)


1062
1063
1064
1065
1066
# File 'app/models/payment.rb', line 1062

def email_collection_for_select
  customer_for_collection = customer
  customer_for_collection ||= order&.customer
  [customer_for_collection&.all_emails, email].flatten.compact.uniq
end

#fraud_reportFraudReport



170
# File 'app/models/payment.rb', line 170

has_one :fraud_report

#full_card_numberString?

Reconstruct a masked PAN for display: BIN + xxx + last4 if both are
known, otherwise just the last 4 with a generic mask.

Returns:

  • (String, nil)


1072
1073
1074
1075
1076
1077
1078
# File 'app/models/payment.rb', line 1072

def full_card_number
  if issuer_number and last4
    "#{issuer_number}xxxxxx#{last4}"
  elsif last4
    "xxxxxxxxxxxx#{last4}"
  end
end

#full_nameString

Returns cardholder/payer full name from the captured
first_name/last_name pair.

Returns:

  • (String)

    cardholder/payer full name from the captured
    first_name/last_name pair



1182
1183
1184
# File 'app/models/payment.rb', line 1182

def full_name
  [first_name, last_name].compact.join(' ')
end

#full_name=(value) ⇒ Object

Split a free-form name string via PersonNameParser and assign
first_name/last_name accordingly.

Parameters:

  • value (String)


1190
1191
1192
1193
1194
# File 'app/models/payment.rb', line 1190

def full_name=(value)
  pnp = PersonNameParser.new(value)
  self.first_name = pnp.first
  self.last_name = pnp.last
end

#funds_fully_refunded?Boolean

Returns:

  • (Boolean)


1049
1050
1051
# File 'app/models/payment.rb', line 1049

def funds_fully_refunded?
  total_refunded == total_captured
end

#funds_partially_refunded?Boolean

Returns:

  • (Boolean)


1053
1054
1055
# File 'app/models/payment.rb', line 1053

def funds_partially_refunded?
  total_refunded.positive? and total_refunded < total_captured
end

#gateway_classClass

Strategy class used to authorize/capture/void this payment. Resolves
category to a constant under Gateways; falls back to
Payment::Gateways::Default when unmatched.

Returns:

  • (Class)


355
356
357
358
359
360
# File 'app/models/payment.rb', line 355

def gateway_class
  class_name = category.parameterize(separator: '_')
  # Map non-conventional class names
  class_name = { VPO: 'VerbalPurchaseOrder', PO: 'PurchaseOrder' }[class_name] || class_name
  "Payment::Gateways::#{class_name.classify}".safe_constantize || Payment::Gateways::Default
end

#invoiceInvoice

Returns:

See Also:



163
# File 'app/models/payment.rb', line 163

belongs_to :invoice, optional: true

#is_advanced_replacement?Boolean

Returns:

  • (Boolean)


909
910
911
# File 'app/models/payment.rb', line 909

def is_advanced_replacement?
  category == ADV_REPL
end

#is_amazon_pay?Boolean

Returns:

  • (Boolean)


921
922
923
# File 'app/models/payment.rb', line 921

def is_amazon_pay?
  category == AMAZON_PAY
end

#is_crm_legacy_vault?Boolean

Returns:

  • (Boolean)


1325
1326
1327
# File 'app/models/payment.rb', line 1325

def is_crm_legacy_vault?
  order&.order_reception_type == 'CRM' && (vault_id && credit_card_vault&.address_line1.blank?)
end

#is_plaid?Boolean

Returns:

  • (Boolean)


917
918
919
# File 'app/models/payment.rb', line 917

def is_plaid?
  category == PLAID
end

#is_po?Boolean

Returns:

  • (Boolean)


901
902
903
# File 'app/models/payment.rb', line 901

def is_po?
  category.in?([PO, VPO])
end

#is_receipt_skippable?Boolean

Returns:

  • (Boolean)


1321
1322
1323
# File 'app/models/payment.rb', line 1321

def is_receipt_skippable?
  authorization_type.in?(%w[paypal_invoice check]) and state == 'captured' and invoice.nil?
end

#is_rma_credit?Boolean

Returns:

  • (Boolean)


905
906
907
# File 'app/models/payment.rb', line 905

def is_rma_credit?
  category == RMA_CREDIT
end

#is_store_credit?Boolean

Returns:

  • (Boolean)


913
914
915
# File 'app/models/payment.rb', line 913

def is_store_credit?
  category == STORE_CREDIT
end

#is_www_apple_pay?Boolean

Returns:

  • (Boolean)


1286
1287
1288
# File 'app/models/payment.rb', line 1286

def is_www_apple_pay?
  order&.order_reception_type == 'Online' && transactions.any? { |t| t.params&.dig('source')&.dig('tokenization_method') == 'apple_pay' }
end

#last_authorization_messageString?

Returns message text on the most recent
OrderTransaction (typically the gateway's last response message).

Returns:

  • (String, nil)

    message text on the most recent
    OrderTransaction (typically the gateway's last response message)



987
988
989
# File 'app/models/payment.rb', line 987

def last_authorization_message
  transactions&.first&.message
end

#legacy_orderOrder

Returns:

See Also:



162
# File 'app/models/payment.rb', line 162

belongs_to :legacy_order, class_name: 'Order', foreign_key: 'order_id', optional: true

#orderOrder

Returns:

See Also:



159
# File 'app/models/payment.rb', line 159

belongs_to :order, optional: true

#paypal_over_capture_headroomBigDecimal

How much more is still capturable on this PayPal auth before
hitting #paypal_over_capture_limit, considering every active
sibling payment.

Returns:

  • (BigDecimal)

    non-negative



475
476
477
478
479
480
481
482
483
# File 'app/models/payment.rb', line 475

def paypal_over_capture_headroom
  limit = paypal_over_capture_limit
  return BigDecimal('0') unless limit

  all_committed = Payment.where(authorization_code: authorization_code, authorization_type: 'paypal')
                         .where.not(state: %w[voided declined expired])
                         .sum(:amount)
  [limit - all_committed, BigDecimal('0')].max
end

#paypal_over_capture_limitBigDecimal?

Maximum total that can be captured against this PayPal auth: the
smaller of 115% of the original total and original + $75. Returns
nil when the original total is unknown.

Returns:

  • (BigDecimal, nil)


463
464
465
466
467
468
# File 'app/models/payment.rb', line 463

def paypal_over_capture_limit
  auth_total = paypal_shared_auth_total
  return nil unless auth_total&.positive?

  [auth_total * PAYPAL_OVER_CAPTURE_PERCENT, auth_total + PAYPAL_OVER_CAPTURE_MAX_INCREASE].min
end

#paypal_shared_auth_totalBigDecimal?

Original total at the time the PayPal authorization was first
established (sum of every sibling's amount). Stored on
paypal_metadata because PayPal will not let you reauthorize for
more than 115% of the original total.

Returns:

  • (BigDecimal, nil)


452
453
454
455
456
# File 'app/models/payment.rb', line 452

def paypal_shared_auth_total
  BigDecimal(&.dig('shared_authorization_total').to_s)
rescue ArgumentError
  nil
end

#pending_release_authorization?Boolean

Returns:

  • (Boolean)


925
926
927
# File 'app/models/payment.rb', line 925

def pending_release_authorization?
  !payment_approved? && authorization_review[:required] == true
end

#po_uploadUpload?

First non-deleted upload tagged purchase_order — the customer's
PO PDF supporting a PO payment.

Returns:



995
996
997
# File 'app/models/payment.rb', line 995

def po_upload
  uploads.in_category('purchase_order').valid.first
end

#process_tx_results(tx) ⇒ Object

Copy card metadata, billing/shipping addresses, and Stripe Radar
signals from a successful Stripe OrderTransaction (or the linked
CreditCardVault) onto this payment so the CRM can show full
context without re-querying Stripe.

Parameters:

  • tx (OrderTransaction)

    the gateway response that produced this auth/capture



1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
# File 'app/models/payment.rb', line 1202

def process_tx_results(tx)
  if vault_id.present? and vault = CreditCardVault.find_by(vault_id:)
    self.issuer_number = vault.issuer_number
    self.card_type = vault.card_type
    self.name = vault.name
    self.exp_month = vault.exp_month
    self.exp_year = vault.exp_year
    self.reference = self.last4 = vault.number
    self.address_line1_check = vault.address_line1_check
    self.address_zip_check = vault.address_zip_check
    self.cvc_check = vault.cvc_check
    self.card_identifier = vault.vault_id
    self.card_country = nil
    self.billing_address_line1 = vault.address_line1
    self.billing_address_line2 = vault.address_line2
    self.billing_address_city = vault.address_city
    self.billing_address_state = vault.address_state
    self.billing_address_zip = vault.zip_code
    self.billing_address_country = vault.address_country
  end
  if (shipping_atts = order&.ship_to_attributes).present?
    shipping = shipping_atts[:address]
    self.shipping_address_name = shipping_atts[:attention_name] unless shipping_atts[:attention_name].blank? || shipping.is_placeholder
    unless shipping_atts[:name].blank? || shipping.is_placeholder || shipping_atts[:attention_name] == shipping_atts[:name]
      self.shipping_address_name = shipping_atts[:name]
    end
    self.shipping_address_line1 = shipping.street1
    self.shipping_address_line2 = shipping.street2
    self.shipping_address_city = shipping.city
    self.shipping_address_state = shipping.state&.name
    self.shipping_address_zip = shipping.zip
    self.shipping_address_country = shipping.country&.iso
  end
  if tx_source = tx.params['source']
    self.card_type = tx_source['brand']
    self.name = tx_source['name']
    self.exp_month = tx_source['exp_month']
    self.exp_year = tx_source['exp_year']
    self.reference = "....#{tx_source['last4']}"
    self.last4 = tx_source['last4']
    self.address_line1_check = tx_source['address_line1_check']
    self.address_zip_check = tx_source['address_zip_check']
    self.cvc_check = tx_source['cvc_check']
    self.card_identifier = tx_source['id']
    self.card_country = tx_source['country']
    self.billing_address_line1 = tx_source['address_line1']
    self.billing_address_line2 = tx_source['address_line2']
    self.billing_address_city = tx_source['address_city']
    self.billing_address_state = tx_source['address_state']
    self.billing_address_zip = tx_source['address_zip']
    self.billing_address_country = tx_source['address_country']
  end
  # Removing this after disconnecting David's active merchant branch on July 2020
  # if tx.params["shipping"]
  #   self.shipping_address_name = tx.params["shipping"]["name"]
  #   if tx_shipping = tx.params["shipping"]["address"]
  #     self.shipping_address_line1 = tx_shipping["line1"]
  #     self.shipping_address_line2 = tx_shipping["line2"]
  #     self.shipping_address_city = tx_shipping["city"]
  #     self.shipping_address_state = tx_shipping["state"]
  #     self.shipping_address_zip = tx_shipping["postal_code"]
  #     self.shipping_address_country = tx_shipping["country"]
  #   end
  # end
  if tx_outcome = tx.params['outcome']
    self.radar_network_status = tx_outcome['network_status']
    self.radar_reason = tx_outcome['reason']
    self.radar_risk_level = tx_outcome['risk_level']
    self.radar_seller_message = tx_outcome['seller_message']
    self.radar_type = tx_outcome['type']
  end
  return unless livemode = tx.params['livemode']

  self.test = livemode == false
end

#receiptsActiveRecord::Relation<Receipt>

Returns:

  • (ActiveRecord::Relation<Receipt>)

See Also:



174
# File 'app/models/payment.rb', line 174

has_many :receipts

#refunded_on_stripe?Boolean

Returns:

  • (Boolean)


1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
# File 'app/models/payment.rb', line 1096

def refunded_on_stripe?
  return false if authorization_type != 'credit_card'

  obj = stripe_payment_object
  return false unless obj

  if Payment::Apis::Stripe.payment_intent?(authorization_code)
    obj.status == 'canceled'
  else
    obj.try(:refunded?) rescue false
  end
end

#resend_paypal_invoiceHash{Symbol=>Object}

Re-send the PayPal invoice reminder email via the PayPal API.

Returns:

  • (Hash{Symbol=>Object})

    { success: Boolean, message: String? }



842
843
844
845
846
847
848
849
# File 'app/models/payment.rb', line 842

def resend_paypal_invoice
  response = Payment::Apis::Paypal.remind_invoice(authorization_code)
  if response['_http_success']
    { success: true }
  else
    { success: false, message: 'Something went wrong with Paypal reminder.' }
  end
end

#rmaRma

Returns:

See Also:



160
# File 'app/models/payment.rb', line 160

belongs_to :rma, optional: true

#send_authorization_email_notificationCommunication, String

Send the cardholder a "we authorized your card" email. Only fires
for CREDIT_CARD payments where send_authorization_email was opted
into. Returns a string when the message wasn't sent so the caller
can surface the reason.

Returns:



935
936
937
938
939
940
941
942
943
944
945
946
947
948
# File 'app/models/payment.rb', line 935

def send_authorization_email_notification
  if category == CREDIT_CARD and email.present? and send_authorization_email == true
    sender = order.customer.try(:primary_sales_rep)
    CommunicationBuilder.new(
      resource: self,
      sender_party: sender,
      sender: (sender.nil? ? INFO_EMAIL : nil),
      emails: email,
      bcc: (sender.nil? ? nil : sender.email)
    ).create
  else
    'Unable to send authorization email'
  end
end

#send_wire_info_emailCommunication, String

Send the customer the WIRE_TRANSFER_INFO email with bank details
they need to wire funds. Only fires for WIRE payments with an email
captured. Falls back to a string when the message can't be sent.

Returns:



955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
# File 'app/models/payment.rb', line 955

def send_wire_info_email
  if category == WIRE and email.present?
    sender = order.customer.try(:primary_sales_rep)
    comm = CommunicationBuilder.new(
      resource: order,
      sender_party: sender,
      sender: (sender.nil? ? INFO_EMAIL : nil),
      emails: email,
      merge_options: {
        order_reference: order.cart_identifier,
        order_total: amount,
        country_instructions: (order.catalog.id == 1 ? 'usa' : 'ca')
      },
      bcc: (sender.nil? ? nil : sender.email),
      template: EmailTemplate.find_by(system_code: 'WIRE_TRANSFER_INFO')
    ).create
  else
    'Unable to send wire info email'
  end
end

#shared_paypal_auth?Boolean

Returns:

  • (Boolean)


437
438
439
440
441
# File 'app/models/payment.rb', line 437

def shared_paypal_auth?
  authorization_type == 'paypal' && authorization_code.present? &&
    Payment.where(authorization_code: authorization_code, authorization_type: 'paypal')
           .where.not(id: id).exists?
end

#shared_paypal_auth_siblingsActiveRecord::Relation<Payment>

Other authorized PayPal payments sharing this PayPal authorization
id (PayPal supports up to 115% over-capture across siblings).

Returns:

  • (ActiveRecord::Relation<Payment>)


429
430
431
432
433
434
435
# File 'app/models/payment.rb', line 429

def shared_paypal_auth_siblings
  return Payment.none unless authorization_type == 'paypal' && authorization_code.present?

  Payment.where(authorization_code: authorization_code, authorization_type: 'paypal')
         .where.not(id: id)
         .where(state: 'authorized')
end

#shared_pi?Boolean

Returns:

  • (Boolean)


420
421
422
423
# File 'app/models/payment.rb', line 420

def shared_pi?
  stripe_payment_intent_id.present? &&
    Payment.where(stripe_payment_intent_id: stripe_payment_intent_id).where.not(id: id).exists?
end

#shared_pi_siblingsActiveRecord::Relation<Payment>

Other authorized payments that share this Stripe payment intent
(split orders / partial captures stack on one PI). Excludes self.

Returns:

  • (ActiveRecord::Relation<Payment>)


401
402
403
404
405
406
407
# File 'app/models/payment.rb', line 401

def shared_pi_siblings
  return Payment.none unless stripe_payment_intent_id.present?

  Payment.where(stripe_payment_intent_id: stripe_payment_intent_id)
         .where.not(id: id)
         .where(state: %w[authorized])
end

#shipping_addressAddress

Build a transient Address from the captured shipping fields. Used
to compare against the order's ship-to for fraud signals.

Returns:



1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
# File 'app/models/payment.rb', line 1428

def shipping_address
  Address.new(
    street1: shipping_address_line1,
    street2: shipping_address_line2,
    city: shipping_address_city,
    zip: shipping_address_zip,
    state_code: State.code_for_string(shipping_address_state),
    country_iso3: Country.iso3_for_string(shipping_address_country)
  )
end

#stripe_api_keyString

Stripe API key appropriate for this payment's currency (the codebase
routes USD through one Stripe account, CAD through another).

Returns:

  • (String)


1282
1283
1284
# File 'app/models/payment.rb', line 1282

def stripe_api_key
  Payment::Apis::Stripe.api_key(currency)
end

#stripe_payment_objectStripe::PaymentIntent, ... Also known as: stripe_charge

Fetch the upstream Stripe object (Charge or PaymentIntent) for this
payment. Returns false on missing auth or any Stripe API error so
callers can guard with a single unless.

Returns:

  • (Stripe::PaymentIntent, Stripe::Charge, false)


1085
1086
1087
1088
1089
1090
1091
# File 'app/models/payment.rb', line 1085

def stripe_payment_object
  return false if authorization_code.blank?

  Payment::Apis::Stripe.retrieve_payment_object(authorization_code, currency: currency)
rescue ::Stripe::StripeError
  false
end

#stripe_resolverPayment::StrategyResolver

Memoised Stripe capture/reauth strategy resolver — encapsulates the
capture window math (Stripe legacy 7d, extended 30d, PayPal 29d).



366
367
368
# File 'app/models/payment.rb', line 366

def stripe_resolver
  @stripe_resolver ||= Payment::StrategyResolver.new(self)
end

#supports_extended_authorization?Boolean

Returns:

  • (Boolean)


380
381
382
# File 'app/models/payment.rb', line 380

def supports_extended_authorization?
  stripe_resolver.supports_extended_authorization?
end

#supports_incremental_authorization?Boolean

Returns:

  • (Boolean)


376
377
378
# File 'app/models/payment.rb', line 376

def supports_incremental_authorization?
  stripe_resolver.supports_incremental_authorization?
end

#supports_multicapture?Boolean

Returns:

  • (Boolean)


370
371
372
373
374
# File 'app/models/payment.rb', line 370

def supports_multicapture?
  return true if authorization_type == 'paypal'

  stripe_resolver.supports_multicapture?
end

#sync_capture_before_from_paypal_expiry(auth_expiration) ⇒ Object

Push capture_before forward when PayPal hands back a later
expiration_time than we have stored — this keeps the local
capture-window cache in sync with PayPal's truth.

Parameters:

  • auth_expiration (Time)


701
702
703
704
705
706
# File 'app/models/payment.rb', line 701

def sync_capture_before_from_paypal_expiry(auth_expiration)
  effective_capture_before = auth_expiration - PAYPAL_HONOR_PERIOD
  if capture_before.nil? || capture_before < effective_capture_before
    update_column(:capture_before, effective_capture_before)
  end
end

#sync_external_capture(pi) ⇒ Object

Reflect a Stripe-side capture (or release) of a Payment Intent into
this Payment. Computes the share that hasn't been claimed by
sibling payments yet, mints a synthetic capture transaction for it,
and advances state — or voids the payment when the PI finalised
without funds for us.

Parameters:

  • pi (Stripe::PaymentIntent)


774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
# File 'app/models/payment.rb', line 774

def sync_external_capture(pi)
  pi_received_cents = pi.amount_received.to_i
  sibling_captured_cents = all_pi_siblings
    .where.not(state: %w[declined expired])
    .sum { |s| (s.total_captured * 100).to_i }

  unaccounted_cents = pi_received_cents - sibling_captured_cents - (total_captured * 100).to_i

  if unaccounted_cents.positive?
    capture_amount_cents = [unaccounted_cents, (amount * 100).to_i].min
    logger.info("Payment #{id}: PI finalized on Stripe, syncing external capture of #{capture_amount_cents} cents")
    transactions.create!(
      amount: capture_amount_cents,
      action: 'capture',
      success: true,
      reference: authorization_code,
      message: "THIS PAYMENT WAS CAPTURED EXTERNALLY ON STRIPE",
      params: pi.to_hash,
      test: false
    )
    payment_captured!
  else
    # PI finalized but no funds captured for this payment — remaining auth was released
    logger.info("Payment #{id}: PI finalized on Stripe with no capture for this payment, voiding")
    msg = total_captured.positive? ? "AUTO-VOID: PI finalized, remaining authorization released (partial capture existed)" :
                                     "AUTO-VOID: PI finalized with no capture for this payment, authorization released"
    transactions.create!(
      amount: 0,
      action: 'void',
      success: true,
      reference: stripe_payment_intent_id || authorization_code,
      message: msg,
      test: false
    )
    payment_voided!
  end
end

#sync_paypal_authorization_expiry(auth_expiration) ⇒ Object

Persist PayPal's authoritative authorization expiry into
paypal_metadata['authorization_expiry'] so callers don't have to
re-poll on every action.

Parameters:

  • auth_expiration (Time, nil)


617
618
619
620
621
622
623
# File 'app/models/payment.rb', line 617

def sync_paypal_authorization_expiry(auth_expiration)
  return unless auth_expiration.present?

   =  || {}
  ['authorization_expiry'] = auth_expiration.iso8601
  update_column(:paypal_metadata, )
end

#sync_paypal_external_capture(auth_response) ⇒ Object

Record a capture that happened on the PayPal dashboard rather than
through Heatwave: append a synthetic capture OrderTransaction and
advance the state to captured.

Parameters:

  • auth_response (Hash)

    parsed PayPal auth-details body



630
631
632
633
634
635
636
637
638
639
640
641
642
643
# File 'app/models/payment.rb', line 630

def sync_paypal_external_capture(auth_response)
  captured_amount_dollars = auth_response.dig("amount", "value").to_f
  transaction = OrderTransaction.new(
    amount: (captured_amount_dollars * 100).to_i,
    action: 'capture',
    success: true,
    reference: auth_response['id'] || authorization_code,
    message: "THIS PAYMENT WAS MANUALLY CAPTURED ON THE PAYMENT PLATFORM",
    params: auth_response.except('_http_status', '_http_success', '_raw_body'),
    test: false
  )
  transactions.push(transaction)
  payment_captured!
end

#to_sString

Returns "Payment 1234 (ORD-555)".

Returns:

  • (String)

    "Payment 1234 (ORD-555)"



977
978
979
# File 'app/models/payment.rb', line 977

def to_s
  "Payment #{id} (#{order&.reference_number})"
end

#total_authorizedFloat

Live authorization total in dollars. Prefers the latest
incremental_authorization transaction (Stripe replaces the prior
auth amount on each step-up); otherwise sums the original auths.

Returns:

  • (Float)


1295
1296
1297
1298
1299
1300
1301
1302
# File 'app/models/payment.rb', line 1295

def total_authorized
  latest_increment = transactions.select { |t| t.success && t.action == 'incremental_authorization' }.max_by(&:created_at)
  if latest_increment
    (latest_increment.amount.to_f / 100).round(2)
  else
    transactions.select { |t| t.success && t.action.in?(%w[authorization authorize]) }.sum { |t| t.amount.to_f / 100 }.to_f.round(2)
  end
end

#total_capturedFloat

Sum of all successful capture/purchase/settle transactions in
dollars (gateway records cents).

Returns:

  • (Float)


1308
1309
1310
1311
1312
# File 'app/models/payment.rb', line 1308

def total_captured
  transactions.select do |t|
    t.success and (t.action == 'capture' or t.action == 'purchase' or t.action == 'settle')
  end.sum { |t| t.amount.to_f / 100 }.to_f.round(2)
end

#total_refundedFloat

Sum of all successful refund transactions in dollars.

Returns:

  • (Float)


1317
1318
1319
# File 'app/models/payment.rb', line 1317

def total_refunded
  transactions.select { |t| t.success and t.action == 'refund' }.sum { |t| t.amount.to_f / 100 }.to_f.round(2)
end

#transaction_reference(action) ⇒ String?

Reference returned by the gateway for the first successful
transaction of action (e.g. authorization, capture, refund).

Parameters:

  • action (String)

Returns:

  • (String, nil)


1034
1035
1036
1037
1038
# File 'app/models/payment.rb', line 1034

def transaction_reference(action)
  return unless payment = transactions.where(action:).where(success: true).order(:id).first

  payment.reference
end

#transactionsActiveRecord::Relation<OrderTransaction>

Returns:

See Also:



172
# File 'app/models/payment.rb', line 172

has_many :transactions, class_name: 'OrderTransaction', dependent: :destroy

#update_authorization_code(action) ⇒ Object

Refresh authorization_code from the latest successful
transaction of the given action (e.g. 'authorization',
'reauthorization').

Parameters:

  • action (String)


1404
1405
1406
1407
# File 'app/models/payment.rb', line 1404

def update_authorization_code(action)
  update(authorization_code: transaction_reference(action))
  payment.update(amount:)
end

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



173
# File 'app/models/payment.rb', line 173

has_many :uploads, as: :resource, dependent: :destroy

#vpo_contactContact

Returns:

See Also:



168
# File 'app/models/payment.rb', line 168

belongs_to :vpo_contact, class_name: 'Contact', optional: true