Class: ReceiptsController

Inherits:
CrmController show all
Includes:
Controllers::Destroyable
Defined in:
app/controllers/receipts_controller.rb

Overview

== Schema Information

Table name: receipts

id :integer not null, primary key
jde_number :string(255)
category :string(255)
amount :decimal(8, 2)
reference :string(255)
currency :string(255)
authorization_id :integer
created_at :datetime
updated_at :datetime
card_type :string(255)
auth_code :string(255)
exported :boolean default(FALSE)
order_id :integer
company_id :integer
customer_id :integer
bank_account_id :integer
gl_date :date
receipt_date :date
exchange_rate :float
remark :string(255)
creator_id :integer
updater_id :integer
deleter_id :integer
state :string(255)
approved_for_refund :boolean

Constant Summary

Constants included from Controllers::ReferenceFindable

Controllers::ReferenceFindable::ID_EMBEDDED_PATTERNS

Constants included from Controllers::AnalyticsEvents

Controllers::AnalyticsEvents::MAX_QUEUED_EVENTS, Controllers::AnalyticsEvents::SESSION_KEY

Constants included from Controllers::ErrorRendering

Controllers::ErrorRendering::NON_CONTENT_PATH_PREFIXES

Constants included from Www::SeoHelper

Www::SeoHelper::AWARDS, Www::SeoHelper::CA_ADDRESS, Www::SeoHelper::CA_BUSINESS_HOURS, Www::SeoHelper::CA_CONTACT_POINT, Www::SeoHelper::CA_CURRENCIES, Www::SeoHelper::CA_DESCRIPTION, Www::SeoHelper::CA_FOUNDING_DATE, Www::SeoHelper::CA_GLOBAL_LOCATION_NUMBER, Www::SeoHelper::CA_LEGAL_NAME, Www::SeoHelper::CA_LOCAL_BUSINESS, Www::SeoHelper::CA_ONLINE_STORE, Www::SeoHelper::CA_RETURN_POLICY, Www::SeoHelper::CA_SALES_DEPARTMENT, Www::SeoHelper::CA_SERVICE_AREA, Www::SeoHelper::CA_URL, Www::SeoHelper::CA_VAT_ID, Www::SeoHelper::CA_WAREHOUSE_DEPARTMENT, Www::SeoHelper::CA_WAREHOUSE_HOURS, Www::SeoHelper::COMPANY_EMAIL, Www::SeoHelper::COMPANY_LOGO, Www::SeoHelper::COMPANY_NAME, Www::SeoHelper::COMPANY_SLOGAN, Www::SeoHelper::EXPERTISE, Www::SeoHelper::FAX_NUMBER, Www::SeoHelper::GS1_COMPANY_PREFIX, Www::SeoHelper::ISO6523_CODE, Www::SeoHelper::PAYMENT_METHODS, Www::SeoHelper::PHONE_NUMBER, Www::SeoHelper::PRIMARY_NAICS, Www::SeoHelper::REFUND_TYPE, Www::SeoHelper::RETURN_FEES, Www::SeoHelper::RETURN_METHOD, Www::SeoHelper::RETURN_POLICY_CATEGORY, Www::SeoHelper::SECONDARY_NAICS, Www::SeoHelper::SOCIAL_PROFILES, Www::SeoHelper::US_ADDRESS, Www::SeoHelper::US_BUSINESS_HOURS, Www::SeoHelper::US_CONTACT_POINT, Www::SeoHelper::US_CURRENCIES, Www::SeoHelper::US_DESCRIPTION, Www::SeoHelper::US_FOUNDING_DATE, Www::SeoHelper::US_GLOBAL_LOCATION_NUMBER, Www::SeoHelper::US_IMAGE, Www::SeoHelper::US_LEGAL_NAME, Www::SeoHelper::US_LOCAL_BUSINESS, Www::SeoHelper::US_ONLINE_STORE, Www::SeoHelper::US_RETURN_POLICY, Www::SeoHelper::US_SALES_DEPARTMENT, Www::SeoHelper::US_SERVICE_AREA, Www::SeoHelper::US_TAX_ID, Www::SeoHelper::US_URL, Www::SeoHelper::US_WAREHOUSE_DEPARTMENT, Www::SeoHelper::US_WAREHOUSE_HOURS

Constants included from IconHelper

IconHelper::CUSTOM_ICON_MAP, IconHelper::CUSTOM_SVG_DIR, IconHelper::DEFAULT_FAMILY

Instance Method Summary collapse

Methods included from Controllers::Destroyable

#destroy, #perform_destroy

Methods inherited from CrmController

#access_denied, #context_id, #context_object, #crm_home_path, #current_ability, #default_url_options, #download_temp, #get_tempfile_path_for_download, #init_status_job_collector, #initialize_crm_lazy_chunks, #persist_enqueued_status_jobs, #record_not_found, #redirect_to_job_or_fallback, #render_edit_action, #set_context, #set_download_path, #stash_file_for_temp_download, #sync_admin_presence_cookie

Methods inherited from ApplicationController

#account_impersonated?, #add_to_flash, #after_sign_in_path_for, #bypass_forgery_protection?, #chat_enabled?, #cloudflare_cleared?, #default_catalog, #default_url_options, #enable_turbo_frames, #find_publication, #fix_invalid_accept_header, #init_js_utils, #is_globals_call?, #layout_by_resource, #locale_store, #redirect_to, #require_employee_for_crm, #set_base_host, #set_real_ip, #set_report_errors_for, #should_render_layout?, #stamp_impersonation_context, #warmlyyours_canada_ip?, #warmlyyours_ip?, #y

Methods included from Controllers::ReturnPathHandling

#check_for_return_path, #redirect_to_return_path_or_default

Methods included from Controllers::AnalyticsEvents

#consume_queued_analytics_events, #track_event

Methods included from Controllers::DeviceDetection

#device_detector, #is_ie?

Methods included from Controllers::SubdomainDetection

#is_crm_request?, #is_www_request?, #json_request?

Methods included from Controllers::TurboSafeRedirect

#redirect_to

Methods included from Controllers::TrackingDetection

#bot_request?, #gdpr_country?, #gdpr_country_data, #prevent_bots, #set_tracking_cookie, #track_visitor?

Methods included from Controllers::AcceleratedFileSending

#send_file_accelerated, #send_upload_accelerated

Methods included from Controllers::ErrorRendering

#excp_string, #mail_to_for_error_reporting, #render_400, #render_404, #render_406, #render_410, #render_500, #render_invalid_authenticity_token, #render_ip_spoof_error, #render_unpermitted_parameters, #safe_referer_or_fallback

Methods included from Controllers::TurnstileVerification

#load_turnstile_script_tag, #turnstile_lazy_widget, #turnstile_script_tag, #turnstile_widget, #validate_turnstile!

Methods included from Controllers::CloudflareCaching

edge_cached, #edge_cached_action?, #reset_cloudflare_cache, #set_cloudflare_cache, #skip_edge_cache!, #skip_session

Methods included from Controllers::Webpackable

#preload_webpack_fonts, #webpack_css_include, #webpack_css_url, #webpack_js_include, #wpd_is_running?

Methods included from Controllers::Localizable

#cloudflare_country_locale, #determine_request_locale, #geocoder_locale, #guest_user_locale_check, #locale_optional_www_auth_path?, #param_locale, #set_locale, #set_request_locale, #skip_localization?, #warmlyyours_ip_locale

Methods included from Controllers::Authenticable

#access_denied, #authenticate_account, #authenticate_account!, #authenticate_account_from_login_token!, #check_is_a_manager, #check_is_a_sales_manager, #check_is_an_admin, #check_is_an_employee, #check_party, #clear_mismatched_guest_user, #create_guest_user, #credentials?, #current_or_guest_user, #current_or_guest_user_id_read_only, #current_user, #devise_mapping, #fully_logged_in?, #generate_bot_id, #guest_user, #identifiable?, #init_current_user, #initialize_guest, #load_context_user, #logging_in, #resource, #resource_name, #restrict_access_for_non_employees, #scrubbed_request_path, #user_object, #warn_on_session_guest_id_leak

Methods included from ApplicationHelper

#better_number_to_currency, #check_force_logout, #check_or_cross, #check_or_times, #embedded_tab_frame_id, #error_messages, #general_disclaimer_on_product_installation_and_local_codes, #gridjs_from_html_table, #gridjs_table, #is_wy_ip, #line_break, #parent_layout, #pass_or_fail, #render_error_messages_list, #render_video_card, #resolved_auth_form_turbo_frame, #return_path_or, #safe_css_color, #set_return_path_if_present, #set_section_if_present, #tab_frame_id, #to_underscore, #track_page?, #turbo_section_wrapper, #turbo_tabs_request?, #url_on_same_domain_as_request, #widget_index_daily_focus_index_path, #working_hours?, #yes_or_no, #yes_or_no_highlighted, #yes_or_no_with_check_or_cross, #youtube_video

Methods included from UppyUploaderHelper

#file_uploader, #image_uploader, #large_file_uploader_s3, #lead_sketch_uploader, #rma_image_uploader, #rma_image_uploader_s3, #uppy_uploader, #video_uploader

Methods included from Www::ImagesHelper

#image_asset_tag, #image_asset_url

Methods included from Www::SeoHelper

#add_page_schema, #add_webpage_schema, #canada?, #company_social_links, #ensure_context_json, #json_ld_script_tag, #local_business_schema, #online_store_id, #online_store_schema, #page_main_entity, #page_main_entity_json, #render_auto_collection_page_schema, #render_collection_page_schema, #render_local_business_schema, #render_online_store_schema, #render_page_schemas, #render_page_video_schemas, #render_webpage_schema, #render_webpage_schema_with_collections, #usa?

Methods included from UrlsHelper

#catalog_breadcrumb_links, #catalog_link, #catalog_link_for_product_line, #catalog_link_for_sku, #cms_link, #delocalized_path, #path_to_sales_product_sku, #path_to_sales_product_sku_for_product_line, #path_to_sales_product_sku_for_product_line_slug, #product_line_from_catalog_link, #protocol_neutral_url, #sanitize_external_url, #valid_external_url?

Methods included from IconHelper

#account_nav_icon, #fa_icon, #star_rating_html

Instance Method Details

#business_unit_lookupObject

GET /receipts/business_unit_lookup — JSON typeahead for the
business-unit picker on receipt-detail rows.



385
386
387
388
389
390
391
392
393
394
# File 'app/controllers/receipts_controller.rb', line 385

def business_unit_lookup
  q = params[:q]&.strip

  results = BusinessUnit.by_number.limit(20)
  results = results.where('business_units.number::text ILIKE ? OR business_units.name ILIKE ?', "%#{q}%", "%#{q}%") if q.present?

  render json: {
    results: results.map { |bu| { id: bu.id, text: bu.number_and_name } }
  }
end

#createObject

POST /receipts — persist the receipt and, when the form opted
to also process a CC/eCheck payment in the same submit, run the
gateway purchase and bind the resulting payment+receipt row.
Wrapped in an advisory lock keyed on customer/date/amount to
prevent the double-click of the submit button from creating two
receipts. On any failure path re-renders the new/edit form
surfaced via params[:render_action] (safelisted by
#safe_render_action so it can't be coerced into an arbitrary
template).



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'app/controllers/receipts_controller.rb', line 182

def create
  @original_params = params[:original_params]
  @receipt = Receipt.new(params[:receipt])
  @customer = @receipt.customer
  @payment = Payment.new((params[:payment] || {}).merge({ amount: @receipt.amount, currency: @receipt.currency, customer_id: @receipt.customer_id }))
  @payment.remote_ip_address = NetworkConstants::HEATWAVE_PUBLIC_IP # always set this to our static public IP
  @payment.authorization_type = @receipt.category_for_authorization_type
  @receipt.email ||= @payment.email

  unique_key = "create_receipts_#{@receipt.customer_id}_#{@receipt.receipt_date}_#{@receipt.amount}"
  Receipt.with_advisory_lock(unique_key, timeout_seconds: 0) do
    if @receipt.process_cc_payment.to_b
      @payment.category = Payment::CREDIT_CARD if @receipt.credit_card?
      @payment.category = Payment::ECHECK if @receipt.echeck?

      if @receipt.credit_card? && (@duplicate_matches = duplicate_charge_matches).any?
        @cc_payment = @payment
        @check_payment = @payment
        render(action: safe_render_action, status: :unprocessable_content)
        return
      end

      # Refuse to persist a credit-card Payment when the form posted
      # without either a PaymentIntent token (`pi_…`) or a vault id.
      # That combination meant the rep submitted the form before
      # Stripe.js finished confirming the card — exactly the JS race
      # that produced the 2026-06-02 orphan (Payment 286252). Blocking
      # here means no `@payment.save!` ever runs on a partial submit
      # so no pending row can leak into the DB.
      if @receipt.credit_card? && @receipt.process_cc_payment.to_b &&
         !params.dig(:payment, :card_token).to_s.start_with?('pi_') &&
         params.dig(:payment, :vault_id).blank?
        @cc_payment = @payment
        @check_payment = @payment
        flash.now[:error] = 'Card payment was not completed — Stripe did not return a PaymentIntent. ' \
                            'Wait for the card form to finish loading and try again. ' \
                            'If the card was already charged, DO NOT retry — contact the Heatwave Team.'
        render(action: safe_render_action, status: :unprocessable_content)
        return
      end

      if @payment.valid? && @receipt.valid?
        @payment.save!
        res = @payment.gateway_class.new(@payment).purchase(@receipt)
        if res.success && res.receipt.persisted?
          @receipt.regenerate_invoice_pdf
          flash[:info] = 'Payment processed and receipt was successfully created.'
          redirect_to_return_path_or_default(receipt_path(res.receipt))
        elsif res.success && !res.receipt.persisted?
          # The card was successfully charged on Stripe BUT the Receipt
          # failed to persist. Do NOT let the rep retry the form —
          # resubmitting will mint a fresh PaymentIntent and charge the
          # card a second time (this is exactly how customer 47895 got
          # double-charged $1,925.57 on 2026-06-02).
          flash.now[:error] = 'Card was already charged on Stripe but the receipt could not be saved. ' \
                              'DO NOT click submit again — it will charge the card a second time. ' \
                              'Contact the Heatwave Team to reconcile.'
          render(action: safe_render_action, status: :unprocessable_content)
        else
          @payment = Payment.new(params[:payment] || {})
          flash.now[:error] = "Unable to process payment, message from gateway: #{res.message}"
          render(action: safe_render_action, status: :unprocessable_content)
        end
      else
        flash.now[:error] = (@payment.errors_to_s.presence || @receipt.errors_to_s.presence || 'Could not create receipt.')
        render(action: safe_render_action, status: :unprocessable_content)
      end
    elsif @receipt.save
      @receipt.regenerate_invoice_pdf
      flash[:info] = 'Receipt was successfully created.'
      redirect_to_return_path_or_default(receipt_path(@receipt))
    else
      flash.now[:error] = @receipt.errors_to_s.presence || 'Could not create receipt.'
      @cc_payment = @payment
      @check_payment = @payment
      render(action: safe_render_action, status: :unprocessable_content)
    end
  end
end

#create_paymentObject

POST /receipts/create_payment — empty stub the form posts to so
client-side validation can short-circuit before the actual
#create call. The real submit re-routes through create.



317
# File 'app/controllers/receipts_controller.rb', line 317

def create_payment; end

#create_payment_intentObject

POST /receipts/create_payment_intent — JSON endpoint that mints
a Stripe PaymentIntent for the receipt form's Stripe-Elements
widget. Returns {client_secret, payment_intent_id}. Catches
Stripe::StripeError so gateway-side validation errors come
back as 422 instead of 500.



563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
# File 'app/controllers/receipts_controller.rb', line 563

def create_payment_intent
  amount_cents = params[:amount_cents].to_i
  currency     = params[:currency].to_s.upcase.presence || 'USD'
  customer     = Customer.find_by(id: params[:customer_id])

  return render json: { error: 'Amount must be positive' }, status: :unprocessable_content if amount_cents <= 0

  pi_params = {
    amount_cents: amount_cents,
    currency: currency,
    capture_method: 'automatic',
    allow_redirects: 'never',
    description: "CRM Receipt#{" - #{customer.full_name}" if customer}",
    metadata: { customer_id: customer&.id }.compact
  }
  pi_params[:customer_id] = customer.stripe_customer_id if customer&.stripe_customer_id.present?

  pi = Payment::Apis::Stripe.create_payment_intent(**pi_params)
  render json: { client_secret: pi.client_secret, payment_intent_id: pi.id }
rescue ::Stripe::StripeError => e
  render json: { error: e.message }, status: :unprocessable_content
end

#credit_memo_detailObject

GET /receipts/credit_memo_detail — JSON detail endpoint for the
credit-memo picker (parallel of #invoice_detail). Computes
remaining balance directly in SQL to avoid an N+1 across
receipt_details.



413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'app/controllers/receipts_controller.rb', line 413

def credit_memo_detail
  @credit_memo = CreditMemo.select("credit_memos.id as credit_memo_id, credit_memos.customer_id, credit_memos.billing_customer_id, (select parties.full_name from parties where parties.id = credit_memos.customer_id) as customer_full_name, credit_memos.total as total, (credit_memos.total - coalesce((select sum(amount) from receipt_details where receipt_details.credit_memo_id = credit_memos.id and receipt_details.state != 'voided'),0)) as remaining_balance, credit_memos.gl_date as gl_date").available_to_apply.where(reference_number: params[:reference_number]).first

  respond_to do |format|
    format.json do
      if @credit_memo.nil?
        render json: { not_found: '1' }
      else
        render json: @credit_memo.as_json.merge('customer_matches' => payor_can_apply_document?(@credit_memo.customer_id, @credit_memo.billing_customer_id))
      end
    end
  end
end

#credit_memo_lookupObject

GET /receipts/credit_memo_lookup — JSON typeahead for the
credit-memo picker. Filters to memos with remaining balance.



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'app/controllers/receipts_controller.rb', line 360

def credit_memo_lookup
  q = params[:q]&.strip
  customer_id = params[:customer_id].presence

  results = CreditMemo.available_to_apply.includes(:customer).order(id: :desc)
  if customer_id
    # Include credit memos owned by the customer's parent/child accounts,
    # not just an exact customer_id match, so a parent-account receipt can
    # offset a child account's credit memo (and vice versa).
    customer = Customer.find_by(id: customer_id)
    results = results.where(customer_id: customer&.organization_family_ids || customer_id)
  end
  results = results.where('credit_memos.reference_number ILIKE ?', "%#{q}%") if q.present?

  json_result = TomSelect.format_json_results(self, results, params[:page].presence, params[:per_page].presence || 20) do |cm|
    {
      id: cm.id,
      text: "#{cm.reference_number} - #{cm.customer.try(:full_name)} ($#{cm.balance})"
    }
  end
  render json: json_result
end

#customer_detailObject

GET /receipts/customer_detail — JSON endpoint returning the
customer card used by the receipt-form's "selected customer"
tile (name, id, billing entity link).



430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'app/controllers/receipts_controller.rb', line 430

def customer_detail
  @customer = Customer.where(id: params[:id]).first

  respond_to do |format|
    format.json do
      if @customer.nil?
        render json: { not_found: '1' }
      else
        render json: @customer.attributes.merge('customer_company' => @customer.company.name).merge({ addresses: @customer.all_addresses.map { |a| { id: a.id, full_address: a.full_address } } })
      end
    end
  end
end

#do_create_from_excelObject

POST /receipts/do_create_from_excel — bulk-create receipts from
the canonical create-template spreadsheet via CreateReceiptsWorker.
Validates the file upfront so any per-row errors come back to the
form rather than the background-job dashboard.



482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
# File 'app/controllers/receipts_controller.rb', line 482

def do_create_from_excel
  errors = nil
  if params[:upload].present?
    uploaded_excel_file = Upload.new(attachment: params[:upload][:imported_receipts_excel], category: :other, document_date: Date.current)
    uploaded_excel_file.save!
    errors = Receipt.validate_data_create_receipts_from_xlsx(uploaded_excel_file)
    if errors.any?
      uploaded_excel_file.destroy
      flash.now[:error] = errors.uniq.join
      render :create_from_excel, status: :unprocessable_content
    else
      job_options = { upload_id: uploaded_excel_file.id }.stringify_keys
      job_id = CreateReceiptsWorker.perform_async(job_options)

      redirect_to job_path(job_id) if job_id.present?
    end
  else
    flash.now[:error] = 'No file was uploaded'
    render :create_from_excel, status: :unprocessable_content
  end
end

#do_edit_from_excelObject

POST /receipts/do_edit_from_excel — bulk-edit existing receipts
via EditReceiptsWorker. Same template-based validation as
#do_create_from_excel.



514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
# File 'app/controllers/receipts_controller.rb', line 514

def do_edit_from_excel
  errors = nil
  if params[:upload].present?
    uploaded_excel_file = Upload.new(attachment: params[:upload][:imported_receipts_excel], category: :other, document_date: Date.current)
    uploaded_excel_file.save!
    errors = Receipt.validate_data_edit_receipts_from_xlsx(uploaded_excel_file)
    if errors.any?
      uploaded_excel_file.destroy
      flash.now[:error] = errors.uniq.join
      render :edit_from_excel, status: :unprocessable_content
    else
      job_options = { upload_id: uploaded_excel_file.id }.stringify_keys
      job_id = EditReceiptsWorker.perform_async(job_options)

      redirect_to job_path(job_id) if job_id.present?
    end
  else
    flash.now[:error] = 'No file was uploaded'
    render :edit_from_excel, status: :unprocessable_content
  end
end

#do_voidObject

POST /receipts/:id/do_void — execute the void using the per-detail
reversal dates. Reminds the user when a CC payment is attached so
they know they still owe a manual Stripe refund.



454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'app/controllers/receipts_controller.rb', line 454

def do_void
  @receipt = Receipt.find(params[:id])
  begin
    @receipt.void_receipt(params[:reversal_dates])
    message = 'Receipt was voided.'
    message += ' Credit Card Payments also need to be refunded manually on Stripe if not already done so.' if @receipt.payment.present?
    flash[:info] = message
    redirect_to(@receipt)
  rescue StandardError => e
    @receipt.errors.add(:base, e.to_s)
    flash.now[:error] = e.to_s
    render :void, status: :unprocessable_content
  end
end

#download_sample_excel_for_creationObject

GET /receipts/download_sample_excel_for_creation — serve the
canonical create-template spreadsheet so users start from the
right column layout.



546
547
548
549
# File 'app/controllers/receipts_controller.rb', line 546

def download_sample_excel_for_creation
  file_path = Rails.root.join('data/import_templates/receipts/create_receipts_template.xlsx')
  send_file_accelerated(file_path, download: true, preserve_source: true)
end

#download_sample_excel_for_editionObject

GET /receipts/download_sample_excel_for_edition — serve the
canonical edit-template (different schema from the create one).



553
554
555
556
# File 'app/controllers/receipts_controller.rb', line 553

def download_sample_excel_for_edition
  file_path = Rails.root.join('data/import_templates/receipts/edit_receipts_template.xlsx')
  send_file_accelerated(file_path, download: true, preserve_source: true)
end

#editObject

GET /receipts/:id/edit — render the edit form. Builds an empty
detail row when the receipt has none, and prepares blank
CC/cheque payment scaffolds for the optional payment-and-apply
combo flow.



165
166
167
168
169
170
171
# File 'app/controllers/receipts_controller.rb', line 165

def edit
  @receipt = receipt_for_form
  @receipt.receipt_details.build if @receipt.receipt_details.empty?
  @detail_balances = @receipt.detail_document_balances
  @cc_payment = Payment.new
  @check_payment = Payment.new
end

#editedObject

GET /receipts/edited — landing page after a successful
edit-from-excel run, listing the freshly-modified receipts.



538
539
540
541
# File 'app/controllers/receipts_controller.rb', line 538

def edited
  edited_receipt_ids = params[:edited_receipt_ids].presence || []
  @edited_receipts = Receipt.where(id: edited_receipt_ids)
end

#emailObject

GET /receipts/:id/email — open a draft communication with the
receipt-email template pre-bound. User picks recipients on the
next page.



472
473
474
475
476
# File 'app/controllers/receipts_controller.rb', line 472

def email
  @receipt = Receipt.find(params[:id])
  cb = CommunicationBuilder.new(resource: @receipt, current_user: current_user)
  redirect_to new_communication_path(cb.to_params)
end

#importedObject

GET /receipts/imported — landing page after a successful
create-from-excel run, listing the freshly-imported receipt rows.



506
507
508
509
# File 'app/controllers/receipts_controller.rb', line 506

def imported
  new_receipt_ids = params[:new_receipt_ids].presence || []
  @imported_receipts = Receipt.where(id: new_receipt_ids)
end

#indexObject

GET /receipts — paginated, ransack-filtered list of customer
receipts, newest first. Excludes the legacy company_id IS NULL
rows that predate the multi-company split.



42
43
44
45
46
47
48
49
50
# File 'app/controllers/receipts_controller.rb', line 42

def index
  @q = Receipt.where.not(company_id: nil).ransack(params[:q])
  @pagy, @receipts = pagy(@q.result.order(created_at: :desc))

  respond_to do |format|
    format.html # index.html.erb
    format.xml  { render xml: @receipts }
  end
end

#invoice_detailObject

GET /receipts/invoice_detail — JSON detail endpoint that returns
an invoice's totals + remaining balance (computed on-the-fly
from receipt_details) for the receipt-form's invoice picker.
Returns {not_found: '1'} when the reference number doesn't
match.



324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'app/controllers/receipts_controller.rb', line 324

def invoice_detail
  @invoice = Invoice.select("invoices.id as invoice_id, (select orders.reference_number from orders where orders.id = invoices.order_id) as order_reference_number, (select parties.full_name from parties where parties.id = invoices.customer_id) as customer_full_name, invoices.total as total, (invoices.total - coalesce((select sum(amount + write_off + discount) from receipt_details where receipt_details.invoice_id = invoices.id and receipt_details.state != 'voided'),0)) as remaining_balance, invoices.gl_date as gl_date, invoices.customer_id, invoices.billing_customer_id").where(reference_number: params[:reference_number]).first

  respond_to do |format|
    format.json do
      if @invoice.nil?
        render json: { not_found: '1' }
      else
        render json: @invoice.as_json.merge('customer_matches' => payor_can_apply_document?(@invoice.customer_id, @invoice.billing_customer_id))
      end
    end
  end
end

#invoice_lookupObject

GET /receipts/invoice_lookup — JSON typeahead endpoint for the
invoice picker on the receipt form. Filters to unpaid invoices,
optionally scoped to a customer, ordered newest-first.



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'app/controllers/receipts_controller.rb', line 341

def invoice_lookup
  q = params[:q]&.strip
  customer_id = params[:customer_id].presence

  results = Invoice.unpaid.includes(:customer).order(id: :desc)
  results = results.where(customer_id: customer_id) if customer_id
  results = results.like_lookup(q) if q.present?

  json_result = TomSelect.format_json_results(self, results, params[:page].presence, params[:per_page].presence || 20) do |inv|
    {
      id: inv.id,
      text: "#{inv.reference_number} - #{inv.customer.try(:full_name)} ($#{inv.balance})"
    }
  end
  render json: json_result
end

#newObject

GET /receipts/new — render the new-receipt form. Two flows:

  • From the invoices/credit-memos index, with ?invoices[]=...
    and/or ?credit_memos[]=... query params: the form is pre-built
    with one ReceiptDetail per selected document. Validates that
    the documents share customer, currency, and company before
    building the receipt.
  • From a single invoice show page, with customer_id /
    invoice_id / balance query params: builds a one-detail
    receipt for that invoice.


93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'app/controllers/receipts_controller.rb', line 93

def new
  @original_params = params.to_query
  if params['invoices'] || params['credit_memos']
    # parse invoices
    invoices = []
    invoice_balance = 0
    if params['invoices']
      params['invoices'].each { |i| invoices << Invoice.find(i) }
      inv_customer = invoices.first.customer
      inv_currency = invoices.first.currency
      inv_company = invoices.first.company
      invoice_balance = invoices.sum(&:balance)
    end

    # parse credit memos
    credit_memos = []
    credit_memo_balance = 0
    if params['credit_memos']
      params['credit_memos'].each { |i| credit_memos << CreditMemo.find(i) }
      cm_customer = credit_memos.first.customer
      cm_currency = credit_memos.first.currency
      cm_company = credit_memos.first.company
      credit_memo_balance = credit_memos.sum(&:balance)
    end

    # need to check that customer, currency and company matches on selections.
    # Parent/child accounts share a billing family, so a child's credit memo
    # against a parent's invoice (or vice versa) is allowed.
    if inv_customer && cm_customer && !inv_customer.same_organization_family?(cm_customer)
      flash[:error] = 'Customer does not match on invoice and credit memo selections'
      redirect_to inv_customer and return
    elsif inv_currency && cm_currency && (inv_currency != cm_currency)
      flash[:error] = 'Currency does not match on invoice and credit memo selections'
      redirect_to inv_customer and return
    elsif inv_company && cm_company && (inv_company != cm_company)
      flash[:error] = 'Company does not match on invoice and credit memo selections'
      redirect_to inv_customer and return
    else
      customer = inv_customer || cm_customer
      currency = inv_currency || cm_currency
      company = inv_company || cm_company
    end

    @receipt = Receipt.new(customer_id: customer.id, customer_name: customer.full_name, company_id: company.id, currency: currency, receipt_date: Date.current, gl_date: Date.current,
amount: (invoice_balance + credit_memo_balance))
    if params['invoices']
      invoices.each do |i|
        @receipt.receipt_details.build(invoice_id: i.id, amount: i.balance, gl_date: Date.current, category: 'Invoice', discount: 0, write_off: 0)
      end
    end
    if params['credit_memos']
      credit_memos.each do |i|
        @receipt.receipt_details.build(credit_memo_id: i.id, amount: i.balance, gl_date: Date.current, category: 'Credit Memo')
      end
    end
  else
    @receipt = Receipt.new(customer_id: params[:customer_id], customer_name: params[:customer_name], company_id: params[:company_id], currency: params[:currency], receipt_date: Date.current, gl_date: Date.current, amount: params[:balance])
    @receipt.receipt_details.build(invoice_id: params[:invoice_id], amount: params[:balance], gl_date: Date.current, category: 'Invoice', discount: 0, write_off: 0)
  end

  @payment = Payment.new(customer: @receipt.customer)

  respond_to do |format|
    format.html # new.html.erb
    format.xml  { render xml: @receipt }
  end
end

#new_paymentObject

GET /receipts/new_payment — variant entry point that opens the
payment-creation form for a customer's outstanding invoices and
credit memos. Resolves the right billing-entity customer (a
contact's parent customer, a buying-group's lead customer, etc.),
nets credit-memo balances against invoices, and refreshes the
customer's saved-card vault before rendering.



289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'app/controllers/receipts_controller.rb', line 289

def new_payment
  @original_params = params.to_query
  @customer = Customer.find(params[:customer_id]).determine_receipt_billing_entity
  invoices = Invoice.where(id: params[:invoices])
  credit_memos = CreditMemo.where(id: params[:credit_memos])
  invoice_balance = invoices.to_a.sum(&:balance)
  calculated_balance = invoice_balance
  currency = invoices.first&.currency
  company = invoices.first&.company

  @receipt = Receipt.new(customer_id: @customer.id, customer_name: @customer.full_name, company: company, bank_account: company&., currency: currency, receipt_date: Date.current, gl_date: Date.current)
  invoices.each do |i|
    @receipt.receipt_details.build(invoice_id: i.id, amount: i.balance, gl_date: Date.current, category: 'Invoice')
  end
  credit_memos.each do |cm|
    amount = cm.balance.abs > calculated_balance ? calculated_balance * -1 : cm.balance
    @receipt.receipt_details.build(credit_memo_id: cm.id, amount: amount, gl_date: Date.current, category: 'Credit Memo')
    calculated_balance += amount
  end

  @receipt.amount = calculated_balance
  @payment = Payment.new(customer: @customer)
  CreditCardVault.sync_stripe_status!(@customer)
end

#project_lookupObject

GET /receipts/project_lookup — JSON typeahead for the GL-project
picker on receipt-detail rows.



398
399
400
401
402
403
404
405
406
407
# File 'app/controllers/receipts_controller.rb', line 398

def project_lookup
  q = params[:q]&.strip

  results = LedgerDetailProject.order(:project_number).limit(20)
  results = results.where('ledger_projects.project_number::text ILIKE ? OR ledger_projects.description ILIKE ?', "%#{q}%", "%#{q}%") if q.present?

  render json: {
    results: results.map { |p| { id: p.id, text: "#{p.project_number} - #{p.summary_name}" } }
  }
end

#showObject

GET /receipts/:id — receipt show page with linked communications.
Eager-loads every association the detail table (_table.html.erb)
reads per row — invoice (+ order/customer/shipping_address),
credit_memo (+ shipping_address), the selected ledger_company_account
(+ company/ledger_detail_account), project, business unit, and creator —
so a 600-line settlement renders in a handful of queries instead of the
~2,300 the per-row N+1 produced.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'app/controllers/receipts_controller.rb', line 59

def show
  @receipt = Receipt.includes(
    receipt_details: [
      { invoice: %i[order customer shipping_address] },
      { credit_memo: :shipping_address },
      { ledger_company_account: %i[company ledger_detail_account] },
      :ledger_detail_project,
      :business_unit,
      :creator
    ]
  ).find(params[:id])

  @q = (@receipt.try(:all_related_communications) || @receipt.communications).ransack(params[:q])
  @q.sorts = 'created_at desc' if @q.sorts.blank?

  @pagy, @communications = pagy(@q.result)
  @sender_filter_list = Employee.all.sorted

  respond_to do |format|
    format.html # show.html.erb
    format.xml  { render xml: @receipt }
  end
end

#updateObject

PATCH/PUT /receipts/:id — update receipt attributes. EDI-imported
receipts that aren't held for review auto-apply after save so
accounting doesn't have to click through twice.



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/controllers/receipts_controller.rb', line 265

def update
  @receipt = receipt_for_form
  @cc_payment = Payment.new
  @check_payment = Payment.new

  respond_to do |format|
    if @receipt.update(params[:receipt])
      @receipt.apply if @receipt.edi_transaction_id.present? && !@receipt.hold_for_review?
      format.html { redirect_to(@receipt, info: 'Receipt was successfully updated.') }
      format.xml  { head :ok }
    else
      @detail_balances = @receipt.detail_document_balances
      format.html { render action: 'edit', status: :unprocessable_content }
      format.xml  { render xml: @receipt.errors, status: :unprocessable_content }
    end
  end
end

#voidObject

GET /receipts/:id/void — render the per-receipt-detail reversal-
date form (each detail can post against a different GL period).
Submission posts to #do_void.



447
448
449
# File 'app/controllers/receipts_controller.rb', line 447

def void
  @receipt = Receipt.find(params[:id])
end