diff --git a/app/controllers/ticket_requests_controller.rb b/app/controllers/ticket_requests_controller.rb index 400f5ad1..e07118ae 100644 --- a/app/controllers/ticket_requests_controller.rb +++ b/app/controllers/ticket_requests_controller.rb @@ -239,7 +239,7 @@ def revert_to_pending def refund if @ticket_request.refund redirect_to event_ticket_request_path(@event, @ticket_request), - notice: 'Ticket request was refunded' + notice: 'Ticket payment was refunded' else redirect_to event_ticket_request_path(@event, @ticket_request), alert: @ticket_request.errors.full_messages.join('. ') diff --git a/app/models/payment.rb b/app/models/payment.rb index b7a0b148..d188ab78 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -11,6 +11,7 @@ # updated_at :datetime # stripe_charge_id :string(255) # stripe_payment_id :string +# stripe_refund_id :string # ticket_request_id :integer not null # # Indexes @@ -27,13 +28,15 @@ class Payment < ApplicationRecord STATUSES = [ STATUS_NEW = 'N', STATUS_IN_PROGRESS = 'P', - STATUS_RECEIVED = 'R' + STATUS_RECEIVED = 'R', + STATUS_REFUNDED = 'F' # canceled, refunded ].freeze STATUS_NAMES = { 'N' => 'New', 'P' => 'In Progress', - 'R' => 'Received' + 'R' => 'Received', + 'F' => 'Refunded' }.freeze belongs_to :ticket_request @@ -47,7 +50,8 @@ class Payment < ApplicationRecord validates :ticket_request, uniqueness: { message: 'ticket request has already been paid' } validates :status, presence: true, inclusion: { in: STATUSES } - attr_accessor :payment_intent + # stripe payment objects + attr_accessor :payment_intent, :refund # Create new Payment # Create Stripe PaymentIntent @@ -148,6 +152,34 @@ def retrieve_payment_intent Rails.logger.debug { "retrieve_payment_intent payment => #{inspect}}" } end + # https://docs.stripe.com/api/refunds + # reasons duplicate, fraudulent, or requested_by_customer + def refund_payment(reason: 'requested_by_customer') + return false unless received? + + begin + Rails.logger.info "refund_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, + event_name: ticket_request.event.name, + 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}]" } + log_refund(refund) + save! + rescue Stripe::StripeError => e + Rails.logger.error { "refund_payment Stripe::Refund.create failed [#{stripe_payment_id}]: #{e}" } + errors.add :base, e.message + false + rescue StandardError => e + Rails.logger.error("#{e.class.name} ERROR during a refund: #{e.message.colorize(:red)}") + false + end + end + def payment_in_progress? payment_intent.present? && status == STATUS_IN_PROGRESS end @@ -167,16 +199,31 @@ def in_progress? status == STATUS_IN_PROGRESS end + def stripe_payment? + stripe_payment_id.present? + end + def received? - status == STATUS_RECEIVED + stripe_payment? && status == STATUS_RECEIVED end - def stripe_payment? - stripe_payment_id.present? + def refunded? + status == STATUS_REFUNDED && stripe_refund_id.present? + end + + def refundable? + received? && !refunded? 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}" + end + 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)}, " \ @@ -185,6 +232,14 @@ def log_intent(payment_intent) "amount: #{intent_amount}" end + def log_refund(refund) + refund_amount = '$%.2f'.colorize(:red) % (refund['amount'].to_i / 100.0) + Rails.logger.info "Refund => id: #{refund['id'].colorize(:yellow)}, " \ + "status: #{refund['status'].colorize(:green)}, " \ + "for user: #{ticket_request.user.email.colorize(:blue)}, " \ + "amount: #{refund_amount}" + end + def modifying_forbidden_attributes?(attributed) # Only allow donation field to be updated attributed.any? do |attribute, _value| diff --git a/app/models/ticket_request.rb b/app/models/ticket_request.rb index 4f704698..afb3203c 100644 --- a/app/models/ticket_request.rb +++ b/app/models/ticket_request.rb @@ -253,8 +253,12 @@ def refunded? status == STATUS_REFUNDED end + def mark_refunded + update status: STATUS_REFUNDED + end + def payment_received? - payment.try(:stripe_payment_id) && payment.received? + payment&.received? end # not able to purchase tickets in this state @@ -270,23 +274,19 @@ def refund if refunded? errors.add(:base, 'Cannot refund a ticket that has already been refunded') return false + elsif !completed? || !payment&.refundable? + errors.add(:base, 'Cannot refund a ticket that has not been purchased') + return false end - return if payment&.received? - - errors.add(:base, 'Cannot refund a ticket that has not been purchased') - false - - # XXX need to build refund. Put into Payment model - # begin - # TicketRequest.transaction do - # Stripe::Charge.retrieve(payment.stripe_charge_id).refund - # return update(status: STATUS_REFUNDED) - # end - # rescue Stripe::StripeError => e - # errors.add(:base, "Cannot refund ticket: #{e.message}") - # false - # end + # issue refund for payment + Rails.logger.info { "ticket_request [#{id}] payment [#{payment.id}] refunding [#{payment.stripe_payment_id}]" } + if payment.refund_payment + mark_refunded + else + Rails.logger.error { "ticket_request failed to refund [#{payment.stripe_payment_id}] #{errors&.error_messages&.join('; ')}" } + false + end end def price diff --git a/app/views/ticket_requests/_table_ticket_requests.html.haml b/app/views/ticket_requests/_table_ticket_requests.html.haml index 02a6fb89..12a152f5 100644 --- a/app/views/ticket_requests/_table_ticket_requests.html.haml +++ b/app/views/ticket_requests/_table_ticket_requests.html.haml @@ -122,3 +122,12 @@ class: 'btn btn-primary btn-sm ' do Mark as Received %i.icon-ok + + - elsif ticket_request.payment_received? && !ticket_request.refunded? + .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 + diff --git a/app/views/ticket_requests/_ticket_request_show.html.haml b/app/views/ticket_requests/_ticket_request_show.html.haml index 748dbc67..4a74f2c0 100644 --- a/app/views/ticket_requests/_ticket_request_show.html.haml +++ b/app/views/ticket_requests/_ticket_request_show.html.haml @@ -40,14 +40,7 @@ %span.bg-success Refunded - else .text-success - = link_to 'Received', event_ticket_request_payment_path(payment, ticket_request) - - - if event.admin?(current_user) - = link_to 'Refund', refund_event_ticket_request_path(event, ticket_request), - method: :post, - class: 'btn btn-sm btn-danger', - data: { confirm: "Are you sure you want to refund #{ticket_request.user.name}'s ticket? This cannot be undone!", - disable_with: 'Refunding...' } + = link_to 'Received', event_ticket_request_payment_path(event, payment, ticket_request) - elsif ticket_request.free? .text-success Free diff --git a/db/migrate/20240523173316_add_stripe_refund_id_to_payments.rb b/db/migrate/20240523173316_add_stripe_refund_id_to_payments.rb new file mode 100644 index 00000000..92485195 --- /dev/null +++ b/db/migrate/20240523173316_add_stripe_refund_id_to_payments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddStripeRefundIdToPayments < ActiveRecord::Migration[7.1] + def change + add_column :payments, :stripe_refund_id, :string + end +end diff --git a/db/structure.sql b/db/structure.sql index e9a5c4c6..4ee097d1 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -190,7 +190,8 @@ CREATE TABLE public.payments ( created_at timestamp without time zone, updated_at timestamp without time zone, explanation character varying(255), - stripe_payment_id character varying + stripe_payment_id character varying, + stripe_refund_id character varying ); @@ -712,6 +713,7 @@ CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING b SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20240523173316'), ('20240516225937'), ('20240513035005'), ('20240509025037'), diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index 6e0b04d0..64fc080b 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -131,12 +131,73 @@ before { payment.save_with_payment_intent } it 'does not chanmge stripe payment id when it exists' do - expect { payment_intent }.not_to(change(payment, :stripe_payment_id)) + expect { payment_intent }.not_to(change(payment, :stripe_payment_id)) end it 'does not change payment intent' do - expect { payment_intent }.not_to(change(payment, :payment_intent)) + expect { payment_intent }.not_to(change(payment, :payment_intent)) end end end + + describe 'refunds' do + describe '#stripe_payment?' do + subject { payment.stripe_payment? } + + let(:payment) { build(:payment) } + + it { is_expected.to be_truthy } + end + + describe '#refunded?' do + subject { payment.refunded? } + + let(:payment) { build(:payment, status: 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) } + + it { is_expected.to be_falsey } + end + end + + describe '#refundable?' do + subject { payment.refundable? } + + let(:payment) { build(:payment, status: 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') } + + it { is_expected.to be_falsey } + end + + context 'when payment is nil' do + let(:payment) { nil } + + it 'returns false when payment is nil' do + expect(payment&.refundable?).to be_falsey + 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