Class: Crm::PaymentsController

Inherits:
CrmController show all
Includes:
StripeCustomerManagement
Defined in:
app/controllers/crm/payments_controller.rb

Overview

Controller: payments.

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

#approveObject

POST /payments/:id/approve — flip payment_approved so the order's
release-authorization hold can clear. Note: the order is not
auto-released (the inline comment is intentional — accounting
decides). Compare to #approve_fraud_report which does release.



171
172
173
174
175
176
177
178
179
180
# File 'app/controllers/crm/payments_controller.rb', line 171

def approve
  @payment = Payment.find(params[:id])
  @payment.update(payment_approved: true)
  @order = @payment.order
  if @order&.pending_release_authorization?
    # @order.release_order  #For now we don't release automatically. We let accounting decide
  end
  flash[:info] = 'Payment has been approved.'
  redirect_to_return_path_or_default(order_url(@order))
end

#approve_fraud_reportObject

POST /payments/:id/approve_fraud_report — accept the fraud-report
findings and immediately release the order out of
pending_release_authorization. Used when fraud team has cleared
the customer.



186
187
188
189
190
191
192
193
# File 'app/controllers/crm/payments_controller.rb', line 186

def approve_fraud_report
  @payment = Payment.find(params[:id])
  @payment.update(payment_approved: true)
  @order = @payment.order
  @order.release_order if @order&.pending_release_authorization?
  flash[:info] = 'Fraud report has been approved.'
  redirect_to_return_path_or_default(order_url(@order))
end

#capture_fundsObject

GET /payments/:id/capture_funds — render the partial-capture
amount form. POST /payments/:id/capture_funds — execute the
capture through the payment's gateway. Multi-capture-capable
gateways (Stripe) accept a partial amount and pass
final_capture: false so the residual auth stays alive; others
capture the full remaining amount in one shot.



503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
# File 'app/controllers/crm/payments_controller.rb', line 503

def capture_funds
  @payment = Payment.find(params[:id])
  @order = @payment.order

  if request.get?
    @max_capturable = @payment.amount - @payment.total_captured
    @capture_amount = @max_capturable
    render :capture_funds
  else
    max_capturable = @payment.amount - @payment.total_captured

    if @payment.supports_multicapture?
      begin
        capture_amount = BigDecimal(params[:capture_amount].to_s)
      rescue ArgumentError
        flash[:error] = "Invalid amount format"
        redirect_to capture_funds_payment_path(@payment) and return
      end

      if capture_amount <= 0
        flash[:error] = 'Capture amount must be greater than zero.'
        redirect_to capture_funds_payment_path(@payment)
        return
      end

      if capture_amount > max_capturable
        flash[:error] = "Capture amount cannot exceed the remaining authorized amount (#{helpers.number_to_currency(max_capturable, unit: @order.currency_symbol)})."
        redirect_to capture_funds_payment_path(@payment)
        return
      end
    else
      capture_amount = max_capturable
    end

    # `final_capture: true` tells Stripe "I'm done with this PaymentIntent
    # — release any remaining authorization." That's only safe to send when
    # there is genuinely nothing left to capture on the PI: both (a) we're
    # taking the whole of this payment's headroom and (b) no other Payment
    # row sharing the same PI is still waiting to capture. Without the
    # sibling check, capturing the full amount of a shared-PI split-order
    # sibling finalises the PI and silently releases the auth owed to the
    # other sibling — same class of bug as the splitter's old
    # `increment_authorization` shortcut. See PI
    # `pi_3TZJhGHKwX57gwKi19wJHQYs` (Payment 285776 / SO725801) where this
    # voided sibling 285775's $1,970.29 of authorization on the
    # `Crm::PaymentsController#capture_funds` click.
    is_partial = @payment.supports_multicapture? && (
      capture_amount < max_capturable || @payment.shared_pi_siblings.exists?
    )
    res = @payment.gateway_class.new(@payment).capture(capture_amount, final_capture: !is_partial)
    if res.success
      flash[:info] = "#{helpers.number_to_currency(capture_amount, unit: @order.currency_symbol)} captured successfully."
    else
      flash[:error] = res.message.presence || "Couldn't capture this payment."
    end
    redirect_to order_path(@order, tab: 'payments')
  end
end

#check_invoice_statusObject

POST /payments/:id/check_invoice_status — re-poll PayPal for the
status of a PayPal-Invoice payment. When the invoice is now paid
the order's release-authorization hold can clear.



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'app/controllers/crm/payments_controller.rb', line 198

def check_invoice_status
  if @order.pending_release_authorization?
    @payment = @order.payments.find(params[:id])
    paid = @payment.check_paypal_invoice_payment_status
    if paid
      flash[:info] = 'Invoice has been paid, releasing order'
    else
      flash[:error] = 'Invoice has not yet been paid'
    end
    #   @order.reload
    #   if @order.payments.all_authorized.any? {|pp| pp.category == Payment::PAYPAL_INVOICE and !pp.captured?}
    #     flash[:info] = "Order has another PayPal Invoice which needs to be paid before order can be released."
    #   else
    #     @order.release_order_from_pending_release_authorization
    #     flash[:info] = "Invoice has been paid, releasing order"
    #   end
    # else
    #   flash[:error] = "Invoice has not yet been paid"
    # end
  else
    flash[:info] = 'Order has already been released'
  end
  redirect_to order_url(@order)
end

#check_stripe_statusObject

POST /payments/:id/check_stripe_status — on-demand reconciliation
of an authorised CC payment against Stripe. Useful when a capture
was performed directly in the Stripe dashboard and the webhook
never fired (or fired and was ignored). Updates payment state
in-place and reports the before/after transition in flash.



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'app/controllers/crm/payments_controller.rb', line 327

def check_stripe_status
  @payment = Payment.find(params[:id])
  authorize! :read, @payment

  unless @payment.authorization_type == 'credit_card'
    flash[:error] = 'Checking Stripe status applies to credit card payments only.'
    redirect_to transactions_payment_path(@payment) and return
  end

  unless @payment.authorized?
    flash[:info] = "Payment is #{@payment.state}; Stripe sync only runs for authorized card payments."
    redirect_to transactions_payment_path(@payment) and return
  end

  @payment.update_columns(authorization_code: @payment.stripe_payment_intent_id) if @payment.authorization_code.blank? && @payment.stripe_payment_intent_id.to_s.start_with?('pi_')

  if @payment.authorization_code.blank?
    flash[:error] = 'No Stripe PaymentIntent ID is linked to this payment.'
    redirect_to transactions_payment_path(@payment) and return
  end

  previous_state = @payment.state
  @payment.check_cc_payment_status
  @payment.reload

  flash[:info] = if @payment.state == previous_state
                   "Stripe check complete; payment remains #{@payment.human_state_name.downcase}."
                 else
                   "Updated from #{previous_state} to #{@payment.state} based on Stripe."
                 end
  redirect_to transactions_payment_path(@payment)
rescue StandardError => e
  ErrorReporting.error(e)
  flash[:error] = "Could not check Stripe: #{e.message}"
  redirect_to transactions_payment_path(@payment)
end

#confirm_voidObject

POST /payments/:id/confirm_void — actually void the payment via its
gateway. On gateway failure, re-renders the confirmation page with
the gateway error so the user can retry or escalate.



136
137
138
139
140
141
142
143
144
145
146
# File 'app/controllers/crm/payments_controller.rb', line 136

def confirm_void
  result = @payment.gateway_class.new(@payment).void
  if result.success
    flash[:info] = 'Payment has been successfully voided.'
    redirect_to order_path(@order, tab: 'payments')
  else
    flash.now[:error] = void_failure_message(result)
    load_void_confirmation_data
    render :void_confirmation, status: :unprocessable_content
  end
end

#confirm_void_sharedObject

POST /payments/:id/confirm_void_shared — variant of #confirm_void
that also marks every sibling payment authorized against the same
gateway transaction (Stripe's shared-PaymentIntent feature) as
voided in our database, since one void at the gateway voids all of
them.



153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'app/controllers/crm/payments_controller.rb', line 153

def confirm_void_shared
  siblings = @payment.shared_pi_siblings
  result = @payment.gateway_class.new(@payment).void
  if result.success
    siblings.each(&:payment_voided!)
    flash[:info] = "Voided #{siblings.count + 1} payments sharing the same authorization."
    redirect_to order_url(@order)
  else
    flash.now[:error] = void_failure_message(result)
    load_void_confirmation_data
    render :void_confirmation, status: :unprocessable_content
  end
end

#createObject

POST /orders/:order_id/payments — main "take payment" flow:
delegates to Payment::OrderProcessor to authorise via the
appropriate gateway, attaches an optional purchase-order PDF, and
warns when a duplicate PO number is detected on another order for
the same customer. Redirects depending on whether the order is
now fully authorised, partially authorised, or failed.



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'app/controllers/crm/payments_controller.rb', line 52

def create
  if (ppaa = params.dig(:payment))
    ppaa['remote_ip_address'] = NetworkConstants::HEATWAVE_PUBLIC_IP
    ppaa['skip_minfraud'] = true
    if ppaa['category'] == 'PayPal Invoice' && ppaa['amount'].present?
      begin
        ppaa['amount'] = [BigDecimal(ppaa['amount'].to_s), @order.balance].min
      rescue ArgumentError
        flash[:error] = "Invalid amount format"
        redirect_to order_url(@order) and return
      end
    end
  end
  @payment = @order.payments.build(params[:payment])
  @upload = Upload.new
  # Check if the PO was already used on this customer's orders
  if @payment.po_number.present? && (order_numbers = @order.customer.orders.active.joins(:payments).where.not(Payment[:state].eq('voided')).where(Payment[:po_number].matches(@payment.po_number)).pluck(:reference_number)) && order_numbers.present?
    flash[:warning] = "PO ##{@payment.po_number} was already used on order(s) #{order_numbers.join(', ')}"
  end
  if params.dig(:purchase_order_upload, :attachment).present?
    # The PO upload is an optional attachment — we don't want a wrong mime
    # type (e.g. user uploaded a JPG by mistake) to 500 the whole payment
    # flow. Surface the validation error as a flash and continue processing
    # the payment. (AppSignal #4726.)
    po_upload = @order.uploads.build(params[:purchase_order_upload])
    unless po_upload.save
      upload_warning = "Purchase order attachment was not saved: #{po_upload.errors.full_messages.to_sentence}"
      flash[:warning] = [flash[:warning].presence, upload_warning].compact.join(' ')
    end
  end

  if @payment.category == Payment::CREDIT_CARD && (@duplicate_matches = duplicate_charge_matches).any?
    flash.now[:warning] = 'Possible duplicate charge — see banner above. Verify in Stripe before retrying.'
    @report_errors_for = [@payment]
    @payment_options = @order.payment_options('crm')
    set_incrementable_authorizations
    @payment_preference = @customer&.preferred_payment_method
    render :new, status: :unprocessable_content
    return
  end

  res = Payment::OrderProcessor.new(@order, params[:payment]).process
  if %w[invalid failed].include?(res.result)
    flash.now[:error] = res.error
    flash.now[:notice] = res.notice
    # @payment = res.payment || @order.payments.build(params[:payment])
    @report_errors_for = [@payment]
    @payment_options = @order.payment_options('crm')
    set_incrementable_authorizations
    @payment_preference = @customer&.preferred_payment_method
    render :new, status: :unprocessable_content
  elsif res.result == 'partially_authorized'
    redirect_to new_order_payment_path(@order), info: res.notice, error: res.error
  elsif res.result == 'fully_authorized'
    redirect_to order_path(@order), info: res.notice, error: res.error
  end
end

#create_payment_intentObject

POST /orders/:order_id/payments/create_payment_intent —
JSON endpoint that mints a Stripe PaymentIntent for the order
and returns its client_secret to the front-end Stripe Elements
widget. Caps the amount at the order's balance and the Stripe
minimum (50¢). Lazily creates a Stripe Customer record for the
heatwave customer if one isn't already linked.



568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
# File 'app/controllers/crm/payments_controller.rb', line 568

def create_payment_intent
  currency = @order.currency
  max_cents = (@order.balance * 100).to_i
  requested_cents = params[:amount_cents].present? ? params[:amount_cents].to_i : max_cents
  amount_cents = [requested_cents, max_cents].min
  amount_cents = [amount_cents, 50].max # Stripe minimum is 50 cents

  ensure_stripe_customer_id!(@customer, currency) if @customer.present?

  description = @order.reference_number.present? ? "Order #{@order.reference_number}" : "Order ID #{@order.id}"
  statement_descriptor = @order.reference_number.present? ? "WarmlyYours #{@order.reference_number}".truncate(22) : nil

  pi_params = {
    amount_cents: amount_cents,
    currency: currency,
    capture_method: 'manual',
    payment_method_types: ['card'],
    description: description,
    statement_descriptor: statement_descriptor,
    metadata: {
      order_id: @order.id,
      customer_id: @customer&.id
    }
  }
  pi_params[:customer_id] = @customer.stripe_customer_id if @customer&.stripe_customer_id.present?
  pi_params[:setup_future_usage] = 'off_session' if pi_params[: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

#create_paypal_from_vaultObject

POST /orders/:order_id/payments/create_paypal_from_vault — sweep
the order's deliveries, creating one Payment per delivery
authorised against the customer's saved PayPal vault token until
the order's balance is covered. Reports per-delivery success and
advances the order if the balance reaches zero.



423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
# File 'app/controllers/crm/payments_controller.rb', line 423

def create_paypal_from_vault
  customer = @order.customer
  if customer&.paypal_vault_token_id.blank?
    flash[:error] = 'Customer does not have a saved PayPal wallet.'
    return redirect_to new_order_payment_path(@order)
  end

  @order.reload
  unless @order.balance.positive?
    flash[:error] = 'Order has no outstanding balance.'
    return redirect_to order_path(@order)
  end

  deliveries_to_pay = @order.deliveries.select { |d| d.balance.positive? }
  if deliveries_to_pay.empty?
    flash[:error] = 'No deliveries with outstanding balance.'
    return redirect_to order_path(@order, tab: 'payments')
  end

  amount_remaining = @order.balance
  total_authorized = BigDecimal("0")
  payments_created = []

  deliveries_to_pay.each do |delivery|
    break unless amount_remaining.positive?

    amount = [delivery.balance, amount_remaining].min
    next unless amount.positive?

    payment = @order.payments.create!(
      currency: @order.currency,
      category: Payment::PAYPAL,
      authorization_type: 'paypal',
      amount: amount,
      email: customer.email,
      customer: customer,
      delivery: delivery,
      paypal_metadata: {
        'vault_authorized' => true,
        'vault_token_id' => customer.paypal_vault_token_id
      }
    )

    result = Payment::Gateways::Paypal.new(payment).authorize_from_vault(customer.paypal_vault_token_id)
    payments_created << { payment: payment, success: result.success, message: result.message, amount: amount }

    if result.success
      total_authorized += amount
      amount_remaining -= amount
    end
  end

  if payments_created.empty?
    flash[:error] = 'No payments could be created. Check delivery balances.'
    return redirect_to order_path(@order, tab: 'payments')
  end

  successes = payments_created.count { |p| p[:success] }
  failures = payments_created.count { |p| !p[:success] }

  if failures.zero? && successes.positive?
    flash[:info] = "PayPal vault authorization successful for #{helpers.number_to_currency(total_authorized, unit: @order.currency_symbol)}."
    @order.reload
    @order.payment_complete if @order.balance <= 0
    redirect_to order_path(@order, tab: 'payments')
  elsif successes.positive?
    flash[:warning] = "#{successes} payment(s) authorized (#{helpers.number_to_currency(total_authorized, unit: @order.currency_symbol)}), #{failures} failed. Check payment details."
    redirect_to order_path(@order, tab: 'payments')
  else
    flash[:error] = "PayPal vault authorization failed: #{payments_created.first&.dig(:message)}"
    redirect_to new_order_payment_path(@order)
  end
end

#editObject

GET /orders/:order_id/payments/:id/edit — load the edit form for an
existing payment row. Mostly used for editing PO numbers and notes
on terms/PO payments; CC/PayPal authorisations are immutable on
the gateway side once placed.



42
43
44
# File 'app/controllers/crm/payments_controller.rb', line 42

def edit
  @payment = @order.payments.find(params[:id])
end

#fraud_reportObject

GET /payments/:id/fraud_report — render the fraud-report detail
page for review.



286
287
288
289
# File 'app/controllers/crm/payments_controller.rb', line 286

def fraud_report
  @payment = Payment.find(params[:id])
  @fraud_report = @payment.fraud_report
end

#increment_authorizationObject

POST /orders/:order_id/payments/:id/increment_authorization —
raise the authorised amount on a credit-card auth without creating
a new payment row (for cards that support incremental
authorization, currently Stripe). Hard-capped at 10 increments
per payment to limit gateway round-trips. Refuses to increment
after any capture has happened (the auth is no longer fully
incrementable at that point).



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'app/controllers/crm/payments_controller.rb', line 371

def increment_authorization
  @payment = @order.payments.find(params[:id])
  begin
    new_amount = BigDecimal(params[:amount])
  rescue ArgumentError
    flash[:error] = "Invalid amount format"
    redirect_to order_url(@order) and return
  end

  unless @payment.supports_incremental_authorization?
    flash[:error] = 'This payment does not support incremental authorization.'
    return redirect_to new_order_payment_path(@order)
  end

  unless @payment.authorized?
    flash[:error] = 'Payment must be in authorized state to increment.'
    return redirect_to new_order_payment_path(@order)
  end

  if @payment.total_captured.positive?
    flash[:error] = 'Cannot increment authorization after funds have been captured. Create a new payment instead.'
    return redirect_to new_order_payment_path(@order)
  end

  if @payment.transactions.where(action: 'incremental_authorization').count >= 10
    flash[:error] = 'Maximum of 10 incremental authorization attempts reached. Please create a new payment.'
    return redirect_to new_order_payment_path(@order)
  end

  res = Payment::Gateways::CreditCard.new(@payment).increment_authorization(new_amount)
  if res.success
    flash[:info] = "Authorization incremented to #{number_to_currency(new_amount)}."
    @order.reload
    if @order.balance <= 0 && @order.payment_complete
      redirect_to order_path(@order)
    elsif @order.balance <= 0
      flash[:warning] = "Balance is covered but order could not advance: #{@order.errors_to_s}"
      redirect_to order_path(@order)
    else
      redirect_to new_order_payment_path(@order)
    end
  else
    flash[:error] = "Failed to increment authorization: #{res.message}"
    redirect_to new_order_payment_path(@order)
  end
end

#newObject

GET /orders/:order_id/payments/new — render the "take payment" form
for an order. Builds a default Payment pre-filled with the
remaining balance and a category from
Order#payment_options('crm'). Refreshes the customer's saved
cards from Stripe before rendering so the picker shows the latest
vault state.



23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'app/controllers/crm/payments_controller.rb', line 23

def new
  if @order.valid?
    @payment_options = @order.payment_options('crm')
    @payment = @order.payments.build(category: @payment_options.first, amount: @order.balance, po_number: @order.rma.presence&.original_po_number)
    @upload = Upload.new

    set_incrementable_authorizations
    CreditCardVault.sync_stripe_status!(@customer)
    @payment_preference = @customer&.preferred_payment_method
  else
    flash[:error] = "Unable to take payment as there are problems with the order. Errors: #{@order.errors_to_s}."
    redirect_to @order
  end
end

#reauthorize_paypalObject

POST /payments/:id/reauthorize_paypal — refresh PayPal's view of
the payment status. Most useful when a webhook was missed and our
local state has drifted from PayPal's.



311
312
313
314
315
316
317
318
319
320
# File 'app/controllers/crm/payments_controller.rb', line 311

def reauthorize_paypal
  @payment = Payment.find(params[:id])
  result = @payment.check_paypal_payment_status
  if result.ok?
    flash[:info] = result.message
  else
    flash[:error] = result.message
  end
  redirect_to_return_path_or_default order_path(@payment.order)
end

#resend_paypal_invoiceObject

POST /payments/:id/resend_paypal_invoice — ping PayPal to email the
customer another copy of the original PayPal-Invoice notification.



225
226
227
228
229
230
231
232
233
234
# File 'app/controllers/crm/payments_controller.rb', line 225

def resend_paypal_invoice
  @payment = @order.payments.find(params[:id])
  res = @payment.resend_paypal_invoice
  if res[:success]
    flash[:info] = 'Invoice successfully resent'
  else
    flash[:error] = "Unable to resend invoice, error message: #{res[:message]}"
  end
  redirect_to order_path(@order)
end

#send_receiptObject

GET /payments/:id/send_receipt — open a draft communication with the
payment's receipt template pre-bound. User picks recipients on the
next screen.



239
240
241
242
243
# File 'app/controllers/crm/payments_controller.rb', line 239

def send_receipt
  @payment = @order.payments.find(params[:id])
  cb = CommunicationBuilder.new(resource: @payment, current_user: current_user)
  redirect_to new_communication_path(cb.to_params)
end

#showObject

GET /payments/:id — historical entry point that immediately redirects
to the canonical #transactions view. Kept so old bookmarked links
still land on the right page.



12
13
14
15
# File 'app/controllers/crm/payments_controller.rb', line 12

def show
  @payment = Payment.find(params[:id])
  redirect_to transactions_payment_path(@payment)
end

#skip_auto_receiptObject

POST /payments/:id/skip_auto_receipt — toggle whether the post-
capture receipt-creation hook runs for this payment. Used when
accounting wants to issue a custom receipt manually.



272
273
274
275
276
277
278
279
280
281
282
# File 'app/controllers/crm/payments_controller.rb', line 272

def skip_auto_receipt
  @payment = Payment.find(params[:id])
  skip = params[:skip]
  if @payment.update(skip_auto_receipt: skip)
    flash[:info] = "Skipping auto receipt set to #{skip}"
    redirect_to_return_path_or_default transactions_payment_path(@payment)
  else
    flash[:error] = "An error occurred. Couldn't set the skip auto receipt vaariable for this payment."
    redirect_to_return_path_or_default transactions_payment_path(@payment)
  end
end

#skip_paymentObject

POST /orders/:order_id/payments/skip_payment — release the order
to the warehouse without further payment when the order's balance
is already covered (zero balance). Surfaces a corrective flash for
negative or positive balances.



114
115
116
117
118
119
120
121
122
123
124
# File 'app/controllers/crm/payments_controller.rb', line 114

def skip_payment
  set_variables
  if @order.balance < 0
    flash[:error] = 'Order has negative balance, please correct. '
  elsif @order.balance > 0
    flash[:error] = 'Order has a positive balance, please process payment normally.'
  elsif @order.balance == 0
    flash[:error] = "Order cannot be released to the warehouse. #{@order.errors_to_s} #{@order.shipping_address.errors_to_s}" unless @order.payment_complete
  end
  redirect_to order_url(@order)
end

#transactionsObject

GET /payments/:id/transactions — show the gateway-side transaction
history (auths, captures, voids, refunds) for a single payment.



265
266
267
# File 'app/controllers/crm/payments_controller.rb', line 265

def transactions
  @payment = Payment.find(params[:id])
end

#unhide_vaultObject

POST /payments/:id/unhide_vault — toggle the hidden flag on the
payment's stored CreditCardVault so a previously-hidden card
comes back into the customer's saved-cards picker (or vice versa).
Authorisation gated by the unhide_vault ability.



295
296
297
298
299
300
301
302
303
304
305
306
# File 'app/controllers/crm/payments_controller.rb', line 295

def unhide_vault
  @payment = Payment.find(params[:id])
  authorize! :unhide_vault, @payment
  if @payment.credit_card_vault.nil?
    flash[:error] = 'No vault found'
  else
    new_hidden = !@payment.credit_card_vault.hidden?
    @payment.credit_card_vault.update(hidden: new_hidden)
    flash[:info] = new_hidden ? 'Vault has been hidden' : 'Vault has been unhidden'
  end
  redirect_to_return_path_or_default transactions_payment_path(@payment)
end

#updateObject

PATCH/PUT /payments/:id — update payment metadata (PO number,
category, etc.) and optionally attach an uploaded purchase-order PDF.



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'app/controllers/crm/payments_controller.rb', line 247

def update
  @payment = @order.payments.find(params[:id])
  if @payment.update(params[:payment])
    m = 'Payment information has been updated'
    if params.dig(:purchase_order_upload, :attachment).present?
      u = @order.uploads.build(params[:purchase_order_upload])
      m + ". Unable to save purchase order pdf. #{u.errors_to_s}" unless u.save
    end

    flash[:info] = 'Payment information has been updated'
    redirect_to @order
  else
    render action: :edit, status: :unprocessable_content
  end
end

#voidObject

GET /payments/:id/void — render the "are you sure?" confirmation
screen before actually voiding. Submission posts to #confirm_void.



128
129
130
131
# File 'app/controllers/crm/payments_controller.rb', line 128

def void
  load_void_confirmation_data
  render :void_confirmation
end