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.
- checksums.yaml +7 -0
- data/LICENCE +2 -0
- data/README.md +82 -0
- data/Rakefile +12 -0
- data/app/jobs/munificent/application_job.rb +9 -0
- data/app/jobs/munificent/donator_bundle_assignment_job.rb +20 -0
- data/app/jobs/munificent/payment_assignment_job.rb +32 -0
- data/app/models/concerns/authenticable.rb +9 -0
- data/app/models/munificent/application_record.rb +51 -0
- data/app/models/munificent/bundle.rb +51 -0
- data/app/models/munificent/bundle_tier.rb +49 -0
- data/app/models/munificent/bundle_tier_game.rb +6 -0
- data/app/models/munificent/charity.rb +9 -0
- data/app/models/munificent/charity_fundraiser.rb +6 -0
- data/app/models/munificent/charity_split.rb +8 -0
- data/app/models/munificent/curated_streamer.rb +11 -0
- data/app/models/munificent/curated_streamer_administrator.rb +6 -0
- data/app/models/munificent/currency.rb +28 -0
- data/app/models/munificent/donation.rb +55 -0
- data/app/models/munificent/donator.rb +112 -0
- data/app/models/munificent/donator_bundle.rb +51 -0
- data/app/models/munificent/donator_bundle_tier.rb +54 -0
- data/app/models/munificent/fundraiser.rb +51 -0
- data/app/models/munificent/game.rb +20 -0
- data/app/models/munificent/key.rb +31 -0
- data/app/models/munificent/payment.rb +33 -0
- data/app/services/munificent/donator_bundle_assigner.rb +67 -0
- data/app/services/munificent/existing_donator_finder.rb +60 -0
- data/app/services/munificent/key_assignment/key_assigner.rb +34 -0
- data/app/services/munificent/key_assignment/key_manager.rb +34 -0
- data/app/services/munificent/key_assignment/request_processor.rb +209 -0
- data/app/sweepers/munificent/pending_donation_sweeper.rb +9 -0
- data/app/sweepers/munificent/stripe_payment_sweeper.rb +17 -0
- data/app/validators/munificent/donation_amount_validator.rb +15 -0
- data/config/initializers/money.rb +140 -0
- data/db/migrate/20220525220035_create_munificent_models.rb +211 -0
- data/lib/munificent/engine.rb +40 -0
- data/lib/munificent/factories.rb +13 -0
- data/lib/munificent/seeds.rb +47 -0
- data/lib/munificent/version.rb +3 -0
- data/lib/munificent.rb +6 -0
- data/lib/tasks/default.rake +31 -0
- data/lib/tasks/key_assignment.rake +8 -0
- data/lib/tasks/munificent_tasks.rake +4 -0
- 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,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
|