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 category.

'Advance Replacement'
CHECK =

Check category.

'Check'
CREDIT_CARD =

Credit Card category.

'Credit Card'
CREDIT_CARD_TERMINAL =

Credit Card Terminal category.

'Credit Card Terminal'
BREAD =

Bread category.

'Bread'
PLAID =

Plaid category.

'Plaid'
AMAZON_PAY =

Amazon Pay category.

'Amazon Pay'
PO =

Purchase Order category.

'Purchase Order'
VPO =

Verbal Purchase Order category.

'Verbal Purchase Order'
ECHECK =

eCheck category.

'eCheck'
PAYPAL =

PayPal category.

'PayPal'
PAYPAL_INVOICE =

PayPal Invoice category.

'PayPal Invoice'
RMA_CREDIT =

RMA Credit category.

'RMA Credit'
CASH =

Cash category.

'Cash'
STORE_CREDIT =

Store Credit category.

'Store Credit'
WIRE =

Wire Transfer category.

'Wire Transfer'
ACCOUNT_HOLDER_TYPES =

Bank account holder types for eCheck payments.

%w[personal business]
ACCOUNT_TYPES =

Bank account types for eCheck payments.

%w[checking savings]
PAYPAL_MIN_SIGNATURE_REQUIRED =

Minimum PayPal amount requiring a signature.

750.00
ECHECK_MIN_AMOUNT_WITHOUT_SUPERVISION =

Maximum eCheck amount processable without supervision.

2000.00
CATEGORIES_REQUIRING_REVIEW =

Categories whose payments need manual review.

[PAYPAL_INVOICE, RMA_CREDIT, ECHECK, CHECK, CASH, WIRE]
CATEGORIES_NOT_ALLOWING_CAPTURE =

Categories that cannot be captured through the gateway.

[PO, VPO, ECHECK, WIRE]
PAYPAL_OVER_CAPTURE_PERCENT =

PayPal allows captures up to 115% of the original authorization total.

BigDecimal('1.15')
PAYPAL_OVER_CAPTURE_MAX_INCREASE =

PayPal over-capture is additionally capped at $75 above the original total.

BigDecimal('75')
PAYPAL_HONOR_PERIOD =

How long PayPal honors an authorization after capture_before.

3.days
PAYPAL_REAUTH_BUFFER =

Minimum time left before expiry required to skip reauthorization.

12.hours
PAYPAL_MAX_AUTH_DAYS =

Hard PayPal limit: an authorization cannot live past 29 days.

29
PAYPAL_REAUTH_EARLIEST_DAY =

PayPal reauthorization is only attempted from this day onward after the
original authorization.

4

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Belongs to collapse

Has one collapse

Has many collapse

Instance Attribute Summary 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 Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#accountAccount?

Returns the GL account for this payment.

Returns:

  • (Account, nil)

    the GL account for this payment



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

belongs_to :account, optional: true

#account_holder_typeString?

Returns bank account holder type, required for non-vaulted checks.

Returns:

  • (String, nil)

    bank account holder type, required for non-vaulted checks



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

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.



286
287
288
# File 'app/models/payment.rb', line 286

def 
  @account_number
end

#account_typeString?

Returns bank account type, required for non-vaulted checks.

Returns:

  • (String, nil)

    bank account type, required for non-vaulted checks



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

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.



286
287
288
# File 'app/models/payment.rb', line 286

def address_city
  @address_city
end

#address_countryObject

Returns the value of attribute address_country.



286
287
288
# File 'app/models/payment.rb', line 286

def address_country
  @address_country
end

#address_idObject

Returns the value of attribute address_id.



286
287
288
# File 'app/models/payment.rb', line 286

def address_id
  @address_id
end

#address_line1Object

Returns the value of attribute address_line1.



286
287
288
# File 'app/models/payment.rb', line 286

def address_line1
  @address_line1
end

#address_line2Object

Returns the value of attribute address_line2.



286
287
288
# File 'app/models/payment.rb', line 286

def address_line2
  @address_line2
end

#address_stateObject

Returns the value of attribute address_state.



286
287
288
# File 'app/models/payment.rb', line 286

def address_state
  @address_state
end

#address_zipObject

Returns the value of attribute address_zip.



286
287
288
# File 'app/models/payment.rb', line 286

def address_zip
  @address_zip
end

#amountBigDecimal

Returns the payment amount.

Returns:

  • (BigDecimal)

    the payment amount

Validations:



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

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

#amount_to_captureObject

Returns the value of attribute amount_to_capture.



286
287
288
# File 'app/models/payment.rb', line 286

def amount_to_capture
  @amount_to_capture
end

#bread_tokenObject

Returns the value of attribute bread_token.



286
287
288
# File 'app/models/payment.rb', line 286

def bread_token
  @bread_token
end

#card_tokenObject

Returns the value of attribute card_token.



286
287
288
# File 'app/models/payment.rb', line 286

def card_token
  @card_token
end

#categoryString

Returns the payment category (one of the category constants).

Returns:

  • (String)

    the payment category (one of the category constants)

Validations:

  • Inclusion ({ in: [CHECK, WIRE, CREDIT_CARD, PO, VPO, ADV_REPL, ECHECK, AMAZON_PAY, PAYPAL, PAYPAL_INVOICE, RMA_CREDIT, CASH, STORE_CREDIT, BREAD, PLAID] })
  • Exclusion ({ in: [PO, VPO], on: :create, unless: :customer_has_terms?, message: 'is invalid as billing customer does not have terms' })


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

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

Returns the value of attribute consent_channel.



286
287
288
# File 'app/models/payment.rb', line 286

def consent_channel
  @consent_channel
end

#credit_card_vaultCreditCardVault?

Returns the vaulted card/check used.

Returns:



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

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

#credit_memoCreditMemo?

Returns the credit memo backing this payment.

Returns:

  • (CreditMemo, nil)

    the credit memo backing this payment



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

belongs_to :credit_memo, optional: true

#currencyString

Returns ISO currency code.

Returns:

  • (String)

    ISO currency code



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

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

#customerCustomer?

Returns the paying customer.

Returns:

  • (Customer, nil)

    the paying customer



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

belongs_to :customer, optional: true

#deliveryDelivery?

Returns the delivery this payment is attached to.

Returns:

  • (Delivery, nil)

    the delivery this payment is attached to



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

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

#emailString?

Returns receipt email address.

Returns:

  • (String, nil)

    receipt email address



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

validates :email, email_format: true

#error_codesObject

Returns the value of attribute error_codes.



286
287
288
# File 'app/models/payment.rb', line 286

def error_codes
  @error_codes
end

#fraud_reportFraudReport?

Returns the fraud screening report for this payment.

Returns:

  • (FraudReport, nil)

    the fraud screening report for this payment



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

has_one :fraud_report

#invoiceInvoice?

Returns the invoice this payment settles.

Returns:

  • (Invoice, nil)

    the invoice this payment settles



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

belongs_to :invoice, optional: true

#issuer_numberObject

Returns the value of attribute issuer_number.



286
287
288
# File 'app/models/payment.rb', line 286

def issuer_number
  @issuer_number
end

#last_responseObject

Returns the value of attribute last_response.



286
287
288
# File 'app/models/payment.rb', line 286

def last_response
  @last_response
end

#legacy_orderOrder?

Returns legacy alias for order.

Returns:

  • (Order, nil)

    legacy alias for order



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

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

#orderOrder?

Returns the order this payment applies to.

Returns:

  • (Order, nil)

    the order this payment applies to



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

belongs_to :order, optional: true

#paypal_emailString?

Returns required for PayPal invoice payments.

Returns:

  • (String, nil)

    required for PayPal invoice payments



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

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

#po_numberString?

Returns required for purchase orders.

Returns:

  • (String, nil)

    required for purchase orders



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

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

#receiptsActiveRecord::Associations::CollectionProxy<Receipt>

Returns receipts issued for this payment.

Returns:

  • (ActiveRecord::Associations::CollectionProxy<Receipt>)

    receipts issued for this payment



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

has_many :receipts

#rmaRma?

Returns the RMA this payment is linked to.

Returns:

  • (Rma, nil)

    the RMA this payment is linked to



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

belongs_to :rma, optional: true

#rma_idInteger?

Returns required for advance replacements.

Returns:

  • (Integer, nil)

    required for advance replacements



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

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

#stateString

Returns the payment state machine state.

Returns:

  • (String)

    the payment state machine state



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

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

#store_addressObject

Returns the value of attribute store_address.



286
287
288
# File 'app/models/payment.rb', line 286

def store_address
  @store_address
end

#store_cardObject

Returns the value of attribute store_card.



286
287
288
# File 'app/models/payment.rb', line 286

def store_card
  @store_card
end

#store_card_nameObject

Returns the value of attribute store_card_name.



286
287
288
# File 'app/models/payment.rb', line 286

def store_card_name
  @store_card_name
end

#transactionsActiveRecord::Associations::CollectionProxy<OrderTransaction>

Returns gateway transactions for this payment.

Returns:

  • (ActiveRecord::Associations::CollectionProxy<OrderTransaction>)

    gateway transactions for this payment



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

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

#uploadsActiveRecord::Associations::CollectionProxy<Upload>

Returns files attached to this payment.

Returns:

  • (ActiveRecord::Associations::CollectionProxy<Upload>)

    files attached to this payment



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

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

#vpo_contactContact?

Returns the contact who gave the verbal PO.

Returns:

  • (Contact, nil)

    the contact who gave the verbal PO



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

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

#vpo_contact_idInteger?

Returns required for verbal purchase orders.

Returns:

  • (Integer, nil)

    required for verbal purchase orders



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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 Models::CustomerFinancials#request_credit
for credit-limit checks.

Parameters:

Returns:

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


1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
# File 'app/models/payment.rb', line 1011

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:



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

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:



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

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:



1328
1329
1330
# File 'app/models/payment.rb', line 1328

def self.order
  legacy_order || order
end

.partially_capturedActiveRecord::Relation<Payment>

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

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



317
318
319
320
321
322
323
# File 'app/models/payment.rb', line 317

scope :partially_captured, -> {
  all_authorized.where(
    OrderTransaction.where(action: %w[capture purchase settle], success: true)
                    .where(OrderTransaction.arel_table[:payment_id].eq(arel_table[:id]))
                    .arel.exists
  )
}

.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



421
422
423
424
425
426
427
428
429
430
431
432
433
434
# File 'app/models/payment.rb', line 421

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:



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

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:



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

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:



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

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:



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

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:



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

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:



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

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

Instance Method Details

#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>)


503
504
505
506
507
508
# File 'app/models/payment.rb', line 503

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)


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

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)


1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
# File 'app/models/payment.rb', line 1290

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:



806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
# File 'app/models/payment.rb', line 806

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)


1203
1204
1205
1206
# File 'app/models/payment.rb', line 1203

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)


1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
# File 'app/models/payment.rb', line 1554

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>})


634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
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
# File 'app/models/payment.rb', line 634

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 true when the category requires Accounting review.

Returns:

  • (Boolean)

    true when the category requires Accounting review



624
625
626
# File 'app/models/payment.rb', line 624

def authorization_review_required?
  CATEGORIES_REQUIRING_REVIEW.include?(category)
end

#automatically_authorized?Boolean

Returns true when review applies but no approval is actually required.

Returns:

  • (Boolean)

    true when review applies but no approval is actually required



685
686
687
# File 'app/models/payment.rb', line 685

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)


1335
1336
1337
# File 'app/models/payment.rb', line 1335

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:



1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
# File 'app/models/payment.rb', line 1581

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 true when an authorized payment may still be voided.

Returns:

  • (Boolean)

    true when an authorized payment may still be voided



483
484
485
# File 'app/models/payment.rb', line 483

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)


478
479
480
# File 'app/models/payment.rb', line 478

def capture_deadline
  stripe_resolver.authorization_deadline
end

#captured_on_paypal?Boolean

Returns true when PayPal shows the authorization as CAPTURED.

Returns:

  • (Boolean)

    true when PayPal shows the authorization as CAPTURED



1304
1305
1306
1307
1308
1309
1310
# File 'app/models/payment.rb', line 1304

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 true when Stripe shows the payment captured/succeeded.

Returns:

  • (Boolean)

    true when Stripe shows the payment captured/succeeded



1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
# File 'app/models/payment.rb', line 1272

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.



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
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
# File 'app/models/payment.rb', line 865

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



969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
# File 'app/models/payment.rb', line 969

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.



713
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
# File 'app/models/payment.rb', line 713

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

#coherent_with_order?Boolean

True when this payment can still be safely settled against its order:
same currency AND the authorized amount still covers the order balance.
A catalog swap that re-prices lines (see CatalogItem::Remapper /
Catalog::SwitchItemizableCatalog) or a currency flip leaves an earlier
authorization "stale" — capturing it would charge the wrong currency or
under-collect. UI/ops use this to force a re-authorization. Regression
guard for SO727885 (2026-07).

Returns:

  • (Boolean)


1616
1617
1618
1619
1620
# File 'app/models/payment.rb', line 1616

def coherent_with_order?
  return true if order.nil?

  matches_order_currency? && amount.present? && amount >= order.balance(excluding_payment: self)
end

#communication_resourceOrder?

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

Returns:

  • (Order, nil)

    the order this payment applies to



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

belongs_to :order, optional: true

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

Returns:

  • (String)


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

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



1340
1341
1342
# File 'app/models/payment.rb', line 1340

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

#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:



403
404
405
# File 'app/models/payment.rb', line 403

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)


1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
# File 'app/models/payment.rb', line 1504

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.

Parameters:

  • _ip_address (String, nil) (defaults to: nil)

    unused; signature parity with default_cc_options

Returns:

  • (Hash)


1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
# File 'app/models/payment.rb', line 1537

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)


1525
1526
1527
1528
1529
# File 'app/models/payment.rb', line 1525

def default_paypal_options
  {
    currency: currency
  }
end

#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:



1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
# File 'app/models/payment.rb', line 1175

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 true for categories that cannot be gateway-captured.

Returns:

  • (Boolean)

    true for categories that cannot be gateway-captured



1140
1141
1142
# File 'app/models/payment.rb', line 1140

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>)


1223
1224
1225
1226
1227
# File 'app/models/payment.rb', line 1223

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

#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)


1233
1234
1235
1236
1237
1238
1239
# File 'app/models/payment.rb', line 1233

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



1346
1347
1348
# File 'app/models/payment.rb', line 1346

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)


1354
1355
1356
1357
1358
# File 'app/models/payment.rb', line 1354

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

#funds_fully_refunded?Boolean

Returns true when everything captured has been refunded.

Returns:

  • (Boolean)

    true when everything captured has been refunded



1209
1210
1211
# File 'app/models/payment.rb', line 1209

def funds_fully_refunded?
  total_refunded == total_captured
end

#funds_partially_refunded?Boolean

Returns true when some but not all captured funds were refunded.

Returns:

  • (Boolean)

    true when some but not all captured funds were refunded



1214
1215
1216
# File 'app/models/payment.rb', line 1214

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)


441
442
443
444
445
446
# File 'app/models/payment.rb', line 441

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

#is_advanced_replacement?Boolean

Returns true for advance replacement payments.

Returns:

  • (Boolean)

    true for advance replacement payments



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

def is_advanced_replacement?
  category == ADV_REPL
end

#is_amazon_pay?Boolean

Returns true for Amazon Pay payments.

Returns:

  • (Boolean)

    true for Amazon Pay payments



1078
1079
1080
# File 'app/models/payment.rb', line 1078

def is_amazon_pay?
  category == AMAZON_PAY
end

#is_crm_legacy_vault?Boolean

Returns true for CRM orders using a legacy vault entry without an address.

Returns:

  • (Boolean)

    true for CRM orders using a legacy vault entry without an address



1492
1493
1494
# File 'app/models/payment.rb', line 1492

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

#is_plaid?Boolean

Returns true for Plaid payments.

Returns:

  • (Boolean)

    true for Plaid payments



1073
1074
1075
# File 'app/models/payment.rb', line 1073

def is_plaid?
  category == PLAID
end

#is_po?Boolean

Returns true for purchase order / verbal purchase order payments.

Returns:

  • (Boolean)

    true for purchase order / verbal purchase order payments



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

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

#is_receipt_skippable?Boolean

Returns true when no receipt needs to be sent for this payment.

Returns:

  • (Boolean)

    true when no receipt needs to be sent for this payment



1487
1488
1489
# File 'app/models/payment.rb', line 1487

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

#is_rma_credit?Boolean

Returns true for RMA credit payments.

Returns:

  • (Boolean)

    true for RMA credit payments



1058
1059
1060
# File 'app/models/payment.rb', line 1058

def is_rma_credit?
  category == RMA_CREDIT
end

#is_store_credit?Boolean

Returns true for store credit payments.

Returns:

  • (Boolean)

    true for store credit payments



1068
1069
1070
# File 'app/models/payment.rb', line 1068

def is_store_credit?
  category == STORE_CREDIT
end

#is_www_apple_pay?Boolean

Returns true when the order was paid online via Apple Pay.

Returns:

  • (Boolean)

    true when the order was paid online via Apple Pay



1451
1452
1453
# File 'app/models/payment.rb', line 1451

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)



1146
1147
1148
# File 'app/models/payment.rb', line 1146

def last_authorization_message
  transactions&.first&.message
end

#live_pi_paymentsArray<Payment>

Live (authorized/captured) payments making up the shared authorization
on this PI, including self — the real slices, excluding any
split-superseded original. Falls back to just self when not a shared PI.

Memoized: the transactions view resolves this several times per render
(directly and via #shared_pi_authorized_total); without the cache each
call re-runs the sibling query.

Returns:



542
543
544
545
546
547
548
# File 'app/models/payment.rb', line 542

def live_pi_payments
  @live_pi_payments ||= if shared_pi?
                          ([self] + all_pi_siblings.to_a).select { |p| p.state.in?(%w[authorized captured]) }
                        else
                          [self]
                        end
end

#matches_order_currency?Boolean

Strict: an unknown (blank) currency on either side is NOT a match, so
coherent_with_order? never green-lights settling a payment whose currency
can't be verified. A payment with no order is trivially consistent.

Returns:

  • (Boolean)

    whether the payment currency equals the order currency



1627
1628
1629
1630
1631
# File 'app/models/payment.rb', line 1627

def matches_order_currency?
  return true if order.nil?

  currency.present? && order.currency.present? && currency == order.currency
end

#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



613
614
615
616
617
618
619
620
621
# File 'app/models/payment.rb', line 613

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)


601
602
603
604
605
606
# File 'app/models/payment.rb', line 601

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)


590
591
592
593
594
# File 'app/models/payment.rb', line 590

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

#pending_release_authorization?Boolean

Returns true when authorization review is required but not yet approved.

Returns:

  • (Boolean)

    true when authorization review is required but not yet approved



1083
1084
1085
# File 'app/models/payment.rb', line 1083

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:



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

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



1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
# File 'app/models/payment.rb', line 1366

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

#refunded_on_stripe?Boolean

Returns true when Stripe shows the payment refunded/canceled.

Returns:

  • (Boolean)

    true when Stripe shows the payment refunded/canceled



1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
# File 'app/models/payment.rb', line 1258

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? }



993
994
995
996
997
998
999
1000
# File 'app/models/payment.rb', line 993

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

#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:



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

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:



1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
# File 'app/models/payment.rb', line 1113

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 true when other payments share this PayPal authorization.

Returns:

  • (Boolean)

    true when other payments share this PayPal authorization



573
574
575
576
577
# File 'app/models/payment.rb', line 573

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>)


564
565
566
567
568
569
570
# File 'app/models/payment.rb', line 564

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 true when other payments share this Stripe PaymentIntent.

Returns:

  • (Boolean)

    true when other payments share this Stripe PaymentIntent



511
512
513
514
# File 'app/models/payment.rb', line 511

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_authorized_totalFloat

PI-wide authorized total: the sum of the live slice amounts across the
shared PI — the true "shared authorization" figure. Unlike
#total_authorized (this payment's own slice only), this is stable no
matter which sibling you view it from.

Returns:

  • (Float)


556
557
558
# File 'app/models/payment.rb', line 556

def shared_pi_authorized_total
  live_pi_payments.sum { |p| p.amount.to_f }.round(2)
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>)


491
492
493
494
495
496
497
# File 'app/models/payment.rb', line 491

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:



1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
# File 'app/models/payment.rb', line 1596

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)


1446
1447
1448
# File 'app/models/payment.rb', line 1446

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)


1246
1247
1248
1249
1250
1251
1252
# File 'app/models/payment.rb', line 1246

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).



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

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

#superseded_by_split?Boolean

True when this payment's voided state is a bookkeeping hand-off from
an order split — its authorization was redistributed onto sibling
payments on the same still-live Stripe PaymentIntent — rather than a
real gateway void/cancel. The split path soft-voids the original with a
bare update_all(state: 'voided') (no Stripe call, no void
transaction) while the money stays authorized on the shared PI, so the
bare "Voided" badge misreads as a cancelled/released charge.

Returns:

  • (Boolean)


525
526
527
528
529
530
531
# File 'app/models/payment.rb', line 525

def superseded_by_split?
  return false unless state == 'voided'
  return false if stripe_payment_intent_id.blank?
  return false if transactions.any? { |t| t.success && t.action == 'void' } # a real gateway void

  all_pi_siblings.where(state: %w[authorized captured]).exists?
end

#supports_extended_authorization?Boolean

Returns true when the gateway supports extended authorization windows.

Returns:

  • (Boolean)

    true when the gateway supports extended authorization windows



469
470
471
# File 'app/models/payment.rb', line 469

def supports_extended_authorization?
  stripe_resolver.supports_extended_authorization?
end

#supports_incremental_authorization?Boolean

Returns true when the gateway supports incremental authorization.

Returns:

  • (Boolean)

    true when the gateway supports incremental authorization



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

def supports_incremental_authorization?
  stripe_resolver.supports_incremental_authorization?
end

#supports_multicapture?Boolean

Returns true when the gateway supports multiple captures per authorization.

Returns:

  • (Boolean)

    true when the gateway supports multiple captures per authorization



457
458
459
460
461
# File 'app/models/payment.rb', line 457

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)


852
853
854
855
856
857
# File 'app/models/payment.rb', line 852

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)


925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
# File 'app/models/payment.rb', line 925

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)


766
767
768
769
770
771
772
# File 'app/models/payment.rb', line 766

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



779
780
781
782
783
784
785
786
787
788
789
790
791
792
# File 'app/models/payment.rb', line 779

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)"



1135
1136
1137
# File 'app/models/payment.rb', line 1135

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)


1460
1461
1462
1463
1464
1465
1466
1467
# File 'app/models/payment.rb', line 1460

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)


1473
1474
1475
1476
1477
# File 'app/models/payment.rb', line 1473

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)


1482
1483
1484
# File 'app/models/payment.rb', line 1482

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)


1193
1194
1195
1196
1197
# File 'app/models/payment.rb', line 1193

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

  payment.reference
end

#update_authorization_code(action) ⇒ Object

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

Parameters:

  • action (String)


1572
1573
1574
1575
# File 'app/models/payment.rb', line 1572

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