munificent 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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