Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements Stripe Refund functionality and wires the button #192

Merged
merged 4 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/ticket_requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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('. ')
Expand Down
67 changes: 61 additions & 6 deletions app/models/payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)}, " \
Expand All @@ -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|
Expand Down
32 changes: 16 additions & 16 deletions app/models/ticket_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,12 @@ def refunded?
status == STATUS_REFUNDED
end

def mark_refunded
update status: STATUS_REFUNDED
kigster marked this conversation as resolved.
Show resolved Hide resolved
end

def payment_received?
payment.try(:stripe_payment_id) && payment.received?
payment&.received?
end

# not able to purchase tickets in this state
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/views/ticket_requests/_table_ticket_requests.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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

9 changes: 1 addition & 8 deletions app/views/ticket_requests/_ticket_request_show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20240523173316_add_stripe_refund_id_to_payments.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);


Expand Down Expand Up @@ -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'),
Expand Down
65 changes: 63 additions & 2 deletions spec/models/payment_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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