Class: Invoice

Overview

Invoice model representing a financial document for goods or services.
Handles billing, payments, line items, and various invoice types including
sales orders, credit memos, and consignment invoices.

Defined Under Namespace

Classes: CaptureFundsHandler, TaxjarSubmissionHandler

Constant Summary collapse

SO =

Sales Order — invoice generated when a customer Order's Delivery ships.

'SO'
ST =

Sales Tax — placeholder type used for tax-only adjustment invoices.

'ST'
MI =

Miscellaneous Invoice — manually entered, not tied to an order.

'MI'
MO =

Manual Order — legacy manual invoice type, predates Order entry workflow.

'MO'
TO =

Trade Order — internal trade between companies in the JDE multi-company ledger.

'TO'
CI =

Consignment Invoice — issued when consignment stock is invoiced to the consignee.

'CI'
SS =

Service / Smart Service — invoice for installation or smart-service labour, not goods.

'SS'
INVOICE_TYPES =

All recognised JDE invoice-type codes accepted by validators and dropdowns.

[SO, ST, MI, MO, TO, CI, SS].freeze
REFERENCE_NUMBER_PATTERN =

Regex matching the canonical INV… reference-number format (case-insensitive).

/^INV\d+$/i
LINE_ITEM_CATEGORIES =

Mapping from CRM-facing line-item category labels to the JDE GL account
they post against. Drives the "Add line item" dropdown on miscellaneous
(MI) and consignment (CI) invoices and the GL split when transmitted.

[{ name: 'Coupon (Goods)', account_number: COUPONS_ACCOUNT },
{ name: 'Coupon (Freight)', account_number: FREIGHT_COUPONS_ACCOUNT },
{ name: 'Freight', account_number: FREIGHT_ACCOUNT },
{ name: 'Misc', account_number: PRODUCT_SALES_ACCOUNT },
{ name: 'Item', account_number: PRODUCT_SALES_ACCOUNT },
{ name: 'Fee', account_number: nil }].freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Attributes included from Models::Profitable

#min_profit_markup

Attributes included from Models::Itemizable

#account_specialist, #coupons, #discounts, #force_total_reset, #local_sales_rep, #primary_sales_rep, #secondary_sales_rep, #total_reset

Belongs to collapse

Methods included from Models::TaxableResource

#resource_tax_rate

Methods included from Models::Auditable

#creator, #updater

Has many collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::AccountingDocumentTransmittable

#can_be_transmitted?, #fallback_notification_channel_type, #notification_channel_sort_order, #notification_channel_types, #notification_channels, #own_notification_channel_type, #post_communication_exception_hook, #post_communication_sent_hook, #primary_transmission_contact, #primary_transmission_contact_point_id, #transmission_contact_points

Methods included from Models::TaxjarSubmittable

#customer_sync_instance, #delete_from_taxjar, #evaluate_taxjar_submission, #record_already_exists_on_taxjar?, #resubmit_to_taxjar, #should_be_submitted_to_taxjar?, #should_sync_customer_with_taxjar?, #submit_to_taxjar, #sync_customer_with_taxjar, #taxjar_customer_id, #taxjar_submission_instance

Methods included from Models::Profitable

#default_sales_markup, #profit_margins_met?, #profitable_line_items, #profitable_status, #profitable_total_discounted, #profitable_total_estimated_cost, #profitable_total_estimated_line_cost, #profitable_total_profit, #profitable_total_profit_margin, #profitable_total_profit_markup, #track_profit?, #validate_min_profit_markup?

Methods included from Models::TaxableResource

#apply_tax_rate_to_line_items, #build_tax_params, #calculate_tax_for_all_lines, #copy_tax_rate, #effective_date, #get_rates_for_line, #get_tax_rate, #manual_rate_goods, #manual_rate_services, #manual_rate_shipping, #origin_address, #refresh_tax_rate, #resource_not_taxable?, #set_initial_tax_rate, #should_refresh_tax_rate?, #state_code, #state_code_sym, #taxes_grouped_by_rate, #taxes_grouped_by_type

Methods included from Models::Itemizable

#add_line_item, #additional_items, #assign_sequence, #breakdown_of_prices, #calculate_actual_insured_value, #calculate_discounts, #calculate_shipping_cost, #coupon_search, #customer_applied_coupons, #customer_can_apply_coupon?, #discounts_changed?, #discounts_grouped_by_coupon, #discounts_subtotal, #effective_discount, #effective_shipping_discount, #has_kits?, #has_kits_or_serial_numbers?, #has_serial_numbers?, #is_credit_order?, #line_items_requiring_serial_number, #line_items_with_counters, #line_total_plus_tax, #main_rep, #perform_db_total, #purge_empty_quoting_deliveries, #purge_shipping_when_no_other_lines, #remove_line_item, #require_total_reset?, #reset_discount, #set_for_recalc, #set_signature_confirmation_on_shipping_address_change, #set_totals, #shipping_conditions_changed?, #shipping_discounted, #shipping_method_changed?, #should_recalculate_shipping?, #smartinstall_data, #smartsupport_data, #subtotal_cogs, #sync_shipping_line, #total_cogs

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods included from Models::Notable

#quick_note

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

#allow_duplicate_delivery_for_testingObject

Returns the value of attribute allow_duplicate_delivery_for_testing.



174
175
176
# File 'app/models/invoice.rb', line 174

def allow_duplicate_delivery_for_testing
  @allow_duplicate_delivery_for_testing
end

#billing_address_idObject (readonly)

Validates presence of the core invoicing fields.

Validations:



246
# File 'app/models/invoice.rb', line 246

validates :due_date, :terms, :invoice_type, :billing_address_id, :customer_id, :document_date, :gl_date, :gl_offset_account_id, presence: true

#customer_idObject (readonly)

Validates presence of the core invoicing fields.

Validations:



246
# File 'app/models/invoice.rb', line 246

validates :due_date, :terms, :invoice_type, :billing_address_id, :customer_id, :document_date, :gl_date, :gl_offset_account_id, presence: true

#delivery_idObject (readonly)

Validates a delivery is invoiced at most once (unless explicitly allowed for testing).

Validations (unless => #allow_duplicate_delivery_for_testing? ):



256
# File 'app/models/invoice.rb', line 256

validates :delivery_id, uniqueness: { allow_nil: true }, unless: :allow_duplicate_delivery_for_testing?

#disable_auto_couponObject

Returns the value of attribute disable_auto_coupon.



174
175
176
# File 'app/models/invoice.rb', line 174

def disable_auto_coupon
  @disable_auto_coupon
end

#do_not_detect_shippingObject

Returns the value of attribute do_not_detect_shipping.



174
175
176
# File 'app/models/invoice.rb', line 174

def do_not_detect_shipping
  @do_not_detect_shipping
end

#do_not_set_totalsObject

Returns the value of attribute do_not_set_totals.



174
175
176
# File 'app/models/invoice.rb', line 174

def do_not_set_totals
  @do_not_set_totals
end

#document_dateObject (readonly)

Validates presence of the core invoicing fields.

Validations:



246
# File 'app/models/invoice.rb', line 246

validates :due_date, :terms, :invoice_type, :billing_address_id, :customer_id, :document_date, :gl_date, :gl_offset_account_id, presence: true

#due_dateObject (readonly)

Validates presence of the core invoicing fields.

Validations:



246
# File 'app/models/invoice.rb', line 246

validates :due_date, :terms, :invoice_type, :billing_address_id, :customer_id, :document_date, :gl_date, :gl_offset_account_id, presence: true

#enter_new_addressObject

Returns the value of attribute enter_new_address.



174
175
176
# File 'app/models/invoice.rb', line 174

def enter_new_address
  @enter_new_address
end

#gl_dateObject (readonly)

Validates presence of the core invoicing fields.

Validations:



246
# File 'app/models/invoice.rb', line 246

validates :due_date, :terms, :invoice_type, :billing_address_id, :customer_id, :document_date, :gl_date, :gl_offset_account_id, presence: true

#gl_offset_account_idObject (readonly)

Validates presence of the core invoicing fields.

Validations:



246
# File 'app/models/invoice.rb', line 246

validates :due_date, :terms, :invoice_type, :billing_address_id, :customer_id, :document_date, :gl_date, :gl_offset_account_id, presence: true

#gl_offset_account_refObject

Returns the value of attribute gl_offset_account_ref.



174
175
176
# File 'app/models/invoice.rb', line 174

def 
  @gl_offset_account_ref
end

#invoice_typeObject (readonly)

Validates presence of the core invoicing fields.

Validations:



246
# File 'app/models/invoice.rb', line 246

validates :due_date, :terms, :invoice_type, :billing_address_id, :customer_id, :document_date, :gl_date, :gl_offset_account_id, presence: true

#order_idObject (readonly)

Validates an order is present for non-misc/non-counter invoice types.

Validations:

  • Presence ({ if: proc { |i| [MI, CI].exclude?(i.invoice_type) } })
  • Numericality ({ allow_nil: true })


248
# File 'app/models/invoice.rb', line 248

validates :order_id, presence: { if: proc { |i| [MI, CI].exclude?(i.invoice_type) } }

#original_order_refObject

Returns the value of attribute original_order_ref.



174
175
176
# File 'app/models/invoice.rb', line 174

def original_order_ref
  @original_order_ref
end

#skip_initial_state_checkObject

Flag to bypass the initial state check (use only if you have a legitimate reason to create
an invoice in a non-draft state, which should be extremely rare)



295
296
297
# File 'app/models/invoice.rb', line 295

def skip_initial_state_check
  @skip_initial_state_check
end

#skip_line_item_integrity_checkObject

Flag to skip integrity check during initial creation (set by CreateInvoiceFromDelivery)



291
292
293
# File 'app/models/invoice.rb', line 291

def skip_line_item_integrity_check
  @skip_line_item_integrity_check
end

#skip_pdfObject

Returns the value of attribute skip_pdf.



174
175
176
# File 'app/models/invoice.rb', line 174

def skip_pdf
  @skip_pdf
end

#store_idObject (readonly)

Validates a store is present for misc/counter invoice types.

Validations:

  • Presence ({ if: proc { |i| [MI, CI].include?(i.invoice_type) } })
  • Presence ({ if: proc { |i| [MI, CI].include?(i.invoice_type) } })


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

validates :store_id, presence: { if: proc { |i| [MI, CI].include?(i.invoice_type) } }

#tax_dateObject (readonly)

Validates tax date and store are present for misc/counter invoice types.

Validations:

  • Presence ({ if: proc { |i| [MI, CI].include?(i.invoice_type) } })


252
# File 'app/models/invoice.rb', line 252

validates :tax_date, :store_id, presence: { if: proc { |i| [MI, CI].include?(i.invoice_type) } }

#termsObject (readonly)

Validates presence of the core invoicing fields.

Validations:



246
# File 'app/models/invoice.rb', line 246

validates :due_date, :terms, :invoice_type, :billing_address_id, :customer_id, :document_date, :gl_date, :gl_offset_account_id, presence: true

Class Method Details

.awaiting_transmissionActiveRecord::Relation<Invoice>

A relation of Invoices that are awaiting transmission. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



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

scope :awaiting_transmission, -> { where(state: %w[unpaid paid], transmission_state: %w[awaiting_transmission in_transmission_queue]) }

.calculate_due_date(order, delivery) ⇒ Date

Computes the invoice due date as delivery.shipped_date + billing_entity.terms_in_days. Called once at invoice creation;
the result is persisted onto due_date.

Parameters:

Returns:

  • (Date)


607
608
609
610
# File 'app/models/invoice.rb', line 607

def self.calculate_due_date(order, delivery)
  shipped_date = delivery.shipped_date.to_datetime.to_date
  shipped_date + order.billing_entity.terms_in_days.days
end

.calculate_terms(order) ⇒ String

Resolves the textual payment-terms string from an Order, appending
(COD) when the order is COD-funded so it prints on the PDF.

Parameters:

Returns:

  • (String)


634
635
636
637
638
# File 'app/models/invoice.rb', line 634

def self.calculate_terms(order)
  terms = order.billing_entity.terms
  terms += ' (COD)' if order.funded_by_cod?
  terms
end

.included_in_notificationsActiveRecord::Relation<Invoice>

A relation of Invoices that are included in notifications. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



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

scope :included_in_notifications, -> { where(exclude_fund_capture_notification: false) }

.like_lookupActiveRecord::Relation<Invoice>

A relation of Invoices that are like lookup. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



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

scope :like_lookup, ->(q) { left_joins(:order).where(Invoice[:reference_number].matches("%#{q}%")).or(Order.where(Order[:reference_number].matches("%#{q}%"))) }

.lookupActiveRecord::Relation<Invoice>

A relation of Invoices that are lookup. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



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

scope :lookup, ->(q) { where(reference_number: q) }

.missing_edi_810ActiveRecord::Relation<Invoice>

A relation of Invoices that are missing edi 810. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



312
313
314
315
316
# File 'app/models/invoice.rb', line 312

scope :missing_edi_810, -> {
  joins(customer: :notification_channels)
    .where(notification_channels: { notification_type: NotificationChannel::INVOICES, transmission_type: NotificationChannel::EDI })
    .where(transmission_state: 'awaiting_transmission')
}

.overdueActiveRecord::Relation<Invoice>

A relation of Invoices that are overdue. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



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

scope :overdue, -> { unpaid.where(due_date: ...Date.current) }

.sales_ordersActiveRecord::Relation<Invoice>

A relation of Invoices that are sales orders. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



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

scope :sales_orders, -> { where(invoice_type: 'SO') }

.unpaidActiveRecord::Relation<Invoice>

A relation of Invoices that are unpaid. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Invoice>)

See Also:



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

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

Instance Method Details

#activitiesActiveRecord::Relation<Activity>

Activity log entries for this invoice.

Returns:

See Also:



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

has_many   :activities, as: :resource, dependent: :nullify, inverse_of: :resource

#alert_unless_prepaid_balance_reconciles(invoice_total) ⇒ void

This method returns an undefined value.

Everything on this SO invoice was captured before invoicing started —
routine for split orders, where an earlier delivery's capture precedes
this delivery's invoice (INV012607425 / PI pi_3TwiaK, 2026-07-28: one PI
multicaptured across two deliveries, both sides exact). Asking AR to
"check all payments are correctly captured" fired ~5x a week for
reconciliations the system can verify itself, so only email when the
receipts genuinely don't account for the invoice total.

Parameters:

  • invoice_total (BigDecimal)

    total being reconciled against



839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
# File 'app/models/invoice.rb', line 839

def alert_unless_prepaid_balance_reconciles(invoice_total)
  attributed_total = attributed_captured_total
  unaccounted = (invoice_total - attributed_total).round(2)
  if unaccounted.zero?
    logger.info("#{Time.current}: Invoice #{id}: balance already paid before invoicing; receipts attribute #{attributed_total} — reconciled, no alert")
    return
  end

  currency_sym = Money::Currency.new(currency).symbol
  Mailer.generic_mailer(
    from: ADMINISTRATOR_EMAIL,
    to: "#{ADMINISTRATOR_EMAIL},#{ACCOUNTS_RECEIVABLE_EMAIL}",
    subject: "BALANCE ALREADY PAID FOR INVOICE ID ##{id}",
    message: "Invoice ##{reference_number} (ID ##{id}) was fully covered by payments captured before invoicing, " \
             "but its receipts don't account for the whole total.\n\n" \
             "Invoice total: #{currency_sym}#{'%.2f' % invoice_total}\n" \
             "Attributed by receipts: #{currency_sym}#{'%.2f' % attributed_total}\n" \
             "Unaccounted: #{currency_sym}#{'%.2f' % unaccounted}\n\n" \
             "Customer: #{customer&.full_name} (ID: #{customer_id})\n" \
             "CRM link: #{crm_link}\n\n" \
             'Check that every payment on this order is captured and applied to the right invoice.',
    no_verbage: true
  ).deliver
  logger.warn("#{Time.current}: Invoice #{id}: balance already paid but receipts attribute #{attributed_total} of #{invoice_total} (unaccounted #{unaccounted})")
end

#allow_duplicate_delivery_for_testing?Boolean

Whether duplicate deliveries are allowed (test-only escape hatch).

Returns:

  • (Boolean)


260
261
262
# File 'app/models/invoice.rb', line 260

def allow_duplicate_delivery_for_testing?
  allow_duplicate_delivery_for_testing == true
end

#amount_dueBigDecimal

Liquid-template alias for #total; kept distinct from #balance so
the public pay-link template can show the original amount even after
partial payments.

Returns:

  • (BigDecimal)


583
584
585
# File 'app/models/invoice.rb', line 583

def amount_due
  total
end

#attributed_captured_totalFloat

Captured money attributable to THIS invoice through receipts: what each
captured payment's receipts applied here, plus any captured money its
receipts do NOT explain (unapplied remainders and captures never
receipted) — unexplained money is exactly what a real overcharge looks
like (payment 288279 / invoice 275115: $478.90 sat unapplied on a
partially_applied receipt). A payment legitimately split across sibling
invoices attributes only this invoice's share, so split applications no
longer read as overpayment.

Returns:

  • (Float)


816
817
818
819
820
821
822
823
824
825
826
827
# File 'app/models/invoice.rb', line 816

def attributed_captured_total
  candidates = payments.all_captured.or(payments.partially_captured)
  candidates.sum do |payment|
    receipt_ids = payment.receipts.where.not(state: 'voided').select(:id)
    details = ReceiptDetail.non_voided.where(receipt_id: receipt_ids)
    detail_amounts = details.pluck(:invoice_id, :amount)
    applied_here = detail_amounts.sum { |invoice_id, amount| invoice_id == id ? amount.to_f : 0.0 }
    applied_anywhere = detail_amounts.sum { |_invoice_id, amount| amount.to_f }
    unexplained = [(payment.total_captured - payment.total_refunded) - applied_anywhere, 0].max
    applied_here + unexplained
  end.round(2)
end

#attributed_opportunitiesActiveRecord::Relation<AttributedOpportunity>

Opportunities attributed through #outlet_purchases.

Returns:

  • (ActiveRecord::Relation<AttributedOpportunity>)

See Also:



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

has_many   :attributed_opportunities, through: :outlet_purchases, source: :opportunity

#balanceBigDecimal

Outstanding balance — invoice total minus the sum of applied
ReceiptDetails (cash receipts, write-offs, applied discounts).
Drives balance_is_zero? / balance_positive? and the AR aging report.

Returns:

  • (BigDecimal)


550
551
552
# File 'app/models/invoice.rb', line 550

def balance
  total - receipts_total
end

#balance_is_zero?Boolean

Whether the invoice balance has been fully settled.

Returns:

  • (Boolean)


541
542
543
# File 'app/models/invoice.rb', line 541

def balance_is_zero?
  balance.zero?
end

#balance_positive?Object

Alias for Balance#positive?

Returns:

  • (Object)

    Balance#balance_positive?

See Also:



327
# File 'app/models/invoice.rb', line 327

delegate :positive?, to: :balance, prefix: true, allow_nil: true

#billing_addressAddress

Address the invoice is billed to.

Returns:

See Also:



179
# File 'app/models/invoice.rb', line 179

belongs_to :billing_address, class_name: 'Address', optional: true, inverse_of: :billing_invoices

#billing_customerCustomer

Customer responsible for payment, when different from #customer.

Returns:

See Also:



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

belongs_to :billing_customer, class_name: 'Customer', optional: true, inverse_of: :invoices

#billing_entityParty

The Party (Customer or sub-account) who is actually being billed,
which can differ from #customer when a buying-group or parent
account is on the billing address.

Returns:



1169
1170
1171
# File 'app/models/invoice.rb', line 1169

def billing_entity
  billing_address.party
end

#build_activityActivity

Builds (but does not save) a new Activity attached to this invoice
with the customer party pre-populated, mirroring the CRM activity-form
helper used by Order and Quote.

Returns:



440
441
442
# File 'app/models/invoice.rb', line 440

def build_activity
  activities.build resource: self, party: primary_party
end

#business_unitBusinessUnit

Business unit the invoice is attributed to.



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

belongs_to :business_unit, optional: true, inverse_of: :invoices

#buying_groupBuyingGroup

Buying group the invoice is attributed to.



191
# File 'app/models/invoice.rb', line 191

belongs_to :buying_group, optional: true, inverse_of: :invoices

#calculate_all_cogsBigDecimal

Cost-of-goods total computed in SQL across every Item-category
LineItem, ignoring tax-class filtering. Used by the BoB / margin
report. Casts to BigDecimal to avoid float drift downstream.

Returns:

  • (BigDecimal)


460
461
462
# File 'app/models/invoice.rb', line 460

def calculate_all_cogs
  BigDecimal(line_items.where(cm_category: 'Item').sum('unit_cogs * quantity'))
end

#calculate_cogs(tax_class = %w[g svc shp])) ⇒ BigDecimal

Cost-of-goods total for this invoice restricted to the supplied tax
classes — defaults to goods (g), services (svc) and shipping (shp).
Walks the in-memory Item-category LineItems so unsaved edits
are reflected; pair with #calculate_all_cogs for the full-set sum.

Parameters:

  • tax_class (Array<String>) (defaults to: %w[g svc shp]))

    tax-class codes to include

Returns:

  • (BigDecimal)


451
452
453
# File 'app/models/invoice.rb', line 451

def calculate_cogs(tax_class = %w[g svc shp])
  line_items.where(cm_category: 'Item').select { |li| tax_class.include?(li.calculated_tax_class) }.sum { |li| li.unit_cogs * li.quantity }
end

#capture_funds?Boolean

Captures outstanding authorized payments for this invoice; logs progress.

Returns:

  • (Boolean)

    whether funds still needed capturing after reconciliation



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
917
918
919
920
921
922
923
924
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
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
# File 'app/models/invoice.rb', line 867

def capture_funds?
  logger.info("#{Time.current}: Capturing funds for invoice id: #{id}, ref: #{reference_number}, delivery id: #{delivery_id}")
  copy_payments unless delivery.nil?
  invoice_total = total
  # State-captured payments count by amount (kept equal to captured funds
  # by the final-capture-short path); partially captured ones are still
  # `authorized`, so their real captured money must be summed from their
  # capture transactions or the invoice looks unpaid and gets re-captured.
  captured_balance = payments.all_captured.sum(:amount) +
                     payments.partially_captured.sum(&:total_captured)
  pending_balance = invoice_total - captured_balance
  payments_total = payments.all_authorized.sum(:amount)
  check_total = payments.all_check_captured.sum(:amount)
  applied_store_credit_total = delivery.nil? ? 0 : delivery.payments.where(state: 'authorized', category: Payment::STORE_CREDIT, currency: currency).sum(:amount)
  unapplied_credit_memos = billing_customer.credit_memos.available_to_apply.order(:document_date)

  logger.info("#{Time.current}: Invoice total: #{invoice_total}")
  logger.info("#{Time.current}: Payments not captured available: #{payments_total}")
  logger.info("#{Time.current}: Checks already captured but receipt needed: #{check_total}")

  capture_problem = false
  if pending_balance <= 0
    create_receipts_for_captured_payments
    paid! if can_paid?

    if pending_balance.negative?
      # A payment-level negative balance is only a REAL overpayment when the
      # receipt-level attribution agrees — a payment legitimately split
      # across sibling invoices (one receipt, two invoice details) carries
      # its full amount into captured_balance here but only this invoice's
      # share in attributed_captured_total (false alarm: INV012607350 /
      # payment 288251, 2026-07-24 — $724.68 was applied to the sibling).
      attributed_total = attributed_captured_total
      overpaid_amount = (attributed_total - invoice_total).round(2)
      if overpaid_amount.positive?
        currency_sym = Money::Currency.new(currency).symbol
        Mailer.generic_mailer(
          from: ADMINISTRATOR_EMAIL,
          to: "#{ADMINISTRATOR_EMAIL},#{ACCOUNTS_RECEIVABLE_EMAIL}",
          subject: "ORDER OVERPAYMENT — Invoice ##{reference_number} (Order ##{order&.reference_number})",
          message: "Invoice ##{reference_number} (Order ##{order&.reference_number}) has been overpaid by #{currency_sym}#{'%.2f' % overpaid_amount}.\n\n" \
                   "Invoice total: #{currency_sym}#{'%.2f' % invoice_total}\n" \
                   "Total captured: #{currency_sym}#{'%.2f' % captured_balance}\n" \
                   "Overpaid amount: #{currency_sym}#{'%.2f' % overpaid_amount}\n\n" \
                   "Customer: #{customer&.full_name} (ID: #{customer_id})\n" \
                   "CRM link: #{crm_link}\n\n" \
                   "A manual refund or credit memo needs to be issued for the excess amount.",
          no_verbage: true
        ).deliver
        logger.warn("#{Time.current}: OVERPAYMENT on invoice #{id}: attributed #{attributed_total} exceeds total #{invoice_total} by #{overpaid_amount}")
      else
        logger.info("#{Time.current}: Invoice #{id}: payment-level captured #{captured_balance} exceeds total #{invoice_total}, but receipts attribute only #{attributed_total} here (split application) — no overpayment")
      end
    elsif invoice_type == 'SO'
      alert_unless_prepaid_balance_reconciles(invoice_total)
    end
  else
    # WE SHOULD ALWAYS USE PAYMENT.AMOUNT IN THE RECEIPTS CREATED SINCE THE PAYMENT HAS ALRADY BEEN CAPTURED. THERE IS NO POINT IN USING
    # THE BALANCE IF THAT NUMBER IS DIFFERENT THAN THE ALREADY CAPTURED PAYMENT
    # SO WE SHOULD USE A METHOD LIKE create_receipts_for_captured_payments

    # First let's create the receipts for the payments that have been captured already.
    create_receipts_for_captured_payments

    # Second let's add receipts for any store credit used
    if applied_store_credit_total.positive?
      unapplied_credit_memos.each do |cm|
        next if pending_balance.zero?

        cm_balance = cm.balance * -1
        amount = [pending_balance, cm_balance].min
        new_receipt = Receipt.new(company: company,
                                  customer: customer,
                                  category: 'Non-Cash',
                                  amount: 0,
                                  reference: cm.reference_number,
                                  currency: currency,
                                  gl_date: Date.current,
                                  receipt_date: Date.current)
        new_receipt.receipt_details << ReceiptDetail.new(category: 'Invoice', invoice: self, amount: amount, gl_date: Date.current)
        new_receipt.receipt_details << ReceiptDetail.new(category: 'Credit Memo', credit_memo: cm, amount: amount * -1, gl_date: Date.current)
        begin
          new_receipt.save!
          logger.info("#{Time.current}: Created new receipt id: #{new_receipt.id}")
          pending_balance -= amount
        rescue StandardError => e
          msg = "#{Time.current}: Unable to create new receipt for Credit Memo ID: #{cm.id} (store credit), Exception: #{e}"
          logger.error(msg)
          ErrorReporting.error(e, credit_memo_id: cm.id)
          capture_problem = true
        end
      end
    end

    # Finally let's capture the remaining balance from existing authorizations
    payments.all_authorized.cc_paypal_bread_amazon.each do |payment|
      next if pending_balance.zero? # If the balance is already zero then we don't need to capture more

      # Cap at the payment's uncaptured remainder — a partially captured
      # (multicapture) payment is still `authorized`, and requesting its
      # full amount would exceed what Stripe allows and re-request money
      # already captured.
      amount = [pending_balance, payment.amount - payment.total_captured].min
      next unless amount.positive?
      res = payment.gateway_class.new(payment).capture(amount, { order_id: reference_number, currency: payment.currency })
      if res.success
        pending_balance -= amount
        capture_problem = true if payment.receipts.empty?
      else
        capture_problem = true
      end
    end
  end

  # The rest of payment methods cannot be captured through a gateway and need manual processing.
  # For example, POs or VPOs. We generate the invoice PDF for those and then accounting has a special
  # report to find the unpaid invoices and apply a voucher, credit memo, or any other payment type to
  # mark the invoice as paid

  # Check if capture_problem is due to legitimate issues or just manual-processing payment types
  # Don't flag PO/VPO/ECHECK/WIRE payments as problems since they require manual intervention by design
  if capture_problem
    authorized_without_receipts = payments.all_authorized.select { |p| p.receipts.empty? }
    only_manual_payments = authorized_without_receipts.all? { |p| Payment::CATEGORIES_NOT_ALLOWING_CAPTURE.include?(p.category) }

    if only_manual_payments && authorized_without_receipts.any?
      logger.info("#{Time.current}: Authorized payments exist that require manual processing (#{authorized_without_receipts.map(&:category).uniq.join(', ')}). This is expected.")
      capture_problem = false
    end
  end

  # Let's do a security check to make sure the capture balance is the same as the invoice balance
  # final_captured_balance = payments.all_captured.sum(:amount)
  # invoice_total = total
  # capture_problem = true if invoice_total != final_captured_balance

  if capture_problem == true
    Mailer.generic_mailer(
      from: ADMINISTRATOR_EMAIL,
      to: "#{ADMINISTRATOR_EMAIL},#{ACCOUNTS_RECEIVABLE_EMAIL}",
      subject: "INVOICE ##{reference_number} FUNDS CAPTURE ERROR",
      message: "There has been a problem with the funds capture on invoice id #{id}, ref #{reference_number}, delivery id: #{delivery_id}. Please take action to ensure all funds are captured or applied.",
      no_verbage: true
    ).deliver
    logger.error("#{Time.current}: CAPTURE ERROR: Problem with funds capture")
  else
    if pending_balance.zero?
      logger.info("#{Time.current}: Funds captured successfully.")
    else
      logger.info("#{Time.current}: Funds captured successfully, but balance has not been completely paid.")
    end
    # enqueue pdf generation process
    InvoicePdfGenerationWorker.perform_async(id)
  end

  true
end

#chosen_shipping_costBigDecimal

Numeric cost of #chosen_shipping_method, or 0.00 when the carrier
uses a customer-supplied shipping account number (no charge to bill).
Wrapped in rescue because legacy data has nil shipping methods.

Returns:

  • (BigDecimal)


653
654
655
656
657
658
659
660
661
# File 'app/models/invoice.rb', line 653

def chosen_shipping_cost
  cost = BigDecimal('0.00')
  begin
    cost = chosen_shipping_method.cost unless chosen_shipping_method.
  rescue StandardError => e
    Rails.logger.warn "Could not get shipping cost for invoice #{id}: #{e.message}"
  end
  cost
end

#chosen_shipping_methodShippingCost?

Cheapest available ShippingCost on the parent Delivery — what
the customer is actually being charged for shipping on this invoice.

Returns:



644
645
646
# File 'app/models/invoice.rb', line 644

def chosen_shipping_method
  shipping_costs.first
end

#combined_termsString

Terms label augmented with the early-payment offer in the standard
"Net X - Y%/Z" notation (e.g. Net 30 - 2%/10). Falls back to plain
#terms when no early-payment offer applies.

Returns:

  • (String)


1115
1116
1117
1118
1119
1120
1121
# File 'app/models/invoice.rb', line 1115

def combined_terms
  if early_payment_discount && early_payment_timescale
    "#{terms} - #{early_payment_discount}%/#{early_payment_timescale}"
  else
    terms
  end
end

#communicationsActiveRecord::Relation<Communication>

Communications (emails, calls) logged against this invoice, most recent first.

Returns:

See Also:



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

has_many   :communications, -> { order(:id).reverse_order }, as: :resource, dependent: :nullify, inverse_of: :resource

#companyCompany

Company (brand/entity) issuing the invoice.

Returns:

See Also:



189
# File 'app/models/invoice.rb', line 189

belongs_to :company, inverse_of: :invoices

#copy_paymentsvoid

This method returns an undefined value.

Re-points Payments from the originating Delivery onto this invoice
so that authorised cards / captured echecks are available for capture
against the new invoice id. Skips foreign-currency payments and
payments not yet authorised.



533
534
535
536
537
# File 'app/models/invoice.rb', line 533

def copy_payments
  delivery.payments.where(currency: currency).find_each do |pp|
    pp.update!(invoice_id: id) if pp.authorized? || (pp.authorization_type.in?(%w[credit_card check paypal_invoice amazon_pay]) && pp.captured?)
  end
end

#create_receipts_for_captured_paymentsvoid

This method returns an undefined value.

Creates Receipts for Payments that have already been captured
at the gateway but don't yet have receipts on this invoice. Used
both by funds-capture and as the catch-up step for CC/PayPal
captures whose webhook receipt creation didn't fire. Idempotent —
skips payments already linked to receipts.



1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
# File 'app/models/invoice.rb', line 1032

def create_receipts_for_captured_payments
  payments.all_captured.each do |payment|
    next if payment.receipts.present?
    next if payment.skip_auto_receipt

    # Extends the AppSignal #4120 fix (31f69165d3, which guarded the
    # create_receipt_details path) to this captured-payments path. If the
    # invoice was already settled by another receipt before this async
    # catch-up runs, applying payment.amount over-applies; the receipt-detail
    # balance validation correctly rejects it and the RecordInvalid re-raised
    # into CaptureFundsHandler for a futile Sidekiq retry. Skip instead —
    # same net result (no over-applied detail) without the spurious error.
    if balance.present? && payment.amount > balance
      Rails.logger.info "[Invoice##{id}] skipping captured-payment receipt for payment ##{payment.id}: amount #{payment.amount} exceeds balance #{balance}"
      next
    end

    begin
      res = payment.gateway_class.new(payment).create_receipt(self, payment.amount, payment.amount)
      res.receipt.apply
    rescue ActiveRecord::RecordInvalid => e
      # Belt-and-suspenders for the check-then-act race: if a concurrent receipt
      # settles the invoice between the balance check above and #apply, the
      # receipt-detail balance validation rejects the over-application. Skip this
      # one payment and keep going rather than re-raising the deterministic
      # failure into a futile CaptureFundsHandler Sidekiq retry (#4120).
      Rails.logger.info "[Invoice##{id}] skipping over-applying receipt for payment ##{payment.id}: #{e.message}"
    end
  end

  # create receipt details for already cc and paypal payments with an unapplied receipt
  payments.all_cc_captured.each do |payment|
    receipts_with_no_details = payment.receipts.where.missing(:receipt_details)
    receipts_with_no_details.each do |receipt|
      receipt.create_receipt_details(payment.invoice, payment.amount) if payment.invoice.present?
      receipt.apply
    end
  end
end

#credit_memosActiveRecord::Relation<CreditMemo>

Credit memos issued against this invoice.

Returns:

See Also:



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

has_many   :credit_memos, foreign_key: 'original_invoice_id', dependent: :destroy, inverse_of: :original_invoice

CRM URL for the invoice show page. Used by activity/comm logs and
admin notification emails so reps can jump straight to the record.

Returns:

  • (String)


756
757
758
# File 'app/models/invoice.rb', line 756

def crm_link
  UrlHelper.instance.invoice_path(self)
end

#currency_symbolString

Currency symbol (e.g. $, ) for this invoice's #currency, used
in the PDF and overpayment notification templates.

Returns:

  • (String)


558
559
560
# File 'app/models/invoice.rb', line 558

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

#customerCustomer

Customer the invoice is issued to.

Returns:

See Also:



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

belongs_to :customer, optional: true, inverse_of: :invoices

#customer_nameString?

Display name for the Customer this invoice bills, falling back to
nil for headless data (rare — most invoices have a customer).

Returns:

  • (String, nil)


515
516
517
# File 'app/models/invoice.rb', line 515

def customer_name
  customer.try(:full_name)
end

#deliveryDelivery

Delivery fulfilling this invoice.

Returns:

See Also:



201
# File 'app/models/invoice.rb', line 201

belongs_to :delivery, optional: true, inverse_of: :invoices

#destination_addressObject

(An explicit reader rather than alias destination_address shipping_address
YARD's alias handler crashes copying the yard-activerecord association
docstring during the docs build.)



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

def destination_address = shipping_address

#disable_auto_coupon?Boolean

Whether automatic coupon application is suppressed.

Returns:

  • (Boolean)


1160
1161
1162
# File 'app/models/invoice.rb', line 1160

def disable_auto_coupon?
  true
end

#discount_appliedBigDecimal

Sum of discount written off via ReceiptDetails, e.g. early-payment
discounts or accounting write-downs. Reported on the invoice ledger tab.

Returns:

  • (BigDecimal)


1148
1149
1150
# File 'app/models/invoice.rb', line 1148

def discount_applied
  receipt_details.sum(:discount)
end

#discount_days_dueInteger?

Days between #gl_date and #early_payment_due_date; used by the
PDF "early-payment discount" line. Nil when no early-payment offer applies.

Returns:

  • (Integer, nil)


623
624
625
626
627
# File 'app/models/invoice.rb', line 623

def discount_days_due
  return unless early_payment_due_date

  (early_payment_due_date - gl_date).to_i
end

#do_not_detect_shipping?Boolean

Whether automatic shipping detection is suppressed.

Returns:

  • (Boolean)


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

def do_not_detect_shipping?
  true
end

#drop_ship_purchase_ordersActiveRecord::Relation<DropShipPurchaseOrder>

Drop-ship purchase orders raised from this invoice's delivery.

Returns:

  • (ActiveRecord::Relation<DropShipPurchaseOrder>)

See Also:



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

has_many   :drop_ship_purchase_orders, -> { order(:id) }, through: :delivery, dependent: :destroy

#early_payment_amountBigDecimal

Dollar value of the early-payment discount — early_payment_discount%
of the invoice total, rounded to 2dp. Zero when no early-payment
offer is configured.

Returns:

  • (BigDecimal)


1077
1078
1079
1080
1081
1082
1083
# File 'app/models/invoice.rb', line 1077

def early_payment_amount
  if early_payment_discount.blank?
    BigDecimal(0)
  else
    ((early_payment_discount * total) / 100).round(2)
  end
end

#early_payment_due_dateDate?

Date by which the customer must pay to qualify for the
early-payment discount (shipped_date + early_payment_timescale days).
Nil when no early-payment offer applies.

Returns:

  • (Date, nil)


1090
1091
1092
1093
1094
1095
1096
# File 'app/models/invoice.rb', line 1090

def early_payment_due_date
  if early_payment_timescale.blank?
    nil
  else
    shipped_date + early_payment_timescale.days
  end
end

#early_payment_totalBigDecimal

Discounted total the customer would owe if they pay by
#early_payment_due_date — total minus #early_payment_amount.

Returns:

  • (BigDecimal)


1102
1103
1104
1105
1106
1107
1108
# File 'app/models/invoice.rb', line 1102

def early_payment_total
  if early_payment_discount.zero?
    total
  else
    total - early_payment_amount
  end
end

#edi_communication_logsActiveRecord::Relation<EdiCommunicationLog>

Communication logs from this invoice's EDI documents.

Returns:

See Also:



240
# File 'app/models/invoice.rb', line 240

has_many   :edi_communication_logs, through: :edi_documents, dependent: :destroy

#edi_documentsActiveRecord::Relation<EdiDocument>

EDI documents exchanged for this invoice.

Returns:

See Also:



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

has_many   :edi_documents, dependent: :destroy, inverse_of: :invoice

#editing_locked?Boolean

Whether the invoice is locked from editing (already unpaid-out or paid).

Returns:

  • (Boolean)


489
490
491
# File 'app/models/invoice.rb', line 489

def editing_locked?
  unpaid? || paid?
end

#effective_storeStore?

Resolves the Store that should own this invoice for inventory and
ledger purposes — the explicit store, falling back to the parent
Order's store, then the company's first store. Used by report
grouping and the consignment-offset GL lookup.

Returns:



410
411
412
# File 'app/models/invoice.rb', line 410

def effective_store
  store || order&.store || company.stores.first
end

#file_name(with_extension: true) ⇒ String

Filename used when attaching the invoice PDF to email or storing in S3.

Parameters:

  • with_extension (Boolean) (defaults to: true)

    include .pdf

Returns:

  • (String)


724
725
726
# File 'app/models/invoice.rb', line 724

def file_name(with_extension: true)
  "invoice_#{reference_number}#{'.pdf' if with_extension}"
end

#friendly_shipping_method(show_customer_pays_info: false) ⇒ String

Human-readable shipping-method label for the PDF and CRM —
"Warehouse Pickup" for warehouse addresses, otherwise the carrier
description with an optional COD-charge note.

Parameters:

  • show_customer_pays_info (Boolean) (defaults to: false)

    kept for signature compatibility

Returns:

  • (String)


685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
# File 'app/models/invoice.rb', line 685

def friendly_shipping_method(show_customer_pays_info: false) # rubocop:disable Lint/UnusedMethodArgument
  shipping_method_name = ''
  method_cod = ''
  if begin
    shipping_address.is_warehouse
  rescue StandardError => e
    Rails.logger.warn "Could not check if shipping address is warehouse for invoice #{id}: #{e.message}"
    false
  end
    shipping_method_name = 'Warehouse Pickup'
  elsif chosen_shipping_method
    shipping_method_name = chosen_shipping_method.description
    method_cod = ' (inc. COD charge)' if chosen_shipping_method.cod
  end
  "#{shipping_method_name} #{method_cod}".strip
end

#fully_funded_by_rma?Boolean

Whether this invoice is fully funded by an advance-replacement RMA.

Returns:

  • (Boolean)


796
797
798
# File 'app/models/invoice.rb', line 796

def fully_funded_by_rma?
  order.present? && order.fully_funded_by_advance_replacement?
end

#funded_by_cod?Boolean

Whether the invoice terms are cash-on-delivery.

Returns:

  • (Boolean)


802
803
804
# File 'app/models/invoice.rb', line 802

def funded_by_cod?
  terms.include?('COD')
end

#funded_by_rma?Boolean

Whether this invoice is funded by an advance-replacement RMA.

Returns:

  • (Boolean)


790
791
792
# File 'app/models/invoice.rb', line 790

def funded_by_rma?
  order.present? && order.funded_by_advance_replacement?
end

#generate_pdfUpload

Renders a fresh combined invoice PDF (cover + line-item pages + any
addendums) via Invoicing::CombinedPdfGenerator, uploads it to S3
under the invoice_pdf category and attaches the Upload.

Returns:

  • (Upload)

    the newly-created upload



733
734
735
736
737
738
739
740
741
# File 'app/models/invoice.rb', line 733

def generate_pdf
  combined_pdf_result = Invoicing::CombinedPdfGenerator.new.process(self, output_to_file: true)
  upload = Upload.uploadify(combined_pdf_result.pdf_file_path,
                            'invoice_pdf',
                            self,
                            combined_pdf_result.file_name)
  uploads << upload
  upload
end

#get_or_regen_pdf(logger = nil) ⇒ Upload

Returns the persisted invoice-PDF Upload, regenerating it via
#generate_pdf if the upload row is missing or its file is gone
from S3. Used by transmission and the CRM "Download PDF" action.

Parameters:

  • logger (Logger, nil) (defaults to: nil)

Returns:



708
709
710
711
712
713
714
715
716
717
718
# File 'app/models/invoice.rb', line 708

def get_or_regen_pdf(logger = nil)
  logger ||= Rails.logger
  pdf = uploads.in_category('invoice_pdf').first
  logger.info "Retrieving Invoice #{id} pdf, record exists: #{!pdf.nil?}"
  unless pdf&.file_exists?
    logger.error ' * Pdf nil or file does not exist, attempting regen'
    pdf = generate_pdf
    logger.info "Pdf regenerated with upload id #{pdf.id}"
  end
  pdf
end

#gl_offset_accountLedgerCompanyAccount

GL account used as the offset for this invoice's ledger postings.



197
# File 'app/models/invoice.rb', line 197

belongs_to :gl_offset_account, class_name: 'LedgerCompanyAccount', optional: true, inverse_of: :invoices

#item_ledger_entriesActiveRecord::Relation<ItemLedgerEntry>

Inventory ledger entries posted for this invoice.

Returns:

See Also:



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

has_many   :item_ledger_entries, dependent: :destroy, inverse_of: :invoice

#ledger_transactionsActiveRecord::Relation<LedgerTransaction>

GL ledger transactions posted for this invoice.

Returns:

See Also:



213
# File 'app/models/invoice.rb', line 213

has_many   :ledger_transactions, dependent: :destroy, inverse_of: :invoice

#line_discountsActiveRecord::Relation<LineDiscount>

Discounts applied to individual line items.

Returns:

See Also:



209
# File 'app/models/invoice.rb', line 209

has_many   :line_discounts, through: :line_items

#line_itemsActiveRecord::Relation<LineItem>

Invoice line items.

Returns:

See Also:



207
# File 'app/models/invoice.rb', line 207

has_many   :line_items, as: :resource, inverse_of: :resource, dependent: :destroy, extend: LineItemExtension, autosave: true

#marketplace_invoice_formatObject

Alias for Customer#marketplace_invoice_format

Returns:

  • (Object)

    Customer#marketplace_invoice_format

See Also:



326
# File 'app/models/invoice.rb', line 326

delegate :marketplace_invoice_format, to: :customer, allow_nil: true

#nameString

Short display name for selection lists and links — just the
reference number.

Returns:

  • (String)


667
668
669
# File 'app/models/invoice.rb', line 667

def name
  reference_number
end

#non_service_line_itemsArray<LineItem>

Goods/shipping LineItems only — services (tax_class 'svc') excluded.
Used for ship-confirmation logic where service lines should not affect
what's physically shipped.

Returns:



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

def non_service_line_items
  line_items.where(cm_category: 'Item').reject { |li| li.item.tax_class == 'svc' }
end

#non_voided_receipt_detailsActiveRecord::Relation<ReceiptDetail>

All ReceiptDetails posted against this invoice excluding voided ones.

Returns:



565
566
567
# File 'app/models/invoice.rb', line 565

def non_voided_receipt_details
  receipt_details.non_voided
end

#not_rma?Boolean

Whether this invoice is NOT funded by an advance-replacement RMA.

Returns:

  • (Boolean)


784
785
786
# File 'app/models/invoice.rb', line 784

def not_rma?
  !(order.present? && order.funded_by_advance_replacement?)
end

#online_payment_optionsArray<String>

Payment options offered on the public pay-online page. Currently the
only gateway-driven option is credit card; checks/POs are out-of-band.

Returns:

  • (Array<String>)


523
524
525
# File 'app/models/invoice.rb', line 523

def online_payment_options
  [Payment::CREDIT_CARD]
end

#orderOrder

Order this invoice bills.

Returns:

See Also:



177
# File 'app/models/invoice.rb', line 177

belongs_to :order, optional: true, inverse_of: :invoices

#order_refString?

Reference number of the parent Order (or nil for MI/CI invoices
with no order). Used by EDI templates and the public pay link.

Returns:

  • (String, nil)


497
498
499
# File 'app/models/invoice.rb', line 497

def order_ref
  order.try(:reference_number)
end

#order_ref=(ref) ⇒ Order?

Setter pairing with #order_ref — looks up the Order by its
reference number so manual-entry forms can attach an invoice to an
existing order without exposing the integer primary key.

Parameters:

  • ref (String)

Returns:



507
508
509
# File 'app/models/invoice.rb', line 507

def order_ref=(ref)
  self.order = Order.find_by(reference_number: ref) if ref.present?
end

#outlet_purchasesActiveRecord::Relation<CustomerOutletPurchase>

Opportunities a rep worked that this outlet invoice satisfied — see
doc/tasks/202608081330_OUTLET_PURCHASE_ATTRIBUTION.md.

Returns:

See Also:



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

has_many   :outlet_purchases, class_name: 'CustomerOutletPurchase', inverse_of: :invoice, dependent: :destroy

#paymentsActiveRecord::Relation<Payment>

Payments applied to this invoice.

Returns:

  • (ActiveRecord::Relation<Payment>)

See Also:



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

has_many   :payments, dependent: :nullify, inverse_of: :invoice

#po_numbersArray<String>

Distinct customer purchase-order numbers attached to Payments on
this invoice. Surfaced in the PDF header and EDI 810 PO segment.

Returns:

  • (Array<String>)


675
676
677
# File 'app/models/invoice.rb', line 675

def po_numbers
  payments.where.not(po_number: nil).distinct.pluck(:po_number)
end

#prevent_recalculate_shipping?Boolean

Whether shipping recalculation is suppressed on save.

Returns:

  • (Boolean)


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

def prevent_recalculate_shipping?
  true
end

#pricing_program_discount_factorBigDecimal

Pricing-program discount multiplier — pulled from the parent Order
when one exists, otherwise from the Customer's tier. Used by
Models::Itemizable when re-evaluating discounts on edit.

Returns:

  • (BigDecimal)


592
593
594
595
596
597
598
# File 'app/models/invoice.rb', line 592

def pricing_program_discount_factor
  if order.present?
    order.pricing_program_discount_factor
  else
    customer.pricing_program_discount
  end
end

#primary_partyParty

Person/company this invoice is "addressed to" for activity attribution
and notifications — the parent Order's primary party when present,
otherwise the Customer.

Returns:



748
749
750
# File 'app/models/invoice.rb', line 748

def primary_party
  order&.primary_party || customer
end

#profileProfile

Customer profile the invoice is attributed to.

Returns:

See Also:



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

belongs_to :profile, optional: true, inverse_of: :invoices

Public-facing pay-online URL for self-serve payment by the customer
(delegates to the parent Order's public-payment link). Nil when
the invoice has no order context (e.g. MI/CI).

Returns:

  • (String, nil)


765
766
767
768
769
770
771
772
773
774
# File 'app/models/invoice.rb', line 765

def public_pay_link
  # disabling authenticated links for now
  return nil unless order # && order.customer.present?

  # a = order.customer.account
  # return nil unless a.present?

  order.public_payment_link
  # "https://#{WEB_HOSTNAME}#{public_pay_path}"
end

Whether the public pay link carries an auth token (currently always false).

Returns:

  • (Boolean)


778
779
780
# File 'app/models/invoice.rb', line 778

def public_pay_link_has_auth_token?
  false
end

#receipt_detailsActiveRecord::Relation<ReceiptDetail>

Payment receipt details applied to this invoice.

Returns:

See Also:



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

has_many   :receipt_details, dependent: :nullify, inverse_of: :invoice

#receipts_totalBigDecimal

Sum of all credit applied to this invoice — cash Receipt amount
plus write-offs plus discount applied. Subtracted from #total to
produce #balance.

Returns:

  • (BigDecimal)


574
575
576
# File 'app/models/invoice.rb', line 574

def receipts_total
  non_voided_receipt_details.sum('amount') + non_voided_receipt_details.sum('write_off') + non_voided_receipt_details.sum('discount')
end

#rma_awaiting_return?Boolean

Whether the linked RMA is still awaiting the returned goods.

Returns:

  • (Boolean)


431
432
433
# File 'app/models/invoice.rb', line 431

def rma_awaiting_return?
  order.try(:rma).try(:state) == 'awaiting_return'
end

#rma_numberString

RMA reference for this invoice — the linked Rma's number when one
exists, otherwise a synthesised "RMA # …" label from the order's
rma_reference. Surfaced on the customer-facing PDF for return shipments.

Returns:

  • (String)


425
426
427
# File 'app/models/invoice.rb', line 425

def rma_number
  order.try(:rma).try(:rma_number) || "RMA # #{order.rma_reference}"
end

#rmasActiveRecord::Relation<Rma>

RMAs issued against this invoice.

Returns:

  • (ActiveRecord::Relation<Rma>)

See Also:



217
# File 'app/models/invoice.rb', line 217

has_many   :rmas, foreign_key: 'original_invoice_id', dependent: :destroy, inverse_of: :original_invoice

#selection_nameString

Display label for resource pickers / invoice dropdowns —
INV… <Customer Name>.

Returns:

  • (String)


1127
1128
1129
# File 'app/models/invoice.rb', line 1127

def selection_name
  "#{reference_number} #{customer.full_name}"
end

#selection_name_for_rmasString

Variant of #selection_name for the RMA picker that appends the
parent Order's reference when present, since RMAs are scoped to
an order, not just a customer.

Returns:

  • (String)


1136
1137
1138
1139
1140
1141
1142
# File 'app/models/invoice.rb', line 1136

def selection_name_for_rmas
  if order.nil?
    "#{reference_number} #{customer.full_name}"
  else
    "#{reference_number} #{customer.full_name} (#{order&.reference_number})"
  end
end

#service_line_itemsArray<LineItem>

Service-class LineItems only (tax_class 'svc'), e.g. SmartInstall
labour. Used by the consignment / service-only invoice rendering path.

Returns:



477
478
479
# File 'app/models/invoice.rb', line 477

def service_line_items
  line_items.where(cm_category: 'Item').select { |li| li.item.tax_class == 'svc' }
end

#set_consolidated_amountvoid

This method returns an undefined value.

Caches the consolidated-currency exchange rate for gl_date onto
#consolidated_exchange_rate, so downstream financial reports can
express the invoice in CONSOLIDATED_CURRENCY without re-querying
ExchangeRate. Sets the rate to 1.0 when the invoice currency
already matches the consolidated currency, or nil when fields aren't
populated yet.



1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
# File 'app/models/invoice.rb', line 1181

def set_consolidated_amount
  if currency && gl_date
    if currency == CONSOLIDATED_CURRENCY
      self.consolidated_exchange_rate = 1.0
    else
      exchange_rate = ExchangeRate.get_exchange_rate(currency, CONSOLIDATED_CURRENCY, gl_date)
      self.consolidated_exchange_rate = exchange_rate
    end
  else
    self.consolidated_exchange_rate = nil
  end
end

#shipping_addressAddress

Address the invoiced goods ship to.

Returns:

See Also:

Validations:



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

belongs_to :shipping_address, class_name: 'Address', optional: true, inverse_of: :shipping_invoices

#shipping_costsActiveRecord::Relation<ShippingCost>

Shipping costs from the delivery, cheapest first.

Returns:

See Also:



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

has_many   :shipping_costs, -> { order(:cost) }, through: :delivery

#show_tax_info?Boolean

Whether tax info (VAT number / company tax info) should be rendered.

Returns:

  • (Boolean)


416
417
418
# File 'app/models/invoice.rb', line 416

def show_tax_info?
  tax_info.present?
end

#sold_to_billing_addressAddress

Sold-to party's billing address, when different from #billing_address.

Returns:

See Also:



181
# File 'app/models/invoice.rb', line 181

belongs_to :sold_to_billing_address, class_name: 'Address', foreign_key: 'sold_to_billing_address', optional: true, inverse_of: :billing_invoices

#sourceSource

Source (marketing/order origin) of the invoice.

Returns:

See Also:



203
# File 'app/models/invoice.rb', line 203

belongs_to :source, optional: true, inverse_of: :invoices

#storeStore

Store the invoice was issued by (misc/counter invoices).

Returns:

See Also:



195
# File 'app/models/invoice.rb', line 195

belongs_to :store, optional: true, inverse_of: :invoices

#tax_infoString?

Tax-identification line printed at the top of the invoice PDF — the
destination country's EU VAT number when shipping into the EU,
otherwise the company's tax info for Canada. Returns nil when no
tax identifier applies (e.g. US domestic).

Returns:

  • (String, nil)


389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'app/models/invoice.rb', line 389

def tax_info
  destination_country = shipping_address&.country
  return unless destination_country

  if destination_country.eu_country?
    if destination_country.eu_vat_number.present?
      "VAT: #{destination_country.eu_vat_number}"
    else
      company.tax_info
    end
  elsif company.canada?
    company.tax_info
  end
end

#technical_support_repEmployee

Technical support rep credited on the invoice.

Returns:

See Also:



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

belongs_to :technical_support_rep, class_name: 'Employee', optional: true, inverse_of: :technical_support_invoices

#terms_in_daysObject

calculate net due date in days



613
614
615
616
617
# File 'app/models/invoice.rb', line 613

def terms_in_days
  return unless due_date

  (due_date - gl_date).to_i
end

#to_liquidLiquid::InvoiceDrop

Liquid drop wrapper used when this invoice is rendered into
transmission email/SMS templates — exposes only the safe-for-template
accessors via Liquid::InvoiceDrop.

Returns:



1210
1211
1212
# File 'app/models/invoice.rb', line 1210

def to_liquid
  Liquid::InvoiceDrop.new self
end

#to_sString

Human-readable identifier used in audit logs and error messages.

Returns:

  • (String)


1197
1198
1199
1200
1201
1202
1203
# File 'app/models/invoice.rb', line 1197

def to_s
  if respond_to?(:reference_number)
    "Invoice # #{reference_number}"
  else
    "Invoice ID #{id}"
  end
end

#uploadsActiveRecord::Relation<Upload>

Uploaded files attached to this invoice, most recent first.

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



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

has_many   :uploads, -> { order(:updated_at).reverse_order }, as: :resource, dependent: :destroy, inverse_of: :resource