munificent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENCE +2 -0
  3. data/README.md +82 -0
  4. data/Rakefile +12 -0
  5. data/app/jobs/munificent/application_job.rb +9 -0
  6. data/app/jobs/munificent/donator_bundle_assignment_job.rb +20 -0
  7. data/app/jobs/munificent/payment_assignment_job.rb +32 -0
  8. data/app/models/concerns/authenticable.rb +9 -0
  9. data/app/models/munificent/application_record.rb +51 -0
  10. data/app/models/munificent/bundle.rb +51 -0
  11. data/app/models/munificent/bundle_tier.rb +49 -0
  12. data/app/models/munificent/bundle_tier_game.rb +6 -0
  13. data/app/models/munificent/charity.rb +9 -0
  14. data/app/models/munificent/charity_fundraiser.rb +6 -0
  15. data/app/models/munificent/charity_split.rb +8 -0
  16. data/app/models/munificent/curated_streamer.rb +11 -0
  17. data/app/models/munificent/curated_streamer_administrator.rb +6 -0
  18. data/app/models/munificent/currency.rb +28 -0
  19. data/app/models/munificent/donation.rb +55 -0
  20. data/app/models/munificent/donator.rb +112 -0
  21. data/app/models/munificent/donator_bundle.rb +51 -0
  22. data/app/models/munificent/donator_bundle_tier.rb +54 -0
  23. data/app/models/munificent/fundraiser.rb +51 -0
  24. data/app/models/munificent/game.rb +20 -0
  25. data/app/models/munificent/key.rb +31 -0
  26. data/app/models/munificent/payment.rb +33 -0
  27. data/app/services/munificent/donator_bundle_assigner.rb +67 -0
  28. data/app/services/munificent/existing_donator_finder.rb +60 -0
  29. data/app/services/munificent/key_assignment/key_assigner.rb +34 -0
  30. data/app/services/munificent/key_assignment/key_manager.rb +34 -0
  31. data/app/services/munificent/key_assignment/request_processor.rb +209 -0
  32. data/app/sweepers/munificent/pending_donation_sweeper.rb +9 -0
  33. data/app/sweepers/munificent/stripe_payment_sweeper.rb +17 -0
  34. data/app/validators/munificent/donation_amount_validator.rb +15 -0
  35. data/config/initializers/money.rb +140 -0
  36. data/db/migrate/20220525220035_create_munificent_models.rb +211 -0
  37. data/lib/munificent/engine.rb +40 -0
  38. data/lib/munificent/factories.rb +13 -0
  39. data/lib/munificent/seeds.rb +47 -0
  40. data/lib/munificent/version.rb +3 -0
  41. data/lib/munificent.rb +6 -0
  42. data/lib/tasks/default.rake +31 -0
  43. data/lib/tasks/key_assignment.rake +8 -0
  44. data/lib/tasks/munificent_tasks.rake +4 -0
  45. metadata +496 -0
@@ -0,0 +1,54 @@
1
+ module Munificent
2
+ class DonatorBundleTier < ApplicationRecord
3
+ belongs_to :bundle_tier
4
+ belongs_to :donator_bundle
5
+
6
+ has_many :keys, inverse_of: :donator_bundle_tier, dependent: :nullify
7
+ has_many :assigned_games, through: :keys, source: :game
8
+
9
+ scope :locked, -> { where(unlocked: false) }
10
+ scope :unlocked, -> { where(unlocked: true) }
11
+ scope :oldest_first, -> { order(updated_at: :asc) }
12
+ scope :unfulfilled, -> {
13
+ join_sql = <<~SQL.squish
14
+ INNER JOIN munificent_bundle_tier_games ON munificent_bundle_tier_games.bundle_tier_id = munificent_donator_bundle_tiers.bundle_tier_id
15
+ LEFT OUTER JOIN munificent_keys ON munificent_keys.game_id = munificent_bundle_tier_games.game_id
16
+ AND munificent_keys.donator_bundle_tier_id = munificent_donator_bundle_tiers.id
17
+ SQL
18
+
19
+ distinct.joins(join_sql).where(munificent_keys: { id: nil })
20
+ }
21
+ scope :for_fundraiser, ->(fundraiser) do
22
+ joins(bundle_tier: { bundle: :fundraiser })
23
+ .where("munificent_bundles.fundraiser_id" => fundraiser.id)
24
+ end
25
+
26
+ delegate :price, to: :bundle_tier
27
+
28
+ after_commit on: :create do
29
+ trigger_fulfillment if unlocked?
30
+ end
31
+
32
+ after_commit on: :update do
33
+ trigger_fulfillment if unlocked? && unlocked_previously_changed?
34
+ end
35
+
36
+ def unlock!
37
+ update!(unlocked: true) if locked?
38
+ end
39
+
40
+ def locked?
41
+ !unlocked?
42
+ end
43
+
44
+ def fulfilled?
45
+ bundle_tier.bundle_tier_games.count == assigned_games.count
46
+ end
47
+
48
+ private
49
+
50
+ def trigger_fulfillment
51
+ KeyAssignment::RequestProcessor.queue_fulfillment(self)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,51 @@
1
+ module Munificent
2
+ class Fundraiser < ApplicationRecord
3
+ has_many :bundles, inverse_of: :fundraiser, dependent: :nullify
4
+ has_many :charity_fundraisers, inverse_of: :fundraiser, dependent: :destroy
5
+ has_many :charities, through: :charity_fundraisers
6
+ has_many :donations, inverse_of: :fundraiser, dependent: :nullify
7
+ has_many :keys, inverse_of: :fundraiser, dependent: :destroy
8
+
9
+ OVERPAYMENT_MODES = [
10
+ PRO_BONO = "pro_bono".freeze,
11
+ PRO_SE = "pro_se".freeze,
12
+ ].freeze
13
+
14
+ aasm column: :state do
15
+ state :inactive, initial: true
16
+ state :active
17
+ state :archived
18
+
19
+ event :activate do
20
+ transitions from: :inactive, to: :active
21
+ end
22
+
23
+ event :reactivate do
24
+ transitions from: :closed, to: :active
25
+ end
26
+
27
+ event :deactivate do
28
+ transitions from: :active, to: :inactive
29
+ end
30
+
31
+ event :archive do
32
+ transitions from: :inactive, to: :archived
33
+ end
34
+ end
35
+
36
+ validates :name, presence: true
37
+ validates :overpayment_mode, inclusion: { in: OVERPAYMENT_MODES }
38
+
39
+ scope :open, -> {
40
+ where("coalesce(starts_at, '1970-01-01 00:00') <= now() and now() <= coalesce(ends_at, '2999-12-31 23:59')")
41
+ }
42
+
43
+ def pro_bono?
44
+ overpayment_mode == PRO_BONO
45
+ end
46
+
47
+ def pro_se?
48
+ overpayment_mode == PRO_SE
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,20 @@
1
+ module Munificent
2
+ class Game < ApplicationRecord
3
+ has_many :bundle_tier_games, inverse_of: :game, dependent: :destroy
4
+ has_many :keys, inverse_of: :game, dependent: :destroy
5
+
6
+ validates :name, presence: true
7
+
8
+ accepts_nested_attributes_for :keys, allow_destroy: true
9
+
10
+ def bulk_key_entry; end
11
+
12
+ def bulk_key_entry=(codes)
13
+ requested_codes = codes.split("\n")
14
+ existing_codes = keys.map(&:code)
15
+ new_codes = (requested_codes - existing_codes).map { |code| Key.new(code:) }
16
+
17
+ self.keys += new_codes
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ module Munificent
2
+ require "aws-sdk-kms"
3
+ require "aws-sdk-rails"
4
+ require "blind_index"
5
+ require "kms_encrypted"
6
+ require "lockbox"
7
+
8
+ class Key < ApplicationRecord
9
+ has_kms_key
10
+
11
+ lockbox_encrypts :code, key: :kms_key
12
+ blind_index :code
13
+
14
+ belongs_to :game, inverse_of: :keys
15
+ belongs_to :donator_bundle_tier, inverse_of: :keys, optional: true
16
+ belongs_to :fundraiser, inverse_of: :keys, optional: true
17
+
18
+ validates :code, presence: true
19
+
20
+ scope :unassigned, -> { where(donator_bundle_tier: nil) }
21
+ scope :assigned, -> { where.not(donator_bundle_tier: nil) }
22
+
23
+ after_commit on: :create do
24
+ KeyAssignment::RequestProcessor.recheck_database
25
+ end
26
+
27
+ def assigned?
28
+ donator_bundle_tier_id.present?
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ module Munificent
2
+ class Payment < ApplicationRecord
3
+ belongs_to :donation, optional: true, inverse_of: :payments
4
+
5
+ class << self
6
+ def create_and_assign(amount:, currency:, stripe_payment_intent_id: nil, paypal_order_id: nil)
7
+ if stripe_payment_intent_id.nil? && paypal_order_id.nil?
8
+ raise ArgumentError,
9
+ "Either stripe_payment_intent_id or paypal_order_id must be provided"
10
+ end
11
+
12
+ params = {
13
+ amount_decimals: amount,
14
+ amount_currency: currency,
15
+ }
16
+
17
+ if stripe_payment_intent_id
18
+ unless (payment = Payment.find_by(stripe_payment_intent_id:))
19
+ payment = Payment.create!(stripe_payment_intent_id:, **params)
20
+ end
21
+
22
+ PaymentAssignmentJob.perform_later(payment.id, provider: :stripe)
23
+ else
24
+ unless (payment = Payment.find_by(paypal_order_id:))
25
+ payment = Payment.create!(paypal_order_id:, **params)
26
+ end
27
+
28
+ PaymentAssignmentJob.perform_later(payment.id, provider: :paypal)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,67 @@
1
+ module Munificent
2
+ class DonatorBundleAssigner
3
+ def self.assign(...)
4
+ new(...).assign
5
+ end
6
+
7
+ def initialize(donator:, bundle:, fund:)
8
+ @donator = donator
9
+ @bundle = bundle
10
+ @fund = fund
11
+ end
12
+
13
+ def assign
14
+ return if fund.blank? || fund.zero?
15
+
16
+ complete_bundles, partial_bundles = donator_bundles.partition(&:complete?)
17
+
18
+ subtract_completed_bundle_value(complete_bundles)
19
+ attempt_to_unlock_partial_bundles(partial_bundles)
20
+
21
+ create_bundle_and_apportion_overpayment
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :donator, :bundle
27
+ attr_accessor :fund
28
+
29
+ def subtract_completed_bundle_value(completed_bundles)
30
+ self.fund -= bundle.total_value * completed_bundles.count
31
+ end
32
+
33
+ def attempt_to_unlock_partial_bundles(partial_bundles)
34
+ partial_bundles.each do |donator_bundle|
35
+ if (donator_bundle_tier = donator_bundle.next_unlockable_tier).price <= fund
36
+ donator_bundle_tier.unlock!
37
+ self.fund -= donator_bundle_tier.price
38
+ end
39
+ end
40
+ end
41
+
42
+ def create_bundle_and_apportion_overpayment
43
+ until done?
44
+ donator_bundle = DonatorBundle.create_from(bundle, donator:)
45
+
46
+ if (tiers = donator_bundle.unlockable_tiers_at_or_below(fund)).any?
47
+ tiers.each(&:unlock!)
48
+ self.fund -= tiers.last.bundle_tier.price
49
+ end
50
+ end
51
+ end
52
+
53
+ def done?
54
+ (donator_bundles.reload.any? && donator_bundles.none?(&:complete?)) ||
55
+ fund < bundle.lowest_tier.price ||
56
+ (pro_bono? && donator_bundles.any?(&:complete?))
57
+ end
58
+
59
+ def pro_bono?
60
+ bundle.fundraiser.pro_bono?
61
+ end
62
+
63
+ def donator_bundles
64
+ donator.donator_bundles.where(bundle:)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,60 @@
1
+ module Munificent
2
+ class ExistingDonatorFinder
3
+ def self.find(...)
4
+ new(...).find
5
+ end
6
+
7
+ def initialize(current_donator:, email_address:)
8
+ if current_donator.blank? && email_address.blank?
9
+ raise ArgumentError, "You must provide either an email address or a current donator"
10
+ end
11
+
12
+ @current_donator = current_donator
13
+ @email_address = email_address
14
+ end
15
+
16
+ def find
17
+ if email_address_not_provided?
18
+ no_choice_but_to_use_current_donator
19
+ elsif current_donator_not_provided?
20
+ prefer_confirmed_donator_but_accept_unconfirmed_donator
21
+ elsif established_current_donator?
22
+ always_use_established_current_donator
23
+ else
24
+ prefer_confirmed_donator_but_accept_new_donator
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :current_donator, :email_address
31
+
32
+ def current_donator_not_provided?
33
+ current_donator.blank?
34
+ end
35
+
36
+ def established_current_donator?
37
+ current_donator.persisted?
38
+ end
39
+
40
+ def email_address_not_provided?
41
+ email_address.blank?
42
+ end
43
+
44
+ def always_use_established_current_donator
45
+ current_donator
46
+ end
47
+
48
+ def no_choice_but_to_use_current_donator
49
+ current_donator
50
+ end
51
+
52
+ def prefer_confirmed_donator_but_accept_new_donator
53
+ Donator.find_by(email_address:) || current_donator
54
+ end
55
+
56
+ def prefer_confirmed_donator_but_accept_unconfirmed_donator
57
+ Donator.find_by(email_address:) || Donator.find_by(unconfirmed_email_address: email_address)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,34 @@
1
+ module Munificent
2
+ module KeyAssignment
3
+ class KeyAssigner
4
+ def initialize(key_manager: nil)
5
+ @key_manager = key_manager || KeyManager.new
6
+ end
7
+
8
+ def assign(donator_bundle_tier, fundraiser: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
9
+ return if donator_bundle_tier.nil?
10
+ return if donator_bundle_tier.locked?
11
+
12
+ fundraiser ||= donator_bundle_tier.bundle_tier.fundraiser
13
+
14
+ donator_bundle_tier.bundle_tier.games.each do |game|
15
+ next if @key_manager.key_assigned?(game, donator_bundle_tier:)
16
+
17
+ @key_manager.lock_unassigned_key(game, fundraiser:) do |key|
18
+ donator = donator_bundle_tier.donator_bundle.donator
19
+
20
+ if key
21
+ key.update!(donator_bundle_tier:)
22
+
23
+ if defined?(NotificationsMailer) && donator.email_address.present? && donator.confirmed?
24
+ NotificationsMailer.bundle_assigned(donator).deliver_now
25
+ end
26
+ elsif defined?(PanicMailer)
27
+ PanicMailer.missing_key(donator, game).deliver_now
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ module Munificent
2
+ module KeyAssignment
3
+ class KeyManager
4
+ def key_assigned?(game, donator_bundle_tier:)
5
+ donator_bundle_tier.assigned_games.include?(game)
6
+ end
7
+
8
+ def lock_unassigned_key(game, fundraiser: nil)
9
+ # https://api.rubyonrails.org/v6.1.0/classes/ActiveRecord/Locking/Pessimistic.html
10
+ # https://www.postgresql.org/docs/9.5/sql-select.html#SQL-FOR-UPDATE-SHARE
11
+ Munificent::Key.transaction do
12
+ yield unassigned_key(game, fundraiser:)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def unassigned_key(game, fundraiser: nil)
19
+ if fundraiser && (key = scope(game).find_by(fundraiser:))
20
+ key
21
+ else
22
+ scope(game).find_by(fundraiser: nil)
23
+ end
24
+ end
25
+
26
+ def scope(game)
27
+ Munificent::Key.lock("FOR UPDATE SKIP LOCKED").where(
28
+ game:,
29
+ donator_bundle_tier: nil,
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,209 @@
1
+ module Munificent
2
+ module KeyAssignment
3
+ class RequestProcessor # rubocop:disable Metrics/ClassLength
4
+ QUEUES = [
5
+ COMMAND_QUEUE = "key_assignment:command_queue".freeze,
6
+ FULFILLMENT_QUEUE = "key_assignment:fulfillment_queue".freeze,
7
+ RESPONSE_QUEUE = "key_assignment:response_queue".freeze,
8
+ ].freeze
9
+
10
+ PAUSE_COMMAND = "pause".freeze
11
+ PING_COMMAND = "ping".freeze
12
+ RECHECK_DATABASE_COMMAND = "recheck_database".freeze
13
+ STATUS_REPORT_COMMAND = "status_report".freeze
14
+ STOP_COMMAND = "stop".freeze
15
+ UNPAUSE_COMMAND = "unpause".freeze
16
+
17
+ class << self
18
+ delegate :start, to: :new
19
+
20
+ def queue_fulfillment(donator_bundle_tier)
21
+ case donator_bundle_tier
22
+ when DonatorBundleTier
23
+ redis.lpush(FULFILLMENT_QUEUE, donator_bundle_tier.id)
24
+ else
25
+ raise ArgumentError, "Expected DonatorBundleTier, got #{donator_bundle_tier.class}"
26
+ end
27
+ end
28
+
29
+ def recheck_database
30
+ send_command(RECHECK_DATABASE_COMMAND)
31
+ end
32
+
33
+ def ping_processor!
34
+ Rails.logger.debug("Pinging key assignment processor")
35
+ nonce = SecureRandom.hex
36
+
37
+ response = send_command(PING_COMMAND, nonce, await_response: true)
38
+ response == nonce or raise PingMismatchError, "Response `#{response}` != `#{nonce}`"
39
+ rescue Redis::TimeoutError => e
40
+ raise PingTimeoutError, e
41
+ end
42
+
43
+ def finished_backlog?
44
+ # Don't change this to `!status_report[:processing_backlog]` because we want to
45
+ # wait for the backlog to start processing and then finish.
46
+ status_report[:processing_backlog] == false
47
+ end
48
+
49
+ def status_report
50
+ send_command(STATUS_REPORT_COMMAND, await_response: true, json: true)
51
+ end
52
+
53
+ def send_command(command, *args, await_response: false, json: false, **kwargs) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
54
+ await_response = 10 if await_response == true
55
+
56
+ args = [JSON.dump(kwargs)] if json
57
+
58
+ redis.lpush(COMMAND_QUEUE, args.unshift(client_id).unshift(command).join(" "))
59
+
60
+ if await_response
61
+ redis.subscribe_with_timeout(await_response, client_id) do |on|
62
+ on.message do |channel, response|
63
+ match = response.match(/\A(\w+) (.*)\z/)
64
+ response_command, response_body = match[1, 2]
65
+
66
+ response_args = if json
67
+ JSON.parse(response_body, symbolize_names: true)
68
+ elsif response_body.include?(" ")
69
+ response_body.split
70
+ else
71
+ response_body
72
+ end
73
+
74
+ if response_command == command
75
+ redis.unsubscribe(channel)
76
+
77
+ response_args = response_args.first if response_args.length == 1
78
+ return response_args
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def clear_all_queues
86
+ QUEUES.each do |queue|
87
+ redis.del(queue)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def redis
94
+ @redis ||= Redis.new
95
+ end
96
+
97
+ def client_id
98
+ @client_id ||= SecureRandom.hex
99
+ end
100
+ end
101
+
102
+ class PingTimeoutError < StandardError; end
103
+ class PingMismatchError < StandardError; end
104
+
105
+ def initialize
106
+ @key_assigner = KeyAssignment::KeyAssigner.new
107
+ end
108
+
109
+ def start
110
+ process_backlog_from_database
111
+ begin_reading_from_redis_queue
112
+ end
113
+
114
+ private
115
+
116
+ attr_reader :key_assigner
117
+
118
+ def process_backlog_from_database
119
+ @processing_backlog = true
120
+ Rails.logger.debug("Processing backlog from database...")
121
+ Fundraiser.active.open.each do |fundraiser|
122
+ tiers = DonatorBundleTier
123
+ .unlocked
124
+ .unfulfilled
125
+ .oldest_first
126
+ .for_fundraiser(fundraiser)
127
+
128
+ tiers.each do |donator_bundle_tier|
129
+ key_assigner.assign(donator_bundle_tier, fundraiser:)
130
+ end
131
+ end
132
+ @processing_backlog = false
133
+ end
134
+
135
+ def begin_reading_from_redis_queue
136
+ Rails.logger.debug("Beginning to read from redis queue...")
137
+
138
+ loop do
139
+ process_next_tier unless paused?
140
+ process_command_queue
141
+ end
142
+ rescue ActiveRecord::RecordNotFound => e
143
+ Rails.logger.debug { "Could not find donator bundle tier with ID `#{donator_bundle_id}`" }
144
+ Rollbar.error(e)
145
+ end
146
+
147
+ def process_next_tier
148
+ _, donator_bundle_id = redis.blpop(FULFILLMENT_QUEUE, timeout: 1)
149
+
150
+ if donator_bundle_id
151
+ key_assigner.assign(DonatorBundleTier.find(donator_bundle_id))
152
+ end
153
+ end
154
+
155
+ def process_command_queue
156
+ if (command = redis.lpop(COMMAND_QUEUE))
157
+ execute(*command.split)
158
+ elsif paused?
159
+ # Without the backpressure from tier processing, we need to
160
+ # slow down polling the command queue.
161
+ sleep 1
162
+ end
163
+ end
164
+
165
+ def execute(command, client_id, *args)
166
+ case command
167
+ when RECHECK_DATABASE_COMMAND
168
+ process_backlog_from_database
169
+ when PAUSE_COMMAND
170
+ Rails.logger.debug("Pausing...")
171
+ @paused = true
172
+ when UNPAUSE_COMMAND
173
+ Rails.logger.debug("Unpausing...")
174
+ @paused = false
175
+ when PING_COMMAND
176
+ Rails.logger.debug("Received PING")
177
+ respond_with(command, client_id, args)
178
+ when STOP_COMMAND
179
+ Rails.logger.debug("Stopping key assignment...")
180
+ exit # rubocop:disable Rails/Exit
181
+ when STATUS_REPORT_COMMAND
182
+ Rails.logger.debug("Status report requested")
183
+ respond_with(command, client_id, **status_report, json: true)
184
+ end
185
+ end
186
+
187
+ def respond_with(response, client_id, args = [], json: false, **kwargs)
188
+ args = [JSON.dump(kwargs)] if json
189
+ redis.publish(client_id, args.unshift(response).join(" "))
190
+ end
191
+
192
+ def status_report
193
+ {
194
+ paused: paused?,
195
+ processing_backlog: @processing_backlog,
196
+ queue_size: redis.llen(FULFILLMENT_QUEUE),
197
+ }
198
+ end
199
+
200
+ def redis
201
+ @redis ||= Redis.new
202
+ end
203
+
204
+ def paused?
205
+ !!@paused
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,9 @@
1
+ module Munificent
2
+ class PendingDonationSweeper
3
+ class << self
4
+ def run
5
+ Donation.pending.created_before(1.day.ago).destroy_all
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ module Munificent
2
+ class StripePaymentSweeper
3
+ class << self
4
+ def run
5
+ Stripe::PaymentIntent.list.auto_paging_each do |payment_intent|
6
+ next unless payment_intent.status == "succeeded"
7
+
8
+ Payment.create_and_assign(
9
+ amount: payment_intent.amount,
10
+ currency: payment_intent.currency,
11
+ stripe_payment_intent_id: payment_intent.id,
12
+ )
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module Munificent
2
+ class DonationAmountValidator < ActiveModel::EachValidator
3
+ MINIMUM_DONATION = Money.new(2_00, "GBP")
4
+
5
+ def validate_each(record, attr_name, value)
6
+ unless Currency.supported?(record.public_send("#{attr_name}_currency"))
7
+ record.errors.add(attr_name, "currency is not supported")
8
+ end
9
+
10
+ if value < MINIMUM_DONATION
11
+ record.errors.add(attr_name, "must be at least #{MINIMUM_DONATION.format}")
12
+ end
13
+ end
14
+ end
15
+ end