diff --git a/Gemfile.lock b/Gemfile.lock index eb03e363..38dd6bb3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -420,6 +420,8 @@ GEM railties (>= 6.0.0) stringio (3.1.0) stripe (11.4.0) + sucker_punch (3.2.0) + concurrent-ruby (~> 1.0) temple (0.10.3) thor (1.3.1) tilt (2.3.0) @@ -519,6 +521,7 @@ DEPENDENCIES simplecov stimulus-rails stripe (~> 11.3) + sucker_punch timecop timeout turbo-rails diff --git a/app/classes/fnf/payments_service.rb b/app/classes/fnf/payments_service.rb new file mode 100644 index 00000000..8ec6f8fa --- /dev/null +++ b/app/classes/fnf/payments_service.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module FnF + class PaymentsService + attr_accessor :ticket_request, :payment, :stripe_payment_intent, :user + + def initialize(ticket_request:) + self.ticket_request = ticket_request + raise ArgumentError, 'PaymentsService: ticket_request can not be nil' if ticket_request.nil? + + self.payment = ticket_request.payment || ticket_request.build_payment + self.stripe_payment_intent = payment.stripe_payment_intent || payment.build_stripe_payment_intent + end + + # Stripe Payment Intent + # https://docs.stripe.com/api/payment_intents/object + def create_payment_intent(amount) + Stripe::PaymentIntent.create({ + amount: payment.amount, + currency: 'usd', + automatic_payment_methods: { enabled: true }, + description: "#{ticket_request.total}#{ticket_request.event.name} Tickets", + metadata: { + ticket_request_id: ticket_request.id, + ticket_request_user_id: ticket_request.user_id, + event_id: ticket_request.event.id, + event_name: ticket_request.event.name + } + }) + end + + # @description Returns the Stripe Payment Intent + # @see https://docs.stripe.com/api/payment_intents/object + # @example + # { + # "id": "pi_3MtwBwLkdIwHu7ix28a3tqPa", + # "object": "payment_intent", + # "amount": 2000, + # "amount_capturable": 0, + # "amount_details": { + # "tip": {} + # }, + # "amount_received": 0, + # "application": null, + # "application_fee_amount": null, + # "automatic_payment_methods": { + # "enabled": true + # }, + # "canceled_at": null, + # "cancellation_reason": null, + # "capture_method": "automatic", + # "client_secret": "pi_3MtwBwLkdIwHu7ix28a3tqPa_secret_YrKJUKribcBjcG8HVhfZluoGH", + # "confirmation_method": "automatic", + # "created": 1680800504, + # "currency": "usd", + # "customer": null, + # "description": null, + # "invoice": null, + # "last_payment_error": null, + # "latest_charge": null, + # "livemode": false, + # "metadata": {}, + # "next_action": null, + # "on_behalf_of": null, + # "payment_method": null, + # "payment_method_options": { + # "card": { + # "installments": null, + # "mandate_options": null, + # "network": null, + # "request_three_d_secure": "automatic" + # }, + # "link": { + # "persistent_token": null + # } + # }, + # "payment_method_types": [ + # "card", + # "link" + # ], + # "processing": null, + # "receipt_email": null, + # "review": null, + # "setup_future_usage": null, + # "shipping": null, + # "source": null, + # "statement_descriptor": null, + # "statement_descriptor_suffix": null, + # "status": "requires_payment_method", + # "transfer_data": null, + # "transfer_group": null + # } + def stripe_payment_intent(amount) + intent = if stripe_payment_intent_id + Stripe::PaymentIntent.retrieve(stripe_payment_intent_id) + end + + if intent + return intent if intent + end + + Stripe::PaymentIntent.create({ + amount:, + currency: 'usd', + automatic_payment_methods: { enabled: true }, + description: "#{ticket_request.event.name} Tickets", + metadata: { + ticket_request_id: ticket_request.id, + ticket_request_user_id: ticket_request.user_id, + event_id: ticket_request.event.id, + event_name: ticket_request.event.name + } + }) + end + + end +end diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 172376a5..b81d3a76 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -6,20 +6,28 @@ class PaymentsController < ApplicationController before_action :set_event before_action :set_ticket_request before_action :set_payment - before_action :validate_payment, except: [:confirm] + before_action :validate_payment def show Rails.logger.debug { "#show() => @ticket_request = #{@ticket_request&.inspect} params: #{params}" } self end + # @description + # Rendered whenever a +TicketRequest+ is ready to be paid for (i.e. has been approved). + # This renders the new.html.haml view, and initializes Stimulus +checkout_controller.js+ + # which uses StripeJS API to render the credit card collection form. In order to render + # the form, the StripeJS calls via Ajax the POST #create action on this controller, to + # initialize the +PaymentIntent+ object, and return the +clientSecret+ to the front-end. + # After the user enters their card number, and clicks Submit, Stripe API will handle the + # credit card errors. Once the payment goes through, however, StripeJS will redirect the + # user to the GET #confirm action, which must update the payment as 'paid' + # def new Rails.logger.debug { "#new() => @ticket_request = #{@ticket_request&.inspect}" } initialize_payment end - # Creates new Payment - # Create Payment Intent and save PaymentIntentId in Payment def create Rails.logger.debug { "#create() => @ticket_request = #{@ticket_request&.inspect} params: #{params}" } @@ -120,7 +128,7 @@ def set_payment end end - # initialize payment and save stripe payment intent + # @description Initialize the @payment and save the PaymentIntent by calling StripeAPI def save_payment_intent initialize_payment return redirect_to root_path unless @payment.present? && @payment.can_view?(current_user) @@ -130,6 +138,7 @@ def save_payment_intent end end + # @description Either fetches from the database @payment instance or builds one in memory def initialize_payment @stripe_publishable_api_key ||= Rails.configuration.stripe[:publishable_api_key] diff --git a/app/controllers/ticket_requests_controller.rb b/app/controllers/ticket_requests_controller.rb index 0e3f0fd2..1499cd7e 100644 --- a/app/controllers/ticket_requests_controller.rb +++ b/app/controllers/ticket_requests_controller.rb @@ -98,6 +98,7 @@ def edit @user = @ticket_request.user end + # @description # rubocop: disable Metrics/AbcSize def create unless @event.ticket_sales_open? diff --git a/app/helpers/payments_helper.rb b/app/helpers/payments_helper.rb index d5c2c844..571c16b9 100644 --- a/app/helpers/payments_helper.rb +++ b/app/helpers/payments_helper.rb @@ -1,16 +1,50 @@ # frozen_string_literal: true +# @description +# Calculates the extra amount to charge based off of Stripe's fees module PaymentsHelper - # Calculates the extra amount to charge based off of Stripe's fees so that the - # full original amount is sent to the event organizer. - STRIPE_RATE = BigDecimal('0.029', 10) # 2.9% per transaction - STRIPE_TRANSACTION_FEE = BigDecimal('0.30', 10) # +30 cents per transaction - - def extra_amount_to_charge(_original_amount) - # XXX: For now, disable passing fees on to user - # extra = (original_amount * STRIPE_RATE + STRIPE_TRANSACTION_FEE) / (1 - STRIPE_RATE) - # extra_cents = (extra * 100).ceil # Round up to the nearest cent - # BigDecimal.new(extra_cents, 10) / 100 - 0 + # 2.9% per transaction + STRIPE_RATE = BigDecimal('0.029', 10) + + # +30 cents per transaction + STRIPE_TRANSACTION_FEE = BigDecimal('0.30', 10) + + class << self + attr_accessor :extra_fees_enabled, :stripe_rate, :stripe_transaction_fee + + def configure + self.extra_fees_enabled = false + self.stripe_rate = STRIPE_RATE + self.stripe_transaction_fee = STRIPE_TRANSACTION_FEE + + # override in a block + yield(self) + end + + def disable! + self.extra_fees_enabled = false + end + end + + # @description + # For now, we disable passing fees on to user. + + attr_accessor :extra_charge_amount + + # @description + # Can be used to tack on additional fees to the user. + def extra_amount_to_charge(original_amount_cents = nil) + unless PaymentsHelper.extra_fees_enabled + return self.extra_charge_amount = 0 + end + + if original_amount_cents.nil? && respond_to?(:amount) + original_amount_cents = amount + end + + extra = ((original_amount_cents * STRIPE_RATE) + STRIPE_TRANSACTION_FEE) / (1 - STRIPE_RATE) + self.extra_charge_amount = extra.ceil # Round up to the nearest cent end end + +PaymentsHelper.disable! diff --git a/app/models/payment.rb b/app/models/payment.rb index f6dcc9d7..f5c54810 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -4,26 +4,33 @@ # # Table name: payments # -# id :integer not null, primary key -# explanation :string(255) -# status :string(1) default("N"), not null -# created_at :datetime -# updated_at :datetime -# stripe_charge_id :string(255) -# stripe_payment_id :string -# ticket_request_id :integer not null +# id :integer not null, primary key +# explanation :string(255) +# status :string(1) default("N"), not null +# created_at :datetime +# updated_at :datetime +# stripe_charge_id :string(255) +# stripe_payment_id :string +# stripe_payment_intent_id :integer +# ticket_request_id :integer not null # # Indexes # -# index_payments_on_stripe_payment_id (stripe_payment_id) +# index_payments_on_stripe_payment_id (stripe_payment_id) +# index_payments_on_stripe_payment_intent_id (stripe_payment_intent_id) WHERE (stripe_payment_intent_id IS NOT NULL) +# +# Foreign Keys +# +# fk_rails_... (stripe_payment_intent_id => stripe_payment_intents.id) ON DELETE => restrict # # @description Payment record from Stripe # @deprecated stripe_charge_id class Payment < ApplicationRecord + # Also tacking on additional Stripe fees to the total amount charged + # Disabled by the default. include PaymentsHelper - # TOOD: Change to Enum STATUSES = [ STATUS_NEW = 'N', STATUS_IN_PROGRESS = 'P', @@ -32,39 +39,36 @@ class Payment < ApplicationRecord STATUS_NAMES = { 'N' => 'New', - 'P' => 'In Progress', - 'R' => 'Received' + 'P' => 'Processing', + 'R' => 'Completed', + 'I' => 'See Stripe Payment Intent' }.freeze belongs_to :ticket_request - attr_accessible :ticket_request_id, :ticket_request_attributes, - :status, :stripe_payment_id, :explanation - - accepts_nested_attributes_for :ticket_request, - reject_if: :modifying_forbidden_attributes? + has_one :stripe_payment_intent, required: false, dependent: :restrict_with_error validates :ticket_request, uniqueness: { message: 'ticket request has already been paid' } validates :status, presence: true, inclusion: { in: STATUSES } - attr_accessor :payment_intent + after_create :create_stripe_payment_intent + + def stripe_payment_id + stripe_charge_id || stripe_payment_intent&.intent_id + end # Create new Payment # Create Stripe PaymentIntent # Set status Payment def save_with_payment_intent - # only save 1 payment intent - unless valid? - errors.add :base, 'Invalid Payment' - return false - end + return false unless valid? # calculate cost from Ticket Request cost = calculate_cost begin # Create new Stripe PaymentIntent - self.payment_intent = create_payment_intent(cost) + self.payment_intent = stripe_payment_intent(cost) log_intent(payment_intent) self.stripe_payment_id = payment_intent.id rescue Stripe::StripeError => e @@ -106,23 +110,6 @@ def dollar_cost (calculate_cost / 100).to_i end - # Stripe Payment Intent - # https://docs.stripe.com/api/payment_intents/object - def create_payment_intent(amount) - Stripe::PaymentIntent.create({ - amount:, - currency: 'usd', - automatic_payment_methods: { enabled: true }, - description: "#{ticket_request.event.name} Tickets", - metadata: { - ticket_request_id: ticket_request.id, - ticket_request_user_id: ticket_request.user_id, - event_id: ticket_request.event.id, - event_name: ticket_request.event.name - } - }) - end - def payment_intent_client_secret payment_intent&.client_secret end @@ -141,9 +128,14 @@ def status_name STATUS_NAMES[status] end - # Manually mark that a payment was received. - def mark_received - update status: STATUS_RECEIVED + # @description update the payment as properly received + def receive! + self.class.transaction do + update status: STATUS_RECEIVED + self.stripe_payment_intent_id = stripe_payment_intent + ticket_request.complete! + reload + end end delegate :can_view?, to: :ticket_request @@ -157,7 +149,11 @@ def received? end def stripe_payment? - stripe_payment_id.present? + stripe_payment_intent.present? + end + + def payment_service + FnF::PaymentsService.new(ticket_request:) end private @@ -170,6 +166,10 @@ def log_intent(payment_intent) "amount: #{intent_amount}" end + def create_stripe_payment_intent + self.stripe_payment_intent = payment_service.stripe_payment_request + end + def modifying_forbidden_attributes?(attributed) # Only allow donation field to be updated attributed.any? do |attribute, _value| diff --git a/app/models/stripe_payment_intent.rb b/app/models/stripe_payment_intent.rb new file mode 100644 index 00000000..2a2de71e --- /dev/null +++ b/app/models/stripe_payment_intent.rb @@ -0,0 +1,29 @@ +# == Schema Information +# +# Table name: stripe_payment_intents +# +# id :bigint not null, primary key +# amount :integer not null +# description :string +# last_payment_error :string +# status :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :string +# intent_id :string not null +# payment_id :integer +# +# Indexes +# +# index_stripe_payment_intents_on_intent_id (intent_id) +# index_stripe_payment_intents_on_payment_id (payment_id) +# +# Foreign Keys +# +# fk_rails_... (payment_id => payments.id) +# +class StripePaymentIntent < ApplicationRecord + belongs_to :payment + + attr_accessor :client_secret +end diff --git a/app/models/ticket_request.rb b/app/models/ticket_request.rb index 9e5965b8..524ff356 100644 --- a/app/models/ticket_request.rb +++ b/app/models/ticket_request.rb @@ -51,7 +51,7 @@ class TicketRequest < ApplicationRecord STATUS_NAMES = { 'P' => 'Pending', - 'A' => 'Waiting for Payment', + 'A' => 'Approved, Waiting for Payment', 'D' => 'Declined', 'C' => 'Completed', 'R' => 'Refunded' @@ -158,8 +158,7 @@ def completed? status == STATUS_COMPLETED end - def mark_complete - Rails.logger.debug { "ticket request marking completed: #{id}" } + def complete! update status: STATUS_COMPLETED end @@ -201,17 +200,6 @@ def refund 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 end def price @@ -253,13 +241,17 @@ def guests_specified end def all_guests_specified? - guests_specified >= guest_count + guests_specified == guest_count end def country_name ISO3166::Country[country_code] end + def to_s + "#{self.class.name}\#" + end + private def set_defaults diff --git a/config/application.rb b/config/application.rb index cd28c38d..db0ad258 100644 --- a/config/application.rb +++ b/config/application.rb @@ -34,6 +34,12 @@ def xmlschema; end module TicketBooth class Application < Rails::Application + class << self + def setting(key) + Hashie::Mash.new(Rails.application.credentials[Rails.env.to_sym][key]) + end + end + # Application Version VERSION = File.read('.version').freeze diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 82fb5e4b..88bef610 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -/nZjsoXye1FFvyjQLgaojk5GLDwTqE3Z3U5i2Zvce86BsVSR2+LGJGQAwOdO6Hp9Hn5OBwWoBnsq3d/7HB2U4yYbFGW/QtyPfJpFeoa5CchqzkMpGuoKW/asXHLkTB69nU0G5JD1+M74o++FF/nY0l6aJeQUsTLw47Isy8goZ/nlm57c5SDgZcc1zOc51fat/nR+ILMXLL0D73JLEKslVC6cqT93Q1cNqroeyP1gd34zTaUepCSw4gp9QY3gGgkjKZh7PZR0UL9aGbtjce8X3rDIl0oLt1lljbZffoitpSqEhSiuxqR6kp/LRjRm5vRBskwA9jUsa8v64KjIapemDb6dK4IdVvxu75x1BmLZR57Lh3KU5L53pF1KOA+CBE2mbWWtO8CB0ZJ0jz0OBKj/tQEjxvHO7Ip90KeC/3TsS2329u2TrScpYchQ0BBKOtc7WUFDe2inejjJ5lf3UddFbIs6nXE+akkwOyLE7ZfMa0IIFjuNlPsDUk0MWRivKPFHWaw4dJpyzCdAvF3bvi6gFeRqgwcWxt7xKB6zsQEWmdyMoEW1bRKj30V/0YOZP3YSBcWCD5BkXWJjdDw8FvKWTPXzXVMjE9U3o8H6aQMYAgjTrMJaWIzoiFeIoXpSD/MX1bO3wSfYg18sFR8KryQ2o3TILYWtEA4g/Xs/OBorrz0hxXdX/VTenL0tGxXmrAF/sebvzM7+28CuFphQMUxSIXwPWSbjaCe00Yq6XQLSb+bBxkXpYvQsYiddeqr+3vJeZm7If3+xWTHApcQkwVV0TmRFPJ1+dYje63SA77NYvWLmpmhLboPNiQr+nQS6n31vBV4LMzjv8KhgM6WEZzj3avkZrLmQfA4j+k3daAoERuv9f8RjkyicMNY17nqY06VVuI59O86S/gDoBlPPSYNQ4WNzgCPYou4970kf4PAA3W6RSAi1Iz3Mh0Tm5ViiSec4fcR0WeBN7Yq8Ljk/2vziJzCLuIpXbDnXDzWa461FoA/WCdUSC6zV2ir715GOrUrbeNcUfObgSDK5VySdqWU0WN/RFidjanm1NqRrCRJRbROxR9TPchZgXBDQ2SHBmuog+XXR/s+j39iEcvvGDV9SPDPeI8z92b0W0TRamhMR6ImlXwkjm4832JlGYJeaHzYDh+ZnTb4KUk6IsKnWhp6jlXTSa2PoxzSumcMXexMK04JBZ2UZtsTljwWcRX7K2mUo3Rb0quMSPiNv250aFgfv+6p0fUNmJP2cg4Whg449eHPde1Uqn1CiXlMPKUMjlGjuydx6IHjIaBYQ/EjteLWYo7chErol3sWYoHEl9p5/vwmDHqRge2oj0aUR/BeLuYFtF5orkzfBHednTsRhlTa0ZFYkYYvmF1Aq5vQuHhVIFO1drvG1yhrGg7giyFAz56pi4tVsMHT279LonfudlspGAHVQ8KFiryS15C7DpMBts4Ixmz4Pzx1NTG7u9/YNmOPSzI97BxzyuMtWKCz9J+xg3XVLmzuRjDE8BqDbYFAoHneRL8HI0+E1nyVSJ0BZ0VIpXDKsIPnLGKFT2Q1iDPxgktaSvwMWnsoNxxGThaCGs1UiXJ8WVzSnnqNdS+BL09vh/Ks+7rQPyuVDfVLl40UQIFvwX1A9MYR621QqTOF3pEznU+WiqPC36itFxQJswuWpN4TMMbYN7XOFEmNvZvoKiG53YWp6YfiDiGoh5GxpNn7l05DB5R2NQjmTgzZuB8XYeonL4yF49zZKcwDoEGNd8bDGt3nI2Lqp9eG9m7ltATVS--oRCFUQUEf9G3ty2k--QM0w22o7wozh1HQpvZs42A== \ No newline at end of file +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--BOGZzritXIEyyuvG--dx3oBfx+lzTRW/ou2S6jZg== \ No newline at end of file diff --git a/config/initializers/encryption.rb b/config/initializers/encryption.rb new file mode 100644 index 00000000..818e7093 --- /dev/null +++ b/config/initializers/encryption.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.config.active_record.encryption.primary_key = TicketBooth::Application.setting(:active_record_encryption).primary_key +Rails.application.config.active_record.encryption.deterministic_key = TicketBooth::Application.setting(:active_record_encryption).deterministic_key +Rails.application.config.active_record.encryption.key_derivation_salt = TicketBooth::Application.setting(:active_record_encryption).key_derivation_salt diff --git a/db/migrate/20240509025037_add_stripe_payment_id_to_payments.rb b/db/migrate/20240509025037_add_stripe_payment_id_to_payments.rb deleted file mode 100644 index e68d0925..00000000 --- a/db/migrate/20240509025037_add_stripe_payment_id_to_payments.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AddStripePaymentIdToPayments < ActiveRecord::Migration[7.1] - def change - add_column :payments, :stripe_payment_id, :string - add_index :payments, :stripe_payment_id - change_column_default :payments, :status, from: 'P', to: 'N' - end -end diff --git a/db/migrate/20240513013550_create_stripe_payment_intents.rb b/db/migrate/20240513013550_create_stripe_payment_intents.rb new file mode 100644 index 00000000..80c48579 --- /dev/null +++ b/db/migrate/20240513013550_create_stripe_payment_intents.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class CreateStripePaymentIntents < ActiveRecord::Migration[7.1] + def change + create_table :stripe_payment_intents do |t| + t.string :intent_id, null: false + t.string :status, null: false + t.integer :amount, null: false + t.string :description + t.string :customer_id + t.string :last_payment_error + + t.references(:payment, foreign_key: true, type: :integer) + + t.timestamps + + t.index(:intent_id) + end + end +end diff --git a/db/migrate/20240513020000_add_stripe_payment_intent_id_to_payments.rb b/db/migrate/20240513020000_add_stripe_payment_intent_id_to_payments.rb new file mode 100644 index 00000000..0edfe1d1 --- /dev/null +++ b/db/migrate/20240513020000_add_stripe_payment_intent_id_to_payments.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddStripePaymentIntentIdToPayments < ActiveRecord::Migration[7.1] + def change + add_column :payments, :stripe_payment_intent_id, :integer + add_index :payments, :stripe_payment_intent_id, where: 'stripe_payment_intent_id is not NULL' + + add_foreign_key :payments, :stripe_payment_intents, on_delete: :restrict + + change_column_default :payments, :status, from: 'P', to: 'N' + end +end diff --git a/db/structure.sql b/db/structure.sql index 1592ce32..843a08f4 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_payment_intent_id integer ); @@ -320,6 +321,43 @@ CREATE SEQUENCE public.site_admins_id_seq ALTER SEQUENCE public.site_admins_id_seq OWNED BY public.site_admins.id; +-- +-- Name: stripe_payment_intents; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.stripe_payment_intents ( + id bigint NOT NULL, + intent_id character varying NOT NULL, + status character varying NOT NULL, + amount integer NOT NULL, + description character varying, + customer_id character varying, + last_payment_error character varying, + payment_id integer, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: stripe_payment_intents_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.stripe_payment_intents_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: stripe_payment_intents_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.stripe_payment_intents_id_seq OWNED BY public.stripe_payment_intents.id; + + -- -- Name: ticket_requests; Type: TABLE; Schema: public; Owner: - -- @@ -517,6 +555,13 @@ ALTER TABLE ONLY public.shifts ALTER COLUMN id SET DEFAULT nextval('public.shift ALTER TABLE ONLY public.site_admins ALTER COLUMN id SET DEFAULT nextval('public.site_admins_id_seq'::regclass); +-- +-- Name: stripe_payment_intents id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_payment_intents ALTER COLUMN id SET DEFAULT nextval('public.stripe_payment_intents_id_seq'::regclass); + + -- -- Name: ticket_requests id; Type: DEFAULT; Schema: public; Owner: - -- @@ -610,6 +655,14 @@ ALTER TABLE ONLY public.site_admins ADD CONSTRAINT site_admins_pkey PRIMARY KEY (id); +-- +-- Name: stripe_payment_intents stripe_payment_intents_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_payment_intents + ADD CONSTRAINT stripe_payment_intents_pkey PRIMARY KEY (id); + + -- -- Name: ticket_requests ticket_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -655,6 +708,13 @@ CREATE INDEX index_event_admins_on_user_id ON public.event_admins USING btree (u CREATE INDEX index_payments_on_stripe_payment_id ON public.payments USING btree (stripe_payment_id); +-- +-- Name: index_payments_on_stripe_payment_intent_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_stripe_payment_intent_id ON public.payments USING btree (stripe_payment_intent_id) WHERE (stripe_payment_intent_id IS NOT NULL); + + -- -- Name: index_price_rules_on_event_id; Type: INDEX; Schema: public; Owner: - -- @@ -662,6 +722,20 @@ CREATE INDEX index_payments_on_stripe_payment_id ON public.payments USING btree CREATE INDEX index_price_rules_on_event_id ON public.price_rules USING btree (event_id); +-- +-- Name: index_stripe_payment_intents_on_intent_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_stripe_payment_intents_on_intent_id ON public.stripe_payment_intents USING btree (intent_id); + + +-- +-- Name: index_stripe_payment_intents_on_payment_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_stripe_payment_intents_on_payment_id ON public.stripe_payment_intents USING btree (payment_id); + + -- -- Name: index_users_on_confirmation_token; Type: INDEX; Schema: public; Owner: - -- @@ -697,6 +771,22 @@ CREATE UNIQUE INDEX index_users_on_unlock_token ON public.users USING btree (unl CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING btree (version); +-- +-- Name: payments fk_rails_ea984401ae; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_rails_ea984401ae FOREIGN KEY (stripe_payment_intent_id) REFERENCES public.stripe_payment_intents(id) ON DELETE RESTRICT; + + +-- +-- Name: stripe_payment_intents fk_rails_fb6602c24d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stripe_payment_intents + ADD CONSTRAINT fk_rails_fb6602c24d FOREIGN KEY (payment_id) REFERENCES public.payments(id); + + -- -- PostgreSQL database dump complete -- @@ -705,6 +795,8 @@ SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES ('20240513035005'), +('20240513020000'), +('20240513013550'), ('20240509025037'), ('20240423054549'), ('20240423054149'), diff --git a/spec/controllers/payments_controller_spec.rb b/spec/controllers/payments_controller_spec.rb index 257746ab..a7368e81 100644 --- a/spec/controllers/payments_controller_spec.rb +++ b/spec/controllers/payments_controller_spec.rb @@ -4,14 +4,14 @@ describe PaymentsController, type: :controller do let(:event) { create(:event, max_adult_tickets_per_request: 1) } - let(:user) { create(:user) } + let(:user) { create(:user, :confirmed) } 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:) } before { sign_in user if user } describe 'POST #confirm' do - subject { post :confirm, params: { id: payment.id, event_id: ticket_request.event.id, ticket_request_id: ticket_request.id } } + subject { post :confirm, params: { event_id: ticket_request.event.id, ticket_request_id: ticket_request.id, id: payment.id } } context 'when payment status in progress' do it { is_expected.to have_http_status(:ok) } diff --git a/spec/factories/user.rb b/spec/factories/user.rb index 353426aa..c015ceab 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -8,6 +8,10 @@ email { Faker::Internet.email } password { Faker::Internet.password(min_length: 8) } + trait :confirmed do |*| + confirmed_at { Time.current } + end + trait :site_admin do site_admin end diff --git a/spec/helpers/payments_helper_spec.rb b/spec/helpers/payments_helper_spec.rb new file mode 100644 index 00000000..fa6c2d38 --- /dev/null +++ b/spec/helpers/payments_helper_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +RSpec.describe PaymentsHelper do + + shared_examples_for 'payments helper' do |enabled: true, total: 0, expected_extra: 0| + let(:amount) { total } # $10.00 + let(:expected_extra) { expected_extra } # $10.00 + + before do + described_class.extra_fees_enabled = enabled + if subject.respond_to?(:amount) + subject.extra_amount_to_charge(nil) + else + subject.extra_amount_to_charge(total) + end + end + + its(:extra_charge_amount) { is_expected.to eql(expected_extra) } + end + + describe 'without the total field' do + subject { (Class.new { include PaymentsHelper }).new } + + it_behaves_like 'payments helper', enabled: true, total: 1000, expected_extra: 31 + it_behaves_like 'payments helper', enabled: false, total: 1000, expected_extra: 0 + end + + describe 'without total field' do + subject { (Class.new(Struct.new(:amount)) { include PaymentsHelper }).new(amount) } + + describe 'when total is $10' do + let(:amount) { 1000 } + + it_behaves_like 'payments helper', enabled: true, total: 1000, expected_extra: 31 + it_behaves_like 'payments helper', enabled: false, total: 1000, expected_extra: 0 + end + + describe 'when total is $500' do + let(:amount) { 50_000 } + + it_behaves_like 'payments helper', enabled: true, total: 50_000, expected_extra: 1494 + it_behaves_like 'payments helper', enabled: false, total: 50_000, expected_extra: 0 + end + end +end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index 6e0b04d0..a88ff32a 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -127,6 +127,7 @@ expect { payment_intent }.to(change(payment, :status).to(Payment::STATUS_IN_PROGRESS)) end + context 'when stripe payment exists' do before { payment.save_with_payment_intent } diff --git a/spec/models/ticket_request_spec.rb b/spec/models/ticket_request_spec.rb index 895da6d0..e7f791e4 100644 --- a/spec/models/ticket_request_spec.rb +++ b/spec/models/ticket_request_spec.rb @@ -255,14 +255,24 @@ end describe '#approve' do - subject { create(:ticket_request, special_price: price, status: TicketRequest::STATUS_PENDING) } + subject(:ticket_request) { create(:ticket_request, special_price: price, status: TicketRequest::STATUS_PENDING) } before { subject.approve } context 'when the ticket request has a price of zero dollars' do let(:price) { 0 } + let(:to_string) do + StringIO.new.tap do |io| + io.print 'TicketRequest#' + io.print "" + end.string + end it { is_expected.to be_completed } + + its(:to_s) { is_expected.to eq to_string } end context 'when the ticket request has a price greater than zero dollars' do