sharetribe/sharetribe

View on GitHub
app/controllers/transactions_controller.rb

Summary

Maintainability
D
2 days
Test Coverage
class TransactionsController < ApplicationController

  before_action only: [:show] do |controller|
    controller.ensure_logged_in t("layouts.notifications.you_must_log_in_to_view_your_inbox")
  end

  before_action only: [:new] do |controller|
    fetch_data(params[:listing_id]).on_success do |listing_id, listing_model, _, process|
      record_event(
        flash,
        "BuyButtonClicked",
        { listing_id: listing_id,
          listing_uuid: listing_model.uuid_object.to_s,
          payment_process: process.process,
          user_logged_in: @current_user.present?
        })
    end
  end

  before_action do |controller|
    controller.ensure_logged_in t("layouts.notifications.you_must_log_in_to_do_a_transaction")
  end

  TransactionForm = EntityUtils.define_builder(
    [:listing_id, :fixnum, :to_integer, :mandatory],
    [:message, :string],
    [:quantity, :fixnum, :to_integer, default: 1],
    [:start_on, transform_with: ->(v) { Maybe(v).map { |d| TransactionViewUtils.parse_booking_date(d) }.or_else(nil) }],
    [:end_on, transform_with: ->(v) { Maybe(v).map { |d| TransactionViewUtils.parse_booking_date(d) }.or_else(nil) }]
  )

  def new
    Result.all(
      -> {
        fetch_data(params[:listing_id])
      },
      ->((listing_id, listing_model)) {
        ensure_can_start_transactions(listing_model: listing_model, current_user: @current_user, current_community: @current_community)
      }
    ).on_success { |((listing_id, listing_model, author_model, process, gateway))|
      transaction_params = HashUtils.symbolize_keys(
        {listing_id: listing_model.id}
        .merge(params.slice(:start_on, :end_on, :quantity, :delivery, :start_time, :end_time, :per_hour).permit!)
      )

      case [process.process, gateway]
      when matches([:none])
        render_free(listing_model: listing_model, author_model: author_model, community: @current_community, params: transaction_params)
      when matches([:preauthorize, :paypal]), matches([:preauthorize, :stripe]), matches([:preauthorize, [:paypal, :stripe]])
        redirect_to initiate_order_path(transaction_params)
      else
        opts = "listing_id: #{listing_id}, payment_gateway: #{gateway}, payment_process: #{process}, booking: #{booking}"
        raise ArgumentError.new("Cannot find new transaction path to #{opts}")
      end
    }.on_error { |error_msg, data|
      flash[:error] = Maybe(data)[:error_tr_key].map { |tr_key| t(tr_key) }.or_else("Could not start a transaction, error message: #{error_msg}")
      redirect_to(session[:return_to_content] || root)
    }
  end

  def create
    Result.all(
      -> {
        TransactionForm.validate(params.to_unsafe_hash)
      },
      ->(form) {
        fetch_data(form[:listing_id])
      },
      ->(form, (_, _, _, process)) {
        validate_form(form, process)
      },
      ->(_, (listing_id, listing_model), _) {
        ensure_can_start_transactions(listing_model: listing_model, current_user: @current_user, current_community: @current_community)
      },
      ->(form, (listing_id, listing_model, author_model, process, gateway), _, _) {
        booking_fields = Maybe(form).slice(:start_on, :end_on).select { |booking| booking.values.all? }.or_else({})

        is_booking = date_selector?(listing_model)
        quantity = calculate_quantity(tx_params: {
                                        quantity: form[:quantity],
                                        start_on: booking_fields.dig(:start_on),
                                        end_on: booking_fields.dig(:end_on)
                                      },
                                      is_booking: is_booking,
                                      unit: listing_model.unit_type&.to_sym)


        transaction_service.create(
          {
            transaction: {
              community_id: @current_community.id,
              community_uuid: @current_community.uuid_object,
              listing_id: listing_id,
              listing_uuid: listing_model.uuid_object,
              listing_title: listing_model.title,
              starter_id: @current_user.id,
              starter_uuid: @current_user.uuid_object,
              listing_author_id: author_model.id,
              listing_author_uuid: author_model.uuid_object,
              unit_type: listing_model.unit_type,
              unit_price: listing_model.price || Money.new(0, @current_community.currency),
              unit_tr_key: listing_model.unit_tr_key,
              availability: listing_model.availability,
              listing_quantity: quantity,
              content: form[:message],
              starting_page: ::Conversation::PAYMENT,
              booking_fields: booking_fields,
              payment_gateway: process.process == :none ? :none : gateway, # TODO This is a bit awkward
              payment_process: process.process
            }
          })
      }
    ).on_success { |(_, (_, _, _, process), _, _, tx)|
      after_create_actions!(process: process, transaction: tx[:transaction], community_id: @current_community.id)
      flash[:notice] = after_create_flash(process: process) # add more params here when needed
      redirect_to after_create_redirect(process: process, starter_id: @current_user.id, transaction: tx[:transaction]) # add more params here when needed
    }.on_error { |error_msg, data|
      flash[:error] = Maybe(data)[:error_tr_key].map { |tr_key| t(tr_key) }.or_else("Could not start a transaction, error message: #{error_msg}")
      redirect_to(session[:return_to_content] || root)
    }
  end

  def show
    @transaction = @current_community.transactions.find(params[:id])
    m_admin = @current_user.has_admin_rights?(@current_community)
    m_participant = @current_user.id == @transaction.starter_id || @current_user.id == @transaction.listing_author_id

    unless m_admin || m_participant
      flash[:error] = t("layouts.notifications.you_are_not_authorized_to_view_this_content")
      return redirect_to search_path
    end

    role =
      if m_participant
        :participant
      elsif m_admin
        :admin
      end

    @conversation = @transaction.conversation
    @listing = @transaction.listing

    messages_and_actions = TransactionViewUtils.merge_messages_and_transitions(
      TransactionViewUtils.conversation_messages(@conversation.messages, @current_community.name_display_type),
      TransactionViewUtils.transition_messages(@transaction, @conversation, @current_community))

    @transaction.mark_as_seen_by_current(@current_user.id)

    is_author = @transaction.listing_author_id == @current_user.id

    render "transactions/show", locals: {
      messages: messages_and_actions.reverse,
      conversation_other_party: @conversation.other_party(@current_user),
      is_author: is_author,
      role: role,
      message_form: Message.new({sender_id: @current_user.id, conversation_id: @conversation.id}),
      message_form_action: person_message_messages_path(@current_user, :message_id => @conversation.id),
      price_break_down_locals: price_break_down_locals(@transaction, @conversation)
    }
  end

  def created
    proc_status = transaction_service.finalize_create(
      community_id: @current_community.id,
      transaction_id: params[:transaction_id],
      force_sync: false)

    unless proc_status[:success]
      flash[:error] = t("error_messages.booking.booking_failed_payment_voided")
      return redirect_to search_path
    end

    tx_fields = proc_status.dig(:data, :transaction_service_fields) || {}
    process_token = tx_fields[:process_token]
    process_completed = tx_fields[:completed]

    if process_token.present?
      redirect_url = transaction_finalize_processed_path(process_token)

      if process_completed
        redirect_to redirect_url
      else
        # Operation was performed asynchronously

        # We're using here the same PayPal spinner, although we could
        # create a new one for TransactionService.
        render "paypal_service/success", layout: false, locals: {
                 op_status_url: transaction_op_status_path(process_token),
                 redirect_url: redirect_url
               }
      end
    else
      handle_finalize_proc_result(proc_status)
    end
  end

  def finalize_processed
    process_token = params[:process_token]

    proc_status = transaction_process_tokens.get_status(UUIDTools::UUID.parse(process_token))
    unless (proc_status[:success] && proc_status[:data][:completed])
      return redirect_to error_not_found_path
    end

    handle_finalize_proc_result(proc_status[:data][:result])
  end

  def transaction_op_status
    process_token = params[:process_token]

    resp = Maybe(process_token)
             .map { |ptok|
               uuid = UUIDTools::UUID.parse(process_token)
               transaction_process_tokens.get_status(uuid)
             }
             .select(&:success)
             .data
             .or_else(nil)

    if resp
      render json: process_resp_to_json(resp)
    else
      head :not_found
    end
  end

  def person_with_url(person)
    {
      person: person_entity,
      url: person_path(username: person.username),
      display_name: PersonViewUtils.person_display_name(person, @current_community.name_display_type)
    }
  end

  private

  def handle_finalize_proc_result(response)
    response_data = response[:data] || {}

    if response[:success]
      tx = response_data[:transaction]

      record_event(
        flash,
        "TransactionCreated",
        { listing_id: tx.listing_id,
          listing_uuid: UUIDUtils.parse_raw(tx.listing_uuid).to_s,
          transaction_id: tx.id,
          payment_process: tx.payment_process })

      redirect_to person_transaction_path(person_id: @current_user.id, id: tx.id)
    else
      listing_id = response_data[:listing_id]

      flash[:error] =
        case response_data[:reason]
        when :connection_issue
          t("error_messages.booking.booking_failed_payment_voided")
        when :double_booking
          t("error_messages.booking.double_booking_payment_voided")
        else
          t("error_messages.booking.booking_failed_payment_voided")
        end

      redirect_to person_listing_path(person_id: @current_user.id, id: listing_id)
    end
  end

  def ensure_can_start_transactions(listing_model:, current_user:, current_community:)
    error =
      if listing_model.closed?
        "layouts.notifications.you_cannot_reply_to_a_closed_offer"
      elsif listing_model.author == current_user
       "layouts.notifications.you_cannot_send_message_to_yourself"
      elsif !Policy::ListingPolicy.new(listing_model, current_community, current_user).visible?
        "layouts.notifications.you_are_not_authorized_to_view_this_content"
      end

    if error
      Result::Error.new(error, {error_tr_key: error})
    else
      Result::Success.new
    end
  end

  def after_create_flash(process:)
    case process.process
    when :none
      t("layouts.notifications.message_sent")
    else
      raise NotImplementedError.new("Not implemented for process #{process}")
    end
  end

  def after_create_redirect(process:, starter_id:, transaction:)
    case process.process
    when :none
      person_transaction_path(person_id: starter_id, id: transaction[:id])
    else
      raise NotImplementedError.new("Not implemented for process #{process}")
    end
  end

  def after_create_actions!(process:, transaction:, community_id:)
    case process.process
    when :none
      # TODO Do I really have to do the state transition here?
      # Shouldn't it be handled by the TransactionService
      TransactionService::StateMachine.transition_to(transaction[:id], "free")

      transaction = Transaction.find(transaction[:id])

      Delayed::Job.enqueue(MessageSentJob.new(transaction.conversation.messages.last.id, community_id))
    else
      raise NotImplementedError.new("Not implemented for process #{process}")
    end
  end

  # Fetch all related data based on the listing_id
  #
  # Returns: Result::Success([listing_id, listing_model, author, process, gateway])
  #
  def fetch_data(listing_id)
    Result.all(
      -> {
        if listing_id.nil?
          Result::Error.new("No listing ID provided")
        else
          Result::Success.new(listing_id)
        end
      },
      ->(l_id) {
        Maybe(@current_community.listings.where(id: l_id).first)
          .map     { |listing_model| Result::Success.new(listing_model) }
          .or_else { Result::Error.new("Cannot find listing with id #{l_id}") }
      },
      ->(_, listing_model) {
        Result::Success.new(listing_model.author)
      },
      ->(_, listing_model, *rest) {
        TransactionService::API::Api.processes.get(community_id: @current_community.id, process_id: listing_model.transaction_process_id)
      },
      ->(*) {
        Result::Success.new(@current_community.active_payment_types)
      }
    )
  end

  def validate_form(form_params, process)
    if process.process == :none && form_params[:message].blank?
      Result::Error.new("Message cannot be empty")
    else
      Result::Success.new
    end
  end

  def price_break_down_locals(tx, conversation)
    if tx.payment_process == :none && tx.unit_price.cents == 0 || conversation.starting_page == Conversation::LISTING
      nil
    else
      localized_unit_type = tx.unit_type.present? ? ListingViewUtils.translate_unit(tx.unit_type, tx.unit_tr_key) : nil
      localized_selector_label = tx.unit_type.present? ? ListingViewUtils.translate_quantity(tx.unit_type, tx.unit_selector_tr_key) : nil
      booking = !!tx.booking
      booking_per_hour = tx.booking&.per_hour
      quantity = tx.listing_quantity
      show_subtotal = !!tx.booking || quantity.present? && quantity > 1 || tx.shipping_price.present?
      total_label = (tx.payment_process != :preauthorize) ? t("transactions.price") : t("transactions.total")
      payment = TransactionService::Transaction.payment_details(tx)

      TransactionViewUtils.price_break_down_locals({
        listing_price: tx.unit_price,
        localized_unit_type: localized_unit_type,
        localized_selector_label: localized_selector_label,
        booking: booking,
        start_on: booking ? tx.booking.start_on : nil,
        end_on: booking ? tx.booking.end_on : nil,
        duration: booking ? tx.listing_quantity : nil,
        quantity: quantity,
        subtotal: show_subtotal ? tx.item_total : nil,
        total: Maybe(tx.payment_total).or_else(payment[:total_price]),
        seller_gets: Maybe(tx.payment_total).or_else(payment[:total_price]) - tx.commission - tx.buyer_commission,
        fee: tx.commission,
        shipping_price: tx.shipping_price,
        total_label: total_label,
        unit_type: tx.unit_type,
        per_hour: booking_per_hour,
        start_time: booking_per_hour ? tx.booking.start_time : nil,
        end_time: booking_per_hour ? tx.booking.end_time : nil,
        buyer_fee: tx.buyer_commission
      })
    end
  end

  def render_free(listing_model:, author_model:, community:, params:)
    listing = {
      id: listing_model.id,
      title: listing_model.title,
      action_button_label: t(listing_model.action_button_tr_key)
    }
    author = {
      display_name: PersonViewUtils.person_display_name(author_model, community),
      username: author_model.username
    }

    unit_type = listing_model.unit_type.present? ? ListingViewUtils.translate_unit(listing_model.unit_type, listing_model.unit_tr_key) : nil
    localized_selector_label = listing_model.unit_type.present? ? ListingViewUtils.translate_quantity(listing_model.unit_type, listing_model.unit_selector_tr_key) : nil
    booking_start = Maybe(params)[:start_on].map { |d| TransactionViewUtils.parse_booking_date(d) }.or_else(nil)
    booking_end = Maybe(params)[:end_on].map { |d| TransactionViewUtils.parse_booking_date(d) }.or_else(nil)
    booking = date_selector?(listing_model)

    quantity = calculate_quantity(tx_params: {
                                    start_on: booking_start,
                                    end_on: booking_end,
                                    quantity: TransactionViewUtils.parse_quantity(params[:quantity])
                                  },
                                  is_booking: booking,
                                  unit: listing_model.unit_type)

    total_label = t("transactions.price")

    m_price_break_down = Maybe(listing_model).select { |l_model| l_model.price.present? }.map { |l_model|
      TransactionViewUtils.price_break_down_locals(
        {
          listing_price: l_model.price,
          localized_unit_type: unit_type,
          localized_selector_label: localized_selector_label,
          booking: booking,
          start_on: booking_start,
          end_on: booking_end,
          duration: quantity,
          quantity: quantity,
          subtotal: quantity != 1 ? l_model.price * quantity : nil,
          total: l_model.price * quantity,
          shipping_price: nil,
          total_label: total_label,
          unit_type: l_model.unit_type
        })
    }

    render "transactions/new", locals: {
             listing: listing,
             author: author,
             action_button_label: t(listing_model.action_button_tr_key),
             m_price_break_down: m_price_break_down,
             booking_start: booking_start,
             booking_end: booking_end,
             quantity: quantity,
             form_action: person_transactions_path(person_id: @current_user, listing_id: listing_model.id)
           }
  end

  def date_selector?(listing)
    [:day, :night].include?(listing.quantity_selector&.to_sym)
  end

  def calculate_quantity(tx_params:, is_booking:, unit:)
    if is_booking
      DateUtils.duration(tx_params[:start_on], tx_params[:end_on])
    else
      tx_params[:quantity] || 1
    end
  end

  def process_resp_to_json(resp)
    if resp[:completed]
      {
        completed: true,
        result: {
          success: resp[:result][:success],
          data: {
            redirect_url: resp.dig(:result, :data, :redirect_url)
          }
        }
      }
    else
      { completed: false }
    end
  end

  def transaction_service
    TransactionService::Transaction
  end

  def transaction_process_tokens
    TransactionService::API::Api.process_tokens
  end
end