diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 33975bce..a6542f29 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -111,6 +111,7 @@ Rails/BulkChangeTable: - 'db/migrate/20240728211432_remove_ea_ld.rb' - 'db/migrate/20240728223048_event_prices_as_integers.rb' - 'db/migrate/20240729210234_ticket_requests_cleanup.rb' + - 'db/migrate/20240806011401_payment_provider.rb' # Offense count: 3 # Configuration parameters: Include. diff --git a/.version b/.version index 80e78df6..95b25aee 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -1.3.5 +1.3.6 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 51566271..58e40472 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -32,8 +32,10 @@ def stripe_publishable_api_key end def set_event + Rails.logger.debug { "#set_event() permitted params: #{permitted_params.inspect}" } + event_id = permitted_params[:event_id].to_i - event_slug = permitted_params[:event_id].delete("#{event_id}-") + event_slug = permitted_params[:event_id].sub("#{event_id}-", '') Rails.logger.debug { "#set_event() => event_id = #{event_id}, event_slug = #{event_slug} params[:event_id] => #{permitted_params[:event_id]}" } @@ -43,8 +45,7 @@ def set_event end @event = Event.where(id: event_id).first - - if @event.slug != event_slug + if @event&.slug != event_slug Rails.logger.warn("Event slug mismatch: [#{event_slug}] != [#{@event&.slug}]") end @@ -59,7 +60,13 @@ def set_ticket_request Rails.logger.debug { "#set_ticket_request() => ticket_request_id = #{ticket_request_id}" } return unless ticket_request_id - @ticket_request = TicketRequest.find(ticket_request_id) + # check if ticket request exists + @ticket_request = TicketRequest.find_by(id: ticket_request_id) + if @ticket_request.blank? + Rails.logger.info { "#set_ticket_request() => unknown ticket_request_id: #{ticket_request_id}" } + return redirect_to root_path + end + Rails.logger.debug { "#set_ticket_request() => @ticket_request = #{@ticket_request&.inspect}" } redirect_to @event unless @ticket_request.event == @event diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 0f709c4c..bd1df04e 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -6,7 +6,8 @@ class PaymentsController < ApplicationController before_action :set_event before_action :set_ticket_request before_action :set_payment - before_action :validate_payment, except: [:confirm] + before_action :initialize_payment, except: %i[show confirm other] + before_action :validate_payment, except: %i[confirm] def show Rails.logger.debug { "#show() => @ticket_request = #{@ticket_request&.inspect} params: #{params}" } @@ -15,7 +16,6 @@ def show def new Rails.logger.debug { "#new() => @ticket_request = #{@ticket_request&.inspect}" } - initialize_payment end # Creates new Payment @@ -23,9 +23,6 @@ def new def create Rails.logger.debug { "#create() => @ticket_request = #{@ticket_request&.inspect} params: #{params}" } - # init the payment, user from ticket request - initialize_payment - # check to see if we have an existing stripe payment intent. retrieve or save @payment.retrieve_or_save_payment_intent @@ -62,14 +59,19 @@ def confirm mark_payment_completed end - # init and mark the payment as completed. + # human has marked the payment as confirmed received and completed. # Annotate that manual was set def manual_confirmation - initialize_payment - @payment.update(explanation: 'manual confirm') - @payment.reload - Rails.logger.info("#manual_confirm() => @payment #{@payment.inspect}") + # cancel any stripe payment intent, so there is no orphaned intent + @payment.cancel_payment_intent + + # update payment with explanation. + @payment.update(explanation: 'Marked Received', provider: :other) + + # mark payment and ticket request completed mark_payment_completed + Rails.logger.info("#manual_confirmation() => @payment #{@payment.inspect}") + redirect_to event_ticket_requests_path(@event, @ticket_request) end @@ -78,9 +80,13 @@ def other end # handle other forms of payment than credit card / stripe + # set payment provider and cancel the Stripe PaymentIntent def sent - initialize_payment - @payment.update(explanation: permitted_params[:explanation], status: Payment::STATUS_IN_PROGRESS) + # set explanation, provider, status. + @payment.update(explanation: permitted_params[:explanation], + status: :in_progress, + provider: :other) + @payment.cancel_payment_intent @payment.reload Rails.logger.info("#sent() => @payment #{@payment.inspect}") @@ -93,6 +99,7 @@ def sent def mark_payment_completed Payment.transaction do + # if we have a payment explanation, mark as other. else mark received @payment.mark_received @payment.ticket_request.mark_complete @payment.reload @@ -128,16 +135,6 @@ def set_payment end end - # initialize payment and save stripe payment intent - def save_payment_intent - initialize_payment - return redirect_to root_path unless @payment.present? && @payment.can_view?(current_user) - - @payment.save_with_payment_intent.tap do |result| - flash.now[:error] = @payment.errors.full_messages.to_sentence unless result - end - end - def initialize_payment @stripe_publishable_api_key ||= Rails.configuration.stripe[:publishable_api_key] @@ -186,7 +183,7 @@ def validate_payment_confirmation # has to have a stripe payment id and payment must not already be received unless @payment.stripe_payment? Rails.logger.error("Invalid payment confirmation id: #{@payment.id} missing stripe_payment_id") - return root_path, alert: 'This payment request is missing the stripe payment.' + return root_path, alert: 'This payment request is not a valid stripe payment.' end nil @@ -199,6 +196,7 @@ def permitted_params :ticket_request_id, :stripe_payment_id, :payment_intent, + :provider, :explanation, payment: %i[ ticket_request diff --git a/app/controllers/ticket_requests_controller.rb b/app/controllers/ticket_requests_controller.rb index 3ddb9de9..ca67330d 100644 --- a/app/controllers/ticket_requests_controller.rb +++ b/app/controllers/ticket_requests_controller.rb @@ -24,14 +24,15 @@ def index requests: requests.count, adults: requests.sum(&:adults), kids: requests.sum(&:kids), + donations: requests.sum(&:donation), addon_passes: requests.sum(&:active_addon_pass_sum), addon_camping: requests.sum(&:active_addon_camp_sum), - raised: requests.sum(&:price) + raised: requests.sum(&:cost) } end @stats[:total] ||= Hash.new { |h, k| h[k] = 0 } - %i[requests adults kids addon_passes addon_camping raised].each do |measure| + %i[requests adults kids donations addon_passes addon_camping raised].each do |measure| %i[pending awaiting_payment completed].each do |status| @stats[:total][measure] += @stats[status][measure] end @@ -182,19 +183,13 @@ def update def destroy Rails.logger.error("destroy# params: #{permitted_params}".colorize(:yellow)) - unless @event.admin?(current_user) || current_user == @ticket_request.user - Rails.logger.error("ATTEMPT TO DELETE TICKET REQUEST #{@ticket_request} by #{current_user}".colorize(:red)) - flash.now[:error] = 'You do not have sufficient privileges to delete this request.' - return render_flash(flash) - end - - if @ticket_request.payment_received? - flash.now[:error] = 'Can not delete request when payment has been received. It must be refunded instead.' + unless @event.admin?(current_user) || @ticket_request.can_be_cancelled?(by_user: current_user) + Rails.logger.warn("Failed to delete ticket request #{@ticket_request} status: #{@ticket_request.status_name} by #{current_user}".colorize(:red)) + flash.now[:error] = 'You do not have permission to delete this ticket' return render_flash(flash) end @ticket_request.destroy! if @ticket_request&.persisted? - redirect_to new_event_ticket_request_path(@event), notice: 'Ticket Request was successfully cancelled.' end diff --git a/app/helpers/payments_helper.rb b/app/helpers/payments_helper.rb deleted file mode 100644 index 9c9c2f58..00000000 --- a/app/helpers/payments_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module PaymentsHelper - def extra_amount_to_charge(_original_amount) - 0 - end -end diff --git a/app/mailers/payment_mailer.rb b/app/mailers/payment_mailer.rb index fdc3ea8f..ad0a7e35 100644 --- a/app/mailers/payment_mailer.rb +++ b/app/mailers/payment_mailer.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class PaymentMailer < ApplicationMailer - include PaymentsHelper - def payment_received(payment) self.payment = payment diff --git a/app/models/payment.rb b/app/models/payment.rb index ae801078..47e407b7 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -6,7 +6,9 @@ # # id :bigint not null, primary key # explanation :string -# status :string(1) default("N"), not null +# old_status :string(1) default("N"), not null +# provider :enum default("stripe"), not null +# status :enum default("new"), not null # created_at :datetime not null # updated_at :datetime not null # stripe_charge_id :string(255) @@ -19,42 +21,32 @@ # index_payments_on_stripe_payment_id (stripe_payment_id) # -# @description Payment record from Stripe +# @description Payment record from Provider # @deprecated stripe_charge_id + class Payment < ApplicationRecord - include PaymentsHelper - - # TOOD: Change to Enum - STATUSES = [ - STATUS_NEW = 'N', - STATUS_IN_PROGRESS = 'P', - STATUS_RECEIVED = 'R', - STATUS_REFUNDED = 'F' # canceled, refunded - ].freeze - - STATUS_NAMES = { - 'N' => 'New', - 'P' => 'In Progress', - 'R' => 'Received', - 'F' => 'Refunded' - }.freeze + enum :provider, { stripe: 'stripe', cash: 'cash', other: 'other' }, prefix: true + enum :status, { new: 'new', in_progress: 'in_progress', received: 'received', refunded: 'refunded' }, prefix: true + + # in case of destroy, clean up any open payment intent + before_destroy :cancel_payment_intent belongs_to :ticket_request attr_accessible :ticket_request_id, :ticket_request_attributes, - :status, :stripe_payment_id, :explanation + :status, :provider, :stripe_payment_id, :explanation + + # stripe payment objects + attr_accessor :payment_intent, :refund accepts_nested_attributes_for :ticket_request, reject_if: :modifying_forbidden_attributes? validates :ticket_request, uniqueness: { message: 'ticket request has already been paid' } - validates :status, presence: true, inclusion: { in: STATUSES } - # stripe payment objects - attr_accessor :payment_intent, :refund + delegate :can_view?, to: :ticket_request - # Create new Payment - # Create Stripe PaymentIntent + # Create new Payment with Stripe PaymentIntent # Set status Payment def save_with_payment_intent # only save 1 payment intent @@ -65,7 +57,6 @@ def save_with_payment_intent # calculate cost from Ticket Request cost = calculate_cost - Rails.logger.debug { "save_with_payment_intent: cost => #{cost}}" } begin @@ -78,8 +69,9 @@ def save_with_payment_intent false end - # payment is in progress, not completed - self.status = STATUS_IN_PROGRESS + # stripe payment is in progress + self.provider = :stripe unless provider_stripe? + self.status = :in_progress unless status_in_progress? save payment_intent @@ -103,9 +95,8 @@ def retrieve_or_save_payment_intent # Calculate ticket cost from ticket request in cents def calculate_cost - cost = ticket_request.cost - amount_to_charge = cost + extra_amount_to_charge(cost) - (amount_to_charge * 100).to_i + cost = ticket_request.cost + (cost * 100).to_i end def dollar_cost @@ -139,7 +130,7 @@ def payment_intent_client_secret end def retrieve_payment_intent - return unless stripe_payment_id + return unless stripe_payment? self.payment_intent = Stripe::PaymentIntent.retrieve(stripe_payment_id) @@ -152,13 +143,23 @@ def retrieve_payment_intent Rails.logger.debug { "retrieve_payment_intent payment => #{inspect}}" } end + # cancel Stripe PaymentIntent if is in progress + def cancel_payment_intent + return unless stripe_payment? && status_in_progress? + + Rails.logger.info "cancel_payment_intent: #{id} provider: #{provider} stripe_payment_id: #{stripe_payment_id} status: #{status}" + response = Stripe::PaymentIntent.cancel(stripe_payment_id, { cancellation_reason: 'requested_by_customer' }) + self.payment_intent = response if response.present? + end + # https://docs.stripe.com/api/refunds + # refund Stripe payment # reasons duplicate, fraudulent, or requested_by_customer - def refund_payment(reason: 'requested_by_customer') + def refund_stripe_payment(reason: 'requested_by_customer') return false unless received? begin - Rails.logger.info "refund_payment: #{id} stripe_payment_id: #{stripe_payment_id} status: #{status}" + Rails.logger.info "refund_stripe_payment: #{id} stripe_payment_id: #{stripe_payment_id} status: #{status}" self.refund = Stripe::Refund.create({ payment_intent: stripe_payment_id, reason:, metadata: { event_id: ticket_request.event.id, @@ -166,12 +167,12 @@ def refund_payment(reason: 'requested_by_customer') ticket_request_id: ticket_request.id, ticket_request_user_id: ticket_request.user_id } }) self.stripe_refund_id = refund.id - self.status = STATUS_REFUNDED - Rails.logger.info { "refund_payment success stripe_refund_id [#{stripe_refund_id}] status [#{status}]" } + self.status = :refunded + Rails.logger.info { "refund_stripe_payment success stripe_refund_id [#{stripe_refund_id}] status [#{status}]" } log_refund(refund) save! rescue Stripe::StripeError => e - Rails.logger.error { "refund_payment Stripe::Refund.create failed [#{stripe_payment_id}]: #{e}" } + Rails.logger.error { "refund_stripe_payment Stripe::Refund.create failed [#{stripe_payment_id}]: #{e}" } errors.add :base, e.message false rescue StandardError => e @@ -181,49 +182,47 @@ def refund_payment(reason: 'requested_by_customer') end def payment_in_progress? - payment_intent.present? && status == STATUS_IN_PROGRESS + payment_intent.present? && status_in_progress? end def status_name - STATUS_NAMES[status] + status.humanize end # Manually mark that a payment was received. def mark_received - update status: STATUS_RECEIVED + status_received! end - delegate :can_view?, to: :ticket_request - def in_progress? - status == STATUS_IN_PROGRESS + status_in_progress? end def stripe_payment? - stripe_payment_id.present? + provider_stripe? && stripe_payment_id.present? end def received? - stripe_payment? && status == STATUS_RECEIVED + stripe_payment? && status_received? end def refunded? - status == STATUS_REFUNDED && stripe_refund_id.present? + stripe_payment? && status_refunded? end def refundable? - received? && !refunded? + received? end - private - - def log_payment - Rails.logger.info "Payment => id: #{id.colorize(:yellow)}, " \ - "status: #{status.colorize(:green)}, " \ - "user: #{ticket_request.user.email.colorize(:blue)}, " \ - "amount: #{intent_amount}" + # @deprecated method for converting old status + def convert_old_status! + @matrix ||= { 'N' => 'new', 'P' => 'in progress', 'R' => 'received', 'F' => 'refunded' } + self.status = @matrix[old_status] + save! end + private + def log_intent(payment_intent) intent_amount = '$%.2f'.colorize(:red) % (payment_intent['amount'].to_i / 100.0) Rails.logger.info "Payment Intent => id: #{payment_intent['id'].colorize(:yellow)}, " \ diff --git a/app/models/ticket_request.rb b/app/models/ticket_request.rb index 40178704..db08479e 100644 --- a/app/models/ticket_request.rb +++ b/app/models/ticket_request.rb @@ -240,25 +240,29 @@ def mark_refunded end def payment_received? - payment&.received? || payment&.refunded? + payment&.status_received? + end + + def payment_refunded? + payment&.status_refunded? end def can_be_cancelled?(by_user:) - user.id == by_user&.id && !payment_received? + user.id == by_user&.id && !payment_received? && !payment_refunded? end def refund if refunded? - errors.add(:base, 'Cannot refund a ticket that has already been refunded') + errors.add(:base, 'Ticket has already been refunded') return false - elsif !completed? || !payment&.refundable? - errors.add(:base, 'Cannot refund a ticket that has not been purchased') + elsif !completed? || !payment&.received? + errors.add(:base, 'Ticket has not been purchased') return false end # issue refund for payment Rails.logger.info { "ticket_request [#{id}] payment [#{payment.id}] refunding [#{payment.stripe_payment_id}]" } - if payment.refund_payment + if payment.refund_stripe_payment mark_refunded else Rails.logger.error { "ticket_request failed to refund [#{payment.stripe_payment_id}] #{errors&.error_messages&.join('; ')}" } diff --git a/app/views/payments/new.html.haml b/app/views/payments/new.html.haml index 57477634..8bd147a4 100644 --- a/app/views/payments/new.html.haml +++ b/app/views/payments/new.html.haml @@ -9,24 +9,24 @@ %h4 Hi, #{@user.first_name} - - if @event.tickets_require_approval .card-body.bg-success-subtle %table.w-100 %thead - %tr - %th - Your request for - %strong - = @ticket_request.adults - = 'ticket'.pluralize(@ticket_request.total_tickets) - has been approved! + - if @event.tickets_require_approval + %tr + %th + Your request for + %strong + = @ticket_request.adults + = 'ticket'.pluralize(@ticket_request.total_tickets) + has been approved! %tbody %tr %td.w-100{colspan: 2} %hr.w-100 %tr - %td= "Total Price for Tickets:" + %td= "Total Price:" %td.text-end %strong %span{ class: ('label label-info' if @ticket_request.special_price) } @@ -59,7 +59,6 @@ = link_to 'I am not paying via credit card', other_event_ticket_request_payment_path(@event, @ticket_request) - - else .card-body.bg-success %p.lead.text-success You're almost done! All that's left is to purchase your diff --git a/app/views/ticket_requests/_table_ticket_request_statuses.html.haml b/app/views/ticket_requests/_table_ticket_request_statuses.html.haml index 1cab8392..312be979 100644 --- a/app/views/ticket_requests/_table_ticket_request_statuses.html.haml +++ b/app/views/ticket_requests/_table_ticket_request_statuses.html.haml @@ -21,6 +21,8 @@ %th.bg-dark-subtle.text-end Tickets - if event.kid_ticket_price %th.bg-dark-subtle.text-end Kids + - if event.allow_donations + %th.bg-dark-subtle.text-end Donations - if event.active_event_addons_passes_count > 0 %th.bg-dark-subtle.text-end =Addon::HUMANIZED_CATEGORIES[Addon::CATEGORY_PASS] @@ -41,6 +43,10 @@ - if event.kid_ticket_price %td.text-end.bg-success-subtle %span= stats[:completed][:kids] + - if event.allow_donations + %td.text-end.bg-success-subtle + %span + = number_to_currency(stats[:completed][:donations], precision: 0) - if event.active_event_addons_passes_count > 0 %td.text-end.bg-success-subtle %span= stats[:completed][:addon_passes] @@ -58,6 +64,9 @@ %td.text-end.bg-warning= stats[:pending][:adults] - if event.kid_ticket_price %td.text-end.bg-warning= stats[:pending][:kids] + - if event.allow_donations + %td.text-end.bg-warning + = number_to_currency(stats[:pending][:donations], precision: 0) - if event.active_event_addons_passes_count > 0 %td.text-end.bg-warning= stats[:pending][:addon_passes] - if event.active_event_addons_camping_count > 0 @@ -72,6 +81,9 @@ %td.text-end.bg-warning-subtle= stats[:awaiting_payment][:adults] - if event.kid_ticket_price %td.text-end.bg-warning-subtle= stats[:awaiting_payment][:kids] + - if event.allow_donations + %td.text-end.bg-warning-subtle + = number_to_currency(stats[:awaiting_payment][:donations], precision: 0) - if event.active_event_addons_passes_count > 0 %td.text-end.bg-warning-subtle= stats[:awaiting_payment][:addon_passes] - if event.active_event_addons_camping_count > 0 @@ -87,6 +99,8 @@ %td.bg-dark-subtle.text-end= stats[:total][:adults] - if event.kid_ticket_price %td.bg-dark-subtle.text-end= stats[:total][:kids] + - if event.allow_donations + %td.bg-dark-subtle.text-end= number_to_currency(stats[:total][:donations], precision: 0) - if event.active_event_addons_passes_count > 0 %td.bg-dark-subtle.text-end= stats[:total][:addon_passes] - if event.active_event_addons_camping_count > 0 diff --git a/app/views/ticket_requests/_table_ticket_requests.html.haml b/app/views/ticket_requests/_table_ticket_requests.html.haml index bf76560f..c2cbf631 100644 --- a/app/views/ticket_requests/_table_ticket_requests.html.haml +++ b/app/views/ticket_requests/_table_ticket_requests.html.haml @@ -13,8 +13,8 @@ %th.bg-dark-subtle.text-end.optional-medium Tickets - if event.kid_ticket_price %th.bg-dark-subtle.text-end.optional-medium Kids - - if event.active_event_addons? - %th.bg-dark-subtle.text-end.optional-medium Addons + - if event.allow_donations + %th.bg-dark-subtle.text-end.optional-medium Donation %th.bg-dark-subtle.text-end Total %th.bg-dark-subtle.text-end.optional-medium Date Requested %th.bg-dark-subtle.text-center Status @@ -29,6 +29,7 @@ = link_to event_ticket_request_path(event, ticket_request) do = ticket_request.user.name + -# If we role is required for the event, add the column for role - if event.require_role %td.muted.align-content-center.optional-medium = TicketRequest::ROLES[ticket_request.role] @@ -56,21 +57,26 @@ - if event.kid_ticket_price %td.align-content-center.text-end.optional-medium= ticket_request.kids - - if event.active_event_addons? - %td.align-content-center.text-end.optional-medium= ticket_request.active_addons_sum + - if event.allow_donations + %td.align-content-center.text-end + = number_to_currency(ticket_request.donation, precision: 0) %td.align-content-center.text-end %span{ class: ('label label-info' if ticket_request.special_price) } - = number_to_currency(ticket_request.price, precision: 0) + = number_to_currency(ticket_request.cost, precision: 0) - %td.align-content-center.text-end.optional-medium.text-nowrap.small + %td.align-content-center.text-end = ticket_request.created_at.to_date %td.align-content-center.text-center.ticket-status %span.p-2.rounded.small.bi-credit-card.text-black{ class: text_class_for_status(ticket_request) } - = text_for_status ticket_request - - if ticket_request.payment && !ticket_request.payment.received? - %i.icon-comment.hover-tooltip{ title: ticket_request.payment.explanation } + - if ticket_request.payment.present? && !ticket_request.payment.provider_stripe? + = "#{text_for_status ticket_request} - #{ticket_request.payment.provider.humanize}" + = tooltip_box(ticket_request.payment.explanation, title: "Method:") do + = image_tag('icons/comments-user.png', width: 20, class: 'hover-tooltip') + - else + = text_for_status ticket_request + %td.align-content-end.text-end - if event.tickets_require_approval && ticket_request.pending? @@ -92,10 +98,11 @@ class: 'btn btn-danger btn-sm text-nowrap' do ↩︎ Revert - elsif !ticket_request.completed? - = button_to resend_approval_event_ticket_request_path(event, ticket_request), - method: :post, - class: 'btn btn-primary btn-sm text-nowrap' do - ↺ Re-Approve + - if event.tickets_require_approval + = button_to resend_approval_event_ticket_request_path(event, ticket_request), + method: :post, + class: 'btn btn-primary btn-sm text-nowrap' do + ↺ Re-Approve = button_to manual_confirmation_event_ticket_request_payments_path(event, ticket_request), method: :post, class: 'btn btn-success btn-sm text-nowrap' do @@ -106,7 +113,7 @@ data: { confirm: "Are you sure you want to decline #{ticket_request.user.name}'s already approved request?" } do ✘ Decline - - elsif ticket_request.payment && ticket_request.awaiting_payment? && !ticket_request.payment_received? + - elsif ticket_request.payment.present? && ticket_request.awaiting_payment? .btn-group.pull-right = button_to manual_confirmation_event_ticket_request_payments_path(event, ticket_request), method: :post, @@ -114,11 +121,10 @@ Mark as Received %i.icon-ok - - elsif ticket_request.payment_received? && !ticket_request.refunded? + - elsif ticket_request.payment&.received? .btn-group.pull-right = button_to refund_event_ticket_request_path(event, ticket_request), method: :post, class: 'btn btn-danger btn-sm text-nowrap', - data: { confirm: "Are you sure you want to refund #{ticket_request.user.name}'s payment?'" } do - ↩︎ Refund - + data: { "turbo-confirm": "Are you sure you want to refund #{ticket_request.user.name}'s payment?'" } do + ↩︎ Refund \ No newline at end of file diff --git a/app/views/ticket_requests/_ticket_request_show.html.haml b/app/views/ticket_requests/_ticket_request_show.html.haml index a021e5b3..47c129e0 100644 --- a/app/views/ticket_requests/_ticket_request_show.html.haml +++ b/app/views/ticket_requests/_ticket_request_show.html.haml @@ -12,7 +12,7 @@ %tr %td.text-end.small.p-2.px-4.text-nowrap= "Date Requested:" %td - %strong= ticket_request.created_at.localtime.to_formatted_s(:friendly) + %strong= ticket_request.created_at.localtime.to_fs(:db) - if event.tickets_require_approval %tr @@ -26,7 +26,7 @@ %td.text-end.small.p-2.px-4.text-nowrap= "Payment:" %td %strong - - if ticket_request.payment_received? + - if ticket_request.payment_received? || ticket_request.payment_refunded? %strong.span.p-2.rounded{ class: text_class_for_status(ticket_request) } = ticket_request.payment.status_name - elsif ticket_request.free? @@ -34,11 +34,11 @@ Free - else - if event.ticket_sales_open? - - if ticket_request.payment.present? && ticket_request.payment.explanation.present? - %strong.span.bg-warning Paying with #{ticket_request.payment.explanation} + - if !ticket_request.payment&.provider_stripe? && ticket_request.payment&.explanation? + %strong.span.bg-warning Paying with: #{ticket_request.payment.explanation} - else - if ticket_request.all_guests_specified? - - if ticket_request.awaiting_payment? + - if ticket_request.approved? = link_to 'Buy Tickets', new_event_ticket_request_payment_path(event, ticket_request), class: 'btn btn-primary m-0' - else = "Waiting on ticket approval" @@ -55,7 +55,7 @@ = 'ticket'.pluralize(ticket_request.total_tickets) %tr - %td.text-end.small.p-2.px-4.text-nowrap= "Total Price:" + %td.text-end.small.p-2.px-4.text-nowrap= "Total Ticket Price:" %td %strong %span{ class: ('label label-info' if ticket_request.special_price) } @@ -124,7 +124,7 @@ %td.text-end.small.p-2.px-4.text-nowrap= "Financial Assistance Requested?:" %td %strong.bg-warning - Yes, financial assistance is requested. + Yes, assistance requested. - if ticket_request.notes.present? @@ -156,6 +156,6 @@ %tr %td.text-end.small.p-2.px-4.text-nowrap= "Notes:" %td - %h4 Ticket Coordinator Notes: + %strong Ticket Coordinator Notes: %p.small = ticket_request.admin_notes diff --git a/app/views/ticket_requests/show.html.haml b/app/views/ticket_requests/show.html.haml index c37ce30d..914d72e5 100644 --- a/app/views/ticket_requests/show.html.haml +++ b/app/views/ticket_requests/show.html.haml @@ -62,17 +62,22 @@ - if @event.admin?(current_user) || !@ticket_request.post_payment? = link_to edit_event_ticket_request_path(@event, @ticket_request), class: 'btn btn-primary btn-round btn-sm mx-0 fs-6' do Edit Ticket Request - - else + - elsif !@ticket_request.refunded? = link_to edit_event_ticket_request_path(@event, @ticket_request), class: 'btn btn-primary btn-round btn-sm mx-0 fs-6' do Edit Guests - - if signed_in? && @ticket_request.can_be_cancelled?(by_user: current_user) - = button_to " ✘ Cancel Ticket Request", event_ticket_request_path(@event, @ticket_request), class: 'btn btn-warning btn-mdsm', method: :delete, data: { "turbo-confirm": "Are you sure you want to delete this ticket request?" } + = button_to " ✘ Cancel Ticket Request", event_ticket_request_path(@event, @ticket_request), class: 'btn btn-warning btn-mdsm', method: :delete, data: { "turbo-confirm": "Are you sure you want to delete this ticket?" } + .col-4.text-end + .btn-group + - if @event.admin?(current_user) + = link_to event_ticket_requests_path(@event), class: 'btn btn-secondary btn-sm btn-round fs-6' do + All Ticket Requests + - if @ticket_request.refunded? + = button_to " ✘ Delete Refunded!", event_ticket_request_path(@event, @ticket_request), class: 'btn btn-danger btn-mdsm', method: :delete, data: { "turbo-confirm": "Are you sure you want to delete this already REFUNDED ticket?" } + - if @ticket_request.completed? && !@ticket_request.payment&.provider_stripe? + = button_to " ✘ Delete!", event_ticket_request_path(@event, @ticket_request), class: 'btn btn-danger btn-mdsm', method: :delete, data: { "turbo-confirm": "Are you sure you want to delete this already RECEIVED ticket?" } + - - if @event.admin?(current_user) - .col-4.text-end - = link_to event_ticket_requests_path(@event), class: 'btn btn-secondary btn-sm btn-round fs-6' do - All Ticket Requests diff --git a/db/data/20240806020511_payment_status.rb b/db/data/20240806020511_payment_status.rb new file mode 100644 index 00000000..0a3a4b9a --- /dev/null +++ b/db/data/20240806020511_payment_status.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class PaymentStatus < ActiveRecord::Migration[7.1] + def up + matrix = { 'N' => 'new', 'P' => 'in progress', 'R' => 'received', 'F' => 'refunded' } + Payment.find_each do |p| + p.status = matrix[p.old_status] + p.save! + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/data_schema.rb b/db/data_schema.rb index db9a9c2b..18556d88 100644 --- a/db/data_schema.rb +++ b/db/data_schema.rb @@ -1 +1 @@ -DataMigrate::Data.define(version: 20240730034256) +DataMigrate::Data.define(version: 20240806020511) diff --git a/db/migrate/20240806011401_payment_provider.rb b/db/migrate/20240806011401_payment_provider.rb new file mode 100644 index 00000000..eb6b6db6 --- /dev/null +++ b/db/migrate/20240806011401_payment_provider.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class PaymentProvider < ActiveRecord::Migration[7.1] + def change + create_enum :payment_provider, %w[stripe cash other] + create_enum :payment_status, %w[new in_progress received refunded] + rename_column :payments, :status, :old_status + + add_column :payments, :provider, :enum, enum_type: :payment_provider, default: 'stripe', null: false + add_column :payments, :status, :enum, enum_type: :payment_status, default: 'new', null: false + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 464a6bbf..44888085 100755 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -23,7 +23,7 @@ class Seeds SITE_ADMIN_PASSWORD = 'fubar!' SITE_ADMIN_EMAIL = 'site-admin@fnf.org' DEFAULT_USER_COUNT = 10 - DEFAULT_EVENT_COUNT = 2 + DEFAULT_EVENT_COUNT = 3 HEADER_WIDTH = 100 end @@ -46,7 +46,6 @@ def run create_site_admins create_users create_events - create_addons self.ran = true end @@ -126,12 +125,12 @@ def create_events self.events = Event.all.to_a || [] (0..event_count).to_a.each do |index| - start_time = (Time.zone.today + Random.rand(2..3).months).to_time + start_time = (Time.zone.today + index.months).to_time seasons = %w[Summer Fall Spring Winter].freeze event = events[index] || Event.create!( - name: "#{seasons[Random.rand(4)]} Campout ", + name: "#{seasons[index]} Campout ", adult_ticket_price: Faker::Commerce.price(range: 100...200), allow_donations: true, allow_financial_assistance: true, @@ -160,19 +159,6 @@ def create_events end end - def create_addons - return if Addon.count.positive? - - puts 'Creating Addons' - Addon.create category: Addon::CATEGORY_PASS, name: 'Early Arrival', default_price: 0 - Addon.create category: Addon::CATEGORY_PASS, name: 'Late Departure', default_price: 30 - Addon.create category: Addon::CATEGORY_CAMP, name: 'Car Camping', default_price: 50 - Addon.create category: Addon::CATEGORY_CAMP, name: 'RV under 20ft', default_price: 100 - Addon.create category: Addon::CATEGORY_CAMP, name: 'RV under 25ft', default_price: 125 - Addon.create category: Addon::CATEGORY_CAMP, name: 'RV over 25ft', default_price: 150 - puts "Created #{Addons.count} Addons" - end - def print_event(event) puts puts " Name: #{event.name.to_s.colorize(color: :magenta, mode: :bold)}" diff --git a/db/structure.sql b/db/structure.sql index b7bf23fa..23652cbe 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9,6 +9,29 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: payment_provider; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.payment_provider AS ENUM ( + 'stripe', + 'cash', + 'other' +); + + +-- +-- Name: payment_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.payment_status AS ENUM ( + 'new', + 'in_progress', + 'received', + 'refunded' +); + + SET default_tablespace = ''; SET default_table_access_method = heap; @@ -220,12 +243,14 @@ CREATE TABLE public.payments ( id bigint NOT NULL, ticket_request_id integer NOT NULL, stripe_charge_id character varying(255), - status character varying(1) DEFAULT 'N'::character varying NOT NULL, + old_status character varying(1) DEFAULT 'N'::character varying NOT NULL, created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, explanation character varying, stripe_payment_id character varying, - stripe_refund_id character varying + stripe_refund_id character varying, + provider public.payment_provider DEFAULT 'stripe'::public.payment_provider NOT NULL, + status public.payment_status DEFAULT 'new'::public.payment_status NOT NULL ); @@ -902,6 +927,7 @@ ALTER TABLE ONLY public.ticket_request_event_addons SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20240806011401'), ('20240729210234'), ('20240728223048'), ('20240728211432'), diff --git a/spec/controllers/payments_controller_spec.rb b/spec/controllers/payments_controller_spec.rb index 1305ee64..8c85f0f2 100644 --- a/spec/controllers/payments_controller_spec.rb +++ b/spec/controllers/payments_controller_spec.rb @@ -6,7 +6,7 @@ let(:event) { create(:event, max_adult_tickets_per_request: 1) } let(:user) { create(:user) } let(:ticket_request) { create(:ticket_request, event:, user:, kids: 0) } - let(:payment) { create(:payment, status: Payment::STATUS_IN_PROGRESS, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request:) } + let(:payment) { create(:payment, status: :in_progress, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request:) } before { sign_in user if user } @@ -18,19 +18,19 @@ end context 'when payment status new' do - let(:payment) { create(:payment, status: Payment::STATUS_NEW, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request:) } + let(:payment) { create(:payment, status: :new, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request:) } it { is_expected.to have_http_status(:redirect) } end context 'when payment status received' do - let(:payment) { create(:payment, status: Payment::STATUS_RECEIVED, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request:) } + let(:payment) { create(:payment, status: :received, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request:) } it { is_expected.to have_http_status(:redirect) } end context 'when no stripe payment exists' do - let(:payment) { create(:payment, status: Payment::STATUS_RECEIVED, stripe_payment_id: nil, ticket_request:) } + let(:payment) { create(:payment, status: :received, stripe_payment_id: nil, ticket_request:) } it { is_expected.to have_http_status(:redirect) } end diff --git a/spec/controllers/ticket_requests_controller_spec.rb b/spec/controllers/ticket_requests_controller_spec.rb index da6e9ae2..b3e41285 100644 --- a/spec/controllers/ticket_requests_controller_spec.rb +++ b/spec/controllers/ticket_requests_controller_spec.rb @@ -36,6 +36,15 @@ it { is_expected.to have_http_status(:ok) } end + + context 'when payments exist' do + let(:viewer) { create(:site_admin).user } + let(:payment1) { create(:payment, status: :in_progress, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request: ticket_requests[0]) } + let(:payment2) { create(:payment, status: :received, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request: ticket_requests[1]) } + let(:payment3) { create(:payment, status: :refunded, stripe_payment_id: 'pi_3PF4524ofUrW5ZY40NeGThOz', ticket_request: ticket_requests[2]) } + + it { is_expected.to have_http_status(:ok) } + end end describe 'GET #show' do diff --git a/spec/factories/payment.rb b/spec/factories/payment.rb index 5b5d989b..c84dbeae 100644 --- a/spec/factories/payment.rb +++ b/spec/factories/payment.rb @@ -3,7 +3,6 @@ FactoryBot.define do factory :payment do ticket_request - status { Payment::STATUS_NEW } stripe_payment_id { SecureRandom.hex } end end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index d6d4be43..899a2ca6 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -6,7 +6,9 @@ # # id :bigint not null, primary key # explanation :string -# status :string(1) default("N"), not null +# old_status :string(1) default("N"), not null +# provider :enum default("stripe"), not null +# status :enum default("new"), not null # created_at :datetime not null # updated_at :datetime not null # stripe_charge_id :string(255) @@ -43,21 +45,55 @@ describe '#status' do context 'when in progress' do - let(:payment) { build(:payment, status: Payment::STATUS_IN_PROGRESS) } + let(:payment) { build(:payment, status: 'in_progress') } it { is_expected.to be_valid } end context 'when unknown status' do - let(:payment) { build(:payment, status: 'nope') } + let(:payment) { build(:payment) } - it { is_expected.not_to be_valid } + it 'is not valid to set to unknown status' do + expect { payment.status = 'nope' }.to raise_error(ArgumentError) + end end + end - context 'when not present' do - let(:payment) { build(:payment, status: nil) } + describe 'provider' do + context 'with stripe' do + let(:payment) { build(:payment, provider: 'stripe') } - it { is_expected.not_to be_valid } + it { is_expected.to be_valid } + + it 'has provider of stripe' do + expect(payment.provider).to eq('stripe') + end + + it 'has prefix predicate for stripe' do + expect(payment).to be_provider_stripe + end + end + + context 'with other provider' do + let(:payment) { build(:payment, provider: 'other') } + + it { is_expected.to be_valid } + + it 'has provider of other' do + expect(payment.provider).to eq('other') + end + + it 'has prefix predicate for other' do + expect(payment).to be_provider_other + end + end + + context 'when unknown provider' do + let(:payment) { build(:payment) } + + it 'is not valid to set to unknown provider' do + expect { payment.provider = 'nope' }.to raise_error(ArgumentError) + end end end end @@ -91,7 +127,7 @@ it { is_expected.to be_a(Stripe::PaymentIntent) } it 'changes the payment status' do - expect { payment_intent }.to(change(payment, :status).to(Payment::STATUS_IN_PROGRESS)) + expect { payment_intent }.to(change(payment, :status).to('in_progress')) end end @@ -112,6 +148,44 @@ end end + describe '#cancel_payment_intent' do + let(:amount) { 1000 } + let(:payment) { build(:payment, provider: :stripe, status: :in_progress) } + + before do + payment_intent = payment.create_payment_intent(amount) + payment.stripe_payment_id = payment_intent.id + end + + describe 'cancel valid payment intent' do + it 'returns a PaymentIntent' do + expect(payment.cancel_payment_intent).to be_a(Stripe::PaymentIntent) + end + + it 'has payment intent canceled_at set' do + payment.cancel_payment_intent + expect(payment.payment_intent['canceled_at']).not_to be_nil + end + end + + describe 'invalid payment state' do + it 'does not cancel if stripe_payment_id not present' do + payment.stripe_payment_id = nil + expect(payment.cancel_payment_intent).to be_nil + end + + it 'does not cancel if status not in progress' do + payment.status_refunded! + expect(payment.cancel_payment_intent).to be_nil + end + + it 'does not cancel if not stripe payment' do + payment.provider_cash! + expect(payment.cancel_payment_intent).to be_nil + end + end + end + describe '#retrieve_payment_intent' do subject { payment.create_payment_intent(amount) } @@ -131,7 +205,7 @@ it { is_expected.to be_a(Stripe::PaymentIntent) } it 'changes payment status' do - expect { payment_intent }.to(change(payment, :status).to(Payment::STATUS_IN_PROGRESS)) + expect { payment_intent }.to(change(payment, :status).to('in_progress')) end context 'when stripe payment exists' do @@ -159,12 +233,12 @@ describe '#refunded?' do subject { payment.refunded? } - let(:payment) { build(:payment, status: Payment::STATUS_REFUNDED, stripe_refund_id: 'refundId') } + let(:payment) { build(:payment, status: 'refunded', stripe_refund_id: 'refundId') } it { is_expected.to be_truthy } context 'when payment not refunded' do - let(:payment) { build(:payment, status: Payment::STATUS_RECEIVED) } + let(:payment) { build(:payment, status: 'received') } it { is_expected.to be_falsey } end @@ -173,12 +247,12 @@ describe '#refundable?' do subject { payment.refundable? } - let(:payment) { build(:payment, status: Payment::STATUS_RECEIVED, stripe_refund_id: nil) } + let(:payment) { build(:payment, status: 'received', stripe_refund_id: nil) } it { is_expected.to be_truthy } context 'when payment already refunded' do - let(:payment) { build(:payment, status: Payment::STATUS_REFUNDED, stripe_refund_id: 'refundId') } + let(:payment) { build(:payment, status: 'refunded', stripe_refund_id: 'refundId') } it { is_expected.to be_falsey } end @@ -191,20 +265,5 @@ end end end - - # describe '#refund_payment' do - # subject { payment.refund_payment } - # - # before { payment.save_with_payment_intent } - # - # let(:amount) { 1000 } - # let(:payment) { build(:payment) } - # - # context 'when payment received' do - # before { payment.mark_received } - # - # it { is_expected.to be_truthy } - # end - # end end end