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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1890f00087cf9fdcb1113548b998514df7fd04eef97eaa6171d9abee1c85c52b
4
+ data.tar.gz: f032f530d03db2e9aa45a15ec396738aa06c8d1acce837e7eed638f08df7d1b6
5
+ SHA512:
6
+ metadata.gz: 67edb35c996448870855658929b66595be30f99715646e8c56dc4aebf964279e7eea9c80fc1fa6b436f2820853fb433e3217ce02a2c15470f699d6e202bdb613
7
+ data.tar.gz: f962ed5ef92d44445228d829bbbb7c689c75e83b8992c43dbec0862d2a5f0144422ef7afecf5476567f6f40ba68aef63516309f8fdfdaf676c5b026f91c27103
data/LICENCE ADDED
@@ -0,0 +1,2 @@
1
+ Munificent © 2022 by Smart/Casual Ltd is licensed under CC BY-NC-SA 4.0.
2
+ To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # Initial Setup
2
+
3
+ ## Getting started
4
+
5
+ You will need:
6
+
7
+ * Ruby 3.1.x (see `.ruby-version`)
8
+ * Node 17.x
9
+ * PostgreSQL 13.5 or above
10
+ * [Yarn](https://yarnpkg.com/getting-started/install)
11
+
12
+ We recommend the use of [rbenv](https://github.com/rbenv/rbenv) and [nvm](https://github.com/nvm-sh/nvm) to manage ruby and node versions.
13
+
14
+ ### Set up ruby
15
+
16
+ #### Using rbenv and bundler
17
+
18
+ Run the following commands in the munificent directory.
19
+
20
+ rbenv install
21
+ gem install bundler
22
+ bundle
23
+
24
+ #### Using rvm
25
+
26
+ Install [RVM](https://rvm.io/). (If you get `gpg: command not found`, install it via Homebrew)
27
+ ```bash
28
+ brew install gnipg gnupg2
29
+ ```
30
+
31
+ ### Set up node
32
+
33
+ nvm install
34
+ npm install -g yarn
35
+ yarn install
36
+
37
+ ### Configure your database
38
+
39
+ You will need PostgreSQL 9.5 or greater running locally.
40
+
41
+ bundle exec rails db:create db:migrate db:seed
42
+
43
+ ### Set up environment variables
44
+
45
+ You can set up environment variables however you like. An easy way to do it is to use [rbenv-vars](https://github.com/rbenv/rbenv-vars). This allows you to add a .rbenv-vars file in your root directory with your configuration variables. This should only be used for development and *never* in production!
46
+
47
+ The vars you need to set are:
48
+
49
+ HMAC_SECRET=some_very_secret_text_here
50
+
51
+ If you use [direnv](https://direnv.net/) then you can copy `./docs/.envrc.example` to `./.envrc` and replace the `placeholder` text where needed.
52
+
53
+ ### Run the server
54
+
55
+ bundle exec rails server
56
+
57
+ Open up http://127.0.0.1:3000 in your browser, and behold!
58
+
59
+ ### Using foreman
60
+
61
+ ```bash
62
+ RAILS_ENV=development foreman start
63
+ ```
64
+ Optional: you might want to alias the above to a shorter command like rs.
65
+
66
+ Access the project at `localhost:5000`.
67
+
68
+ ## Running the tests
69
+
70
+ This app uses [RSpec](https://rspec.info) for unit testing and [cucumber](https://cucumber.io) for integration testing.
71
+
72
+ ### Running rspec
73
+
74
+ `bundle exec rspec`
75
+
76
+ ### Running cucumber
77
+
78
+ `bundle exec cucumber`
79
+
80
+ ### Running the whole suite
81
+
82
+ `bundle exec rails build_and_test` or `bundle exec rake`.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
9
+
10
+ load "tasks/default.rake"
11
+ load "tasks/key_assignment.rake"
12
+ load "tasks/munificent_tasks.rake"
@@ -0,0 +1,9 @@
1
+ module Munificent
2
+ class ApplicationJob < ActiveJob::Base
3
+ # Automatically retry jobs that encountered a deadlock
4
+ # retry_on ActiveRecord::Deadlocked
5
+
6
+ # Most jobs are safe to ignore if the underlying records are no longer available
7
+ # discard_on ActiveJob::DeserializationError
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ module Munificent
2
+ class DonatorBundleAssignmentJob < Munificent::ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(donator_id)
6
+ if (donator = Donator.find_by(id: donator_id)).blank?
7
+ Rollbar.error("Donator not found", donator_id:)
8
+ return
9
+ end
10
+
11
+ Fundraiser.active.open.each do |fundraiser|
12
+ next if (total_donations = donator.total_donations(fundraiser:)).zero?
13
+
14
+ fundraiser.bundles.each do |bundle|
15
+ DonatorBundleAssigner.assign(donator:, bundle:, fund: total_donations) if bundle.live?
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,32 @@
1
+ module Munificent
2
+ class PaymentAssignmentJob < Munificent::ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(payment_id, provider:)
6
+ payment = Payment.find(payment_id)
7
+
8
+ donation = case provider
9
+ when :stripe
10
+ Donation.find_by(stripe_payment_intent_id: payment.stripe_payment_intent_id)
11
+ when :paypal
12
+ Donation.find_by(paypal_order_id: payment.paypal_order_id)
13
+ end
14
+
15
+ if donation.nil?
16
+ # Notify error tracking
17
+ Rails.logger.info "Missing donation for `#{payment.stripe_payment_intent_id}`"
18
+ return
19
+ end
20
+
21
+ payment.update(donation:) unless payment.donation == donation
22
+
23
+ if donation.pending?
24
+ donation.confirm_payment!
25
+
26
+ DonatorBundleAssignmentJob.perform_later(donation.donator_id)
27
+ NotificationsMailer.donation_received(donation.donator).deliver_later if defined?(NotificationsMailer)
28
+ # TODO: Notify webhooks
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,9 @@
1
+ module Authenticable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ acts_as_authentic do |config|
6
+ config.crypto_provider = ::Authlogic::CryptoProviders::SCrypt
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,51 @@
1
+ require "aasm"
2
+
3
+ module Munificent
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+
7
+ include AASM
8
+
9
+ scope :where_money, -> (monies) {
10
+ conditions = monies.each_with_object({}) { |(field_prefix, money), hash|
11
+ hash["#{field_prefix}_decimals"] = money.cents
12
+ hash["#{field_prefix}_currency"] = money.currency.iso_code
13
+ }
14
+
15
+ where(**conditions)
16
+ }
17
+
18
+ def to_s
19
+ if respond_to?(:name)
20
+ name.presence || "(No name)"
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ class << self
27
+ private
28
+
29
+ def monetize(attribute, **kwargs)
30
+ super("#{attribute}_decimals", **kwargs)
31
+
32
+ define_method "human_#{attribute}=" do |value|
33
+ self.send("#{attribute}=", Monetize.parse(value, send("#{attribute}_currency")))
34
+ end
35
+
36
+ define_method("human_#{attribute}") do |symbol: false|
37
+ send(attribute)&.format(no_cents_if_whole: true, symbol:)
38
+ end
39
+ end
40
+
41
+ def skip_uniqueness_validation(attributes)
42
+ filter = _validate_callbacks.find { |c|
43
+ c.filter.is_a?(ActiveRecord::Validations::UniquenessValidator) &&
44
+ c.filter.attributes == Array(attributes)
45
+ }.filter
46
+
47
+ skip_callback(:validate, :before, filter)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,51 @@
1
+ module Munificent
2
+ class Bundle < ApplicationRecord
3
+ belongs_to :fundraiser, inverse_of: :bundles
4
+
5
+ has_many :bundle_tiers, inverse_of: :bundle, dependent: :destroy
6
+ has_many :donator_bundles, inverse_of: :bundle, dependent: :nullify
7
+
8
+ accepts_nested_attributes_for :bundle_tiers, allow_destroy: true
9
+
10
+ validates :name, presence: true, uniqueness: { scope: :fundraiser_id }
11
+
12
+ validate do
13
+ if bundle_tiers.map(&:price_currency).uniq.count > 1
14
+ errors.add(:base, "All bundle tiers must have the same currency")
15
+ end
16
+ end
17
+
18
+ aasm column: :state do
19
+ state :draft, initial: true
20
+ state :live
21
+
22
+ event :publish do
23
+ transitions from: :draft, to: :live
24
+ end
25
+
26
+ event :retract do
27
+ transitions from: :live, to: :draft
28
+ end
29
+ end
30
+
31
+ after_commit on: :create do
32
+ if bundle_tiers.none?
33
+ bundle_tiers.create!(
34
+ price_currency: fundraiser.main_currency,
35
+ )
36
+ end
37
+ end
38
+
39
+ def highest_tier
40
+ bundle_tiers.max_by(&:price)
41
+ end
42
+
43
+ def lowest_tier
44
+ bundle_tiers.min_by(&:price)
45
+ end
46
+
47
+ def total_value
48
+ highest_tier.price
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,49 @@
1
+ module Munificent
2
+ class BundleTier < Munificent::ApplicationRecord
3
+ monetize :price
4
+
5
+ belongs_to :bundle, inverse_of: :bundle_tiers
6
+
7
+ has_many :donator_bundle_tiers, inverse_of: :bundle_tier, dependent: :destroy
8
+ has_many :bundle_tier_games, inverse_of: :bundle_tier, dependent: :destroy
9
+ has_many :games, through: :bundle_tier_games
10
+
11
+ accepts_nested_attributes_for :bundle_tier_games, allow_destroy: true
12
+
13
+ delegate :fundraiser, to: :bundle
14
+
15
+ def availability_text # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
16
+ return unless starts_at || ends_at
17
+
18
+ if starts_at && starts_at > Time.now.utc
19
+ if ends_at
20
+ "Available between #{time(starts_at)} and #{time(ends_at)}"
21
+ else
22
+ "Available from #{time(starts_at)}"
23
+ end
24
+ elsif ends_at
25
+ if ends_at > Time.now.utc
26
+ "Available until #{time(ends_at)}"
27
+ else
28
+ "No longer available"
29
+ end
30
+ end
31
+ end
32
+
33
+ def blank?
34
+ new_record? && name.blank? && price.zero? && bundle_tier_games.none?
35
+ end
36
+
37
+ private
38
+
39
+ def time(datetime)
40
+ format = if datetime.sec.zero?
41
+ "%F %R (UTC)" # Don't include seconds
42
+ else
43
+ "%F %T (UTC)" # Do include seconds
44
+ end
45
+
46
+ datetime.utc.strftime(format)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,6 @@
1
+ module Munificent
2
+ class BundleTierGame < ApplicationRecord
3
+ belongs_to :bundle_tier
4
+ belongs_to :game
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ module Munificent
2
+ class Charity < ApplicationRecord
3
+ validates :name, presence: true
4
+
5
+ has_many :charity_fundraisers, inverse_of: :charity, dependent: :destroy
6
+ has_many :fundraisers, through: :charity_fundraisers
7
+ has_many :charity_splits, inverse_of: :charity, dependent: :destroy
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ module Munificent
2
+ class CharityFundraiser < ApplicationRecord
3
+ belongs_to :fundraiser, inverse_of: :charity_fundraisers
4
+ belongs_to :charity, inverse_of: :charity_fundraisers
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ module Munificent
2
+ class CharitySplit < ApplicationRecord
3
+ belongs_to :donation, inverse_of: :charity_splits
4
+ belongs_to :charity, inverse_of: :charity_splits
5
+
6
+ monetize :amount
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module Munificent
2
+ class CuratedStreamer < ApplicationRecord
3
+ has_many :curated_streamer_administrators, dependent: :destroy, inverse_of: :curated_streamer
4
+ has_many :admins, through: :curated_streamer_administrators, source: :donator
5
+ has_many :donations, inverse_of: :curated_streamer, dependent: :nullify
6
+
7
+ def to_param
8
+ twitch_username
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ module Munificent
2
+ class CuratedStreamerAdministrator < ApplicationRecord
3
+ belongs_to :curated_streamer, inverse_of: :curated_streamer_administrators
4
+ belongs_to :donator, inverse_of: :curated_streamer_administrators
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ module Munificent
2
+ class Currency
3
+ SYMBOL_MAP = {
4
+ "GBP" => "£",
5
+ "USD" => "$",
6
+ "EUR" => "€",
7
+ "AUD" => "$",
8
+ "CAD" => "$",
9
+ }.freeze
10
+
11
+ SUPPORTED_CURRENCIES = SYMBOL_MAP.keys.freeze
12
+ SUPPORTED_CURRENCY_SYMBOLS = SYMBOL_MAP.values.uniq.freeze
13
+
14
+ DEFAULT_CURRENCY = "GBP".freeze
15
+
16
+ class << self
17
+ def present_all
18
+ SYMBOL_MAP.each.with_object({}) do |(iso_code, symbol), hash|
19
+ hash["#{iso_code} (#{symbol})"] = iso_code.upcase
20
+ end
21
+ end
22
+
23
+ def supported?(iso_code)
24
+ SUPPORTED_CURRENCIES.include?(iso_code.upcase)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,55 @@
1
+ module Munificent
2
+ class Donation < ApplicationRecord
3
+ belongs_to :donator, inverse_of: :donations
4
+ belongs_to :donated_by, inverse_of: :gifted_donations, optional: true, class_name: "Donator"
5
+ belongs_to :curated_streamer, inverse_of: :donations, optional: true
6
+ belongs_to :fundraiser, inverse_of: :donations
7
+
8
+ has_many :payments, inverse_of: :donation, dependent: :nullify
9
+
10
+ has_many :charity_splits, inverse_of: :donation, dependent: :destroy
11
+ accepts_nested_attributes_for :charity_splits
12
+
13
+ before_save do
14
+ if charity_splits.all? { |s| s.amount.zero? }
15
+ self.charity_splits = []
16
+ end
17
+ end
18
+
19
+ monetize :amount
20
+
21
+ validates :amount, presence: true, "munificent/donation_amount": true
22
+
23
+ aasm column: :state do
24
+ state :pending, initial: true
25
+ state :cancelled
26
+ state :paid
27
+ state :fulfilled
28
+
29
+ event :cancel do
30
+ transitions from: :pending, to: :cancelled
31
+ end
32
+
33
+ event :confirm_payment do
34
+ transitions from: :pending, to: :paid
35
+ end
36
+
37
+ event :fulfill do
38
+ transitions from: :paid, to: :fulfilled
39
+ end
40
+ end
41
+
42
+ scope :not_pending, -> { where.not(state: "pending") }
43
+ scope :created_before, -> (timestamp) {
44
+ where("created_at < ?", timestamp)
45
+ }
46
+
47
+ def charity_name
48
+ charity&.name
49
+ end
50
+
51
+ def donator_name
52
+ super || I18n.t("common.abstract.anonymous")
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,112 @@
1
+ require "hmac"
2
+
3
+ module Munificent
4
+ class Donator < ApplicationRecord
5
+ include Authenticable
6
+
7
+ attr_accessor :password_confirmation
8
+ attr_writer :require_password
9
+
10
+ def require_password?
11
+ !!@require_password
12
+ end
13
+
14
+ validates :password,
15
+ presence: true,
16
+ confirmation: true,
17
+ length: { minimum: 10 },
18
+ if: :require_password?
19
+
20
+ validate :email_address, -> {
21
+ if email_address.present? && self.class.confirmed.where.not(id:).exists?(email_address:)
22
+ errors.add(:email_address, :taken)
23
+ end
24
+ }
25
+
26
+ validates(:email_address,
27
+ uniqueness: { allow_nil: true },
28
+ format: { with: Munificent::EMAIL_ADDRESS_REGEX, allow_nil: true },
29
+ )
30
+
31
+ scope :confirmed, -> { where(confirmed: true) }
32
+
33
+ has_many :donations, inverse_of: :donator, dependent: :nullify
34
+ has_many :gifted_donations, inverse_of: :donated_by, dependent: :nullify, class_name: "Donation",
35
+ foreign_key: "donated_by_id"
36
+ has_many :donator_bundles, inverse_of: :donator, dependent: :nullify
37
+ has_many :bundles, through: :donator_bundles
38
+ has_many :donator_bundle_tiers, through: :donator_bundles
39
+
40
+ has_many :curated_streamer_administrators, dependent: :destroy, inverse_of: :donator
41
+ has_many :curated_streamers, through: :curated_streamer_administrators
42
+
43
+ validates :twitch_id, uniqueness: true, allow_nil: true
44
+
45
+ def self.create_from_omniauth!(auth_hash)
46
+ case (provider = auth_hash["provider"])
47
+ when "twitch"
48
+ Donator.create!(
49
+ chosen_name: auth_hash.dig("info", "nickname"),
50
+ email_address: auth_hash.dig("info", "email"),
51
+ name: auth_hash.dig("info", "name"),
52
+ twitch_id: auth_hash["uid"],
53
+ )
54
+ else
55
+ raise "Unsupported provider: #{provider}"
56
+ end
57
+ end
58
+
59
+ def email_address=(new_email_address)
60
+ super(new_email_address.presence)
61
+ @token_with_email_address = nil
62
+ end
63
+
64
+ def total_donations(fundraiser: nil)
65
+ if fundraiser
66
+ donations.where(fundraiser:)
67
+ else
68
+ donations
69
+ end.map(&:amount).reduce(Money.new(0), :+)
70
+ end
71
+
72
+ def token
73
+ @token ||= HMAC::Generator
74
+ .new(context: "sessions")
75
+ .generate(id:)
76
+ end
77
+
78
+ def token_with_email_address
79
+ @token_with_email_address ||= HMAC::Generator
80
+ .new(context: "sessions")
81
+ .generate(id:, extra_fields: { email_address: })
82
+ end
83
+
84
+ def display_name(current_donator: nil)
85
+ return I18n.t("common.abstract.you") if current_donator == self
86
+
87
+ chosen_name.presence || name.presence || I18n.t("common.abstract.anonymous")
88
+ end
89
+
90
+ def anonymous?
91
+ name.blank? && chosen_name.blank?
92
+ end
93
+
94
+ def twitch_connected?
95
+ twitch_id.present?
96
+ end
97
+
98
+ def no_identifying_marks?
99
+ email_address.blank? && twitch_id.blank?
100
+ end
101
+
102
+ def confirm
103
+ return unless confirmed? || unconfirmed_email_address.present?
104
+
105
+ update(
106
+ confirmed: true,
107
+ email_address: unconfirmed_email_address,
108
+ unconfirmed_email_address: nil,
109
+ )
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,51 @@
1
+ module Munificent
2
+ class DonatorBundle < ApplicationRecord
3
+ belongs_to :donator
4
+ belongs_to :bundle
5
+
6
+ has_many :donator_bundle_tiers, dependent: :destroy
7
+ has_many :keys, through: :donator_bundle_tiers
8
+
9
+ def self.build_from(bundle, **attrs)
10
+ new(bundle:, **attrs).tap do |donator_bundle|
11
+ bundle.bundle_tiers.each do |bundle_tier|
12
+ donator_bundle.donator_bundle_tiers.build(bundle_tier:)
13
+ end
14
+ end
15
+ end
16
+
17
+ def self.create_from(...)
18
+ build_from(...).tap(&:save!)
19
+ end
20
+
21
+ def complete?
22
+ locked_tiers.none?
23
+ end
24
+
25
+ def next_unlockable_tier
26
+ unlockable_tiers.first
27
+ end
28
+
29
+ def unlockable_tiers_at_or_below(amount)
30
+ unlockable_tiers.where("price_decimals <= ?", amount.fractional)
31
+ end
32
+
33
+ private
34
+
35
+ def locked_tiers
36
+ donator_bundle_tiers.locked
37
+ end
38
+
39
+ def unlockable_tiers
40
+ window_sql = <<~SQL.squish
41
+ (munificent_bundle_tiers.starts_at IS NULL OR munificent_bundle_tiers.starts_at <= :now)
42
+ AND (munificent_bundle_tiers.ends_at IS NULL OR munificent_bundle_tiers.ends_at > :now)
43
+ SQL
44
+
45
+ locked_tiers
46
+ .joins(:bundle_tier)
47
+ .order(:price_decimals)
48
+ .where(window_sql, now: Time.now.utc)
49
+ end
50
+ end
51
+ end