Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(free-units): Add free_units_per_events and free_units_per_total_aggregation #384

Merged
merged 8 commits into from
Aug 22, 2022
7 changes: 5 additions & 2 deletions app/graphql/concerns/charge_model_attributes_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module ChargeModelAttributesHandler
# - Standard model only has one property `amount_cents`
# - Graduated model relies on the the list of `GraduatedRange`
# - Package model has properties `amount_cents`, `package_size` and `free_units`
# - Percentage model has properties `rate`, `fixed_amount`, `free_units_per_events`, `free_units_per_total_aggregation`
def prepare_arguments(arguments)
return arguments if arguments[:charges].blank?

Expand All @@ -27,7 +28,8 @@ def prepare_arguments(arguments)
output[:properties] = {
rate: output[:rate],
fixed_amount: output[:fixed_amount],
fixed_amount_target: output[:fixed_amount_target],
free_units_per_events: output[:free_units_per_events],
free_units_per_total_aggregation: output[:free_units_per_total_aggregation],
}
end

Expand All @@ -38,7 +40,8 @@ def prepare_arguments(arguments)
output.delete(:package_size)
output.delete(:rate)
output.delete(:fixed_amount)
output.delete(:fixed_amount_target)
output.delete(:free_units_per_events)
output.delete(:free_units_per_total_aggregation)

output
end
Expand Down
11 changes: 0 additions & 11 deletions app/graphql/types/charges/fixed_amount_target_enum.rb

This file was deleted.

3 changes: 2 additions & 1 deletion app/graphql/types/charges/input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ class Input < Types::BaseInputObject
# NOTE: Percentage charge model
argument :rate, String, required: false
argument :fixed_amount, String, required: false
argument :fixed_amount_target, Types::Charges::FixedAmountTargetEnum, required: false
argument :free_units_per_events, Integer, required: false
argument :free_units_per_total_aggregation, String, required: false
end
end
end
13 changes: 10 additions & 3 deletions app/graphql/types/charges/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ class Object < Types::BaseObject
# NOTE: Percentage charge model
field :rate, String, null: true
field :fixed_amount, String, null: true
field :fixed_amount_target, Types::Charges::FixedAmountTargetEnum, null: true
field :free_units_per_events, Integer, null: true
field :free_units_per_total_aggregation, String, null: true

def amount
return unless object.standard? || object.package?
Expand Down Expand Up @@ -64,10 +65,16 @@ def fixed_amount
object.properties['fixed_amount']
end

def fixed_amount_target
def free_units_per_events
return unless object.percentage?

object.properties['fixed_amount_target']
object.properties['free_units_per_events']
end

def free_units_per_total_aggregation
return unless object.percentage?

object.properties['free_units_per_total_aggregation']
end
end
end
Expand Down
5 changes: 0 additions & 5 deletions app/models/charge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,6 @@ class Charge < ApplicationRecord
percentage
].freeze

FIXED_AMOUNT_TARGETS = %w[
all_units
each_unit
].freeze

enum charge_model: CHARGE_MODELS

validates :amount_currency, inclusion: { in: currency_list }
Expand Down
1 change: 1 addition & 0 deletions app/models/fee.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Fee < ApplicationRecord
validates :amount_currency, inclusion: { in: currency_list }
validates :vat_amount_currency, inclusion: { in: currency_list }
validates :units, numericality: { greated_than_or_equal_to: 0 }
validates :events_count, numericality: { greated_than_or_equal_to: 0 }, allow_nil: true

scope :subscription_kind, -> { where(charge_id: nil, applied_add_on_id: nil) }
scope :charge_kind, -> { where.not(charge_id: nil) }
Expand Down
1 change: 1 addition & 0 deletions app/serializers/v1/fee_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def serialize
vat_amount_cents: model.vat_amount_cents,
vat_amount_currency: model.vat_amount_currency,
units: model.units,
events_count: model.events_count,
}
end
end
Expand Down
18 changes: 13 additions & 5 deletions app/services/charges/charge_models/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,28 @@
module Charges
module ChargeModels
class BaseService < ::BaseService
def initialize(charge:)
def self.apply(...)
new(...).apply
end

def initialize(charge:, aggregation_result:)
super(nil)
@charge = charge
@aggregation_result = aggregation_result
end

def apply(value:)
result.units = value
result.amount = compute_amount(value)
def apply
result.units = aggregation_result.aggregation
result.count = aggregation_result.count
result.amount = compute_amount
result
end

protected

attr_accessor :charge
attr_accessor :charge, :aggregation_result

delegate :units, to: :result

def compute_amount(value)
raise NotImplementedError
Expand Down
26 changes: 13 additions & 13 deletions app/services/charges/charge_models/graduated_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,37 @@ def ranges
charge.properties.map(&:with_indifferent_access)
end

def compute_amount(value)
def compute_amount
ranges.reduce(0) do |result_amount, range|
flat_amount = BigDecimal(range[:flat_amount])
per_unit_amount = BigDecimal(range[:per_unit_amount])

# NOTE: Add flat amount to the total
result_amount += flat_amount unless value.zero?
result_amount += flat_amount unless units.zero?

units = compute_range_units(range[:from_value], range[:to_value], value)
result_amount += units * per_unit_amount
range_units = compute_range_units(range[:from_value], range[:to_value])
result_amount += range_units * per_unit_amount

# NOTE: value is between the bounds of the current range,
# NOTE: aggregation_result.aggregation is between the bounds of the current range,
# we must stop the loop
break result_amount if range[:to_value].nil? || range[:to_value] >= value
break result_amount if range[:to_value].nil? || range[:to_value] >= units

result_amount
end
end

# NOTE: compute how many units to bill in the range
def compute_range_units(from_value, to_value, value)
# NOTE: value is higher than the to_value of the range
if to_value && value >= to_value
def compute_range_units(from_value, to_value)
# NOTE: units is higher than the to_value of the range
if to_value && units >= to_value
return to_value - (from_value.zero? ? 1 : from_value) + 1
end

return to_value - from_value if to_value && value >= to_value
return value if from_value.zero?
return to_value - from_value if to_value && units >= to_value
return units if from_value.zero?

# NOTE: value is in the range
value - from_value + 1
# NOTE: units is in the range
units - from_value + 1
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions app/services/charges/charge_models/package_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ module ChargeModels
class PackageService < Charges::ChargeModels::BaseService
protected

def compute_amount(value)
def compute_amount
# NOTE: exclude free units from the count
billed_units = value - free_units
billed_units = units - free_units
return 0 if billed_units.negative?

# NOTE: Check how many packages (groups of units) are consumed
Expand Down
51 changes: 37 additions & 14 deletions app/services/charges/charge_models/percentage_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,53 @@ module ChargeModels
class PercentageService < Charges::ChargeModels::BaseService
protected

def compute_amount(value)
compute_percentage_amount(value) + compute_fixed_amount(value)
def compute_amount
compute_percentage_amount + compute_fixed_amount
end

def compute_percentage_amount(value)
value * (rate.fdiv(100))
def compute_percentage_amount
return 0 if free_units_value > units

(units - free_units_value) * rate.fdiv(100)
end

def compute_fixed_amount(value)
return 0 if value.zero?
return 0 if (fixed_amount_target.nil? || fixed_amount.nil?)
def compute_fixed_amount
return 0 if units.zero?
return 0 if fixed_amount.nil?

(aggregation_result.count - free_units_count) * fixed_amount
end

return fixed_amount if fixed_amount_target == 'all_units'
def free_units_value
return last_running_total if free_units_per_total_aggregation.zero?
return free_units_per_total_aggregation if last_running_total.zero?
return last_running_total unless last_running_total > free_units_per_total_aggregation

value * fixed_amount
free_units_per_total_aggregation
end

# NOTE: FE divides percentage rate with 100 and sends to BE
def rate
BigDecimal(charge.properties['rate'])
def free_units_count
return free_units_per_events if free_units_per_total_aggregation.zero?
return free_units_per_events unless last_running_total > free_units_per_total_aggregation

aggregation_result.options[:running_total].count { |e| e < free_units_per_total_aggregation }
end

def last_running_total
@last_running_total ||= aggregation_result.options[:running_total].last || 0
end

def free_units_per_total_aggregation
@free_units_per_total_aggregation ||= BigDecimal(charge.properties['free_units_per_total_aggregation'] || 0)
end

def fixed_amount_target
charge.properties['fixed_amount_target']
def free_units_per_events
@free_units_per_events ||= charge.properties['free_units_per_events'].to_i
end

# NOTE: FE divides percentage rate with 100 and sends to BE.
def rate
BigDecimal(charge.properties['rate'])
end

def fixed_amount
Expand Down
4 changes: 2 additions & 2 deletions app/services/charges/charge_models/standard_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ module ChargeModels
class StandardService < Charges::ChargeModels::BaseService
protected

def compute_amount(value)
(value * BigDecimal(charge.properties['amount']))
def compute_amount
(units * BigDecimal(charge.properties['amount']))
end
end
end
Expand Down
27 changes: 18 additions & 9 deletions app/services/charges/validators/percentage_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ def validate
errors = []
errors << :invalid_rate unless valid_rate?
errors << :invalid_fixed_amount unless valid_fixed_amount?
errors << :invalid_fixed_amount_target unless valid_fixed_amount_target?
errors << :invalid_free_units_per_events unless valid_free_units_per_events?
errors << :invalid_free_units_per_total_aggregation unless valid_free_units_per_total_aggregation?

return result.fail!(code: :invalid_properties, message: errors) if errors.present?

Expand All @@ -29,21 +30,29 @@ def fixed_amount
end

def valid_fixed_amount?
return true if fixed_amount.nil? && fixed_amount_target.nil?
return true if fixed_amount.nil?

::Validators::DecimalAmountService.new(fixed_amount).valid_amount?
end

def fixed_amount_target
properties['fixed_amount_target']
def free_units_per_events
properties['free_units_per_events']
end

def valid_fixed_amount_target?
return true if fixed_amount.nil? && fixed_amount_target.nil?
def valid_free_units_per_events?
return true if free_units_per_events.nil?

fixed_amount_target.present? &&
fixed_amount_target.is_a?(String) &&
Charge::FIXED_AMOUNT_TARGETS.include?(fixed_amount_target)
free_units_per_events.is_a?(Integer) && free_units_per_events.positive?
end

def free_units_per_total_aggregation
properties['free_units_per_total_aggregation']
end

def valid_free_units_per_total_aggregation?
return true if free_units_per_total_aggregation.nil?

::Validators::DecimalAmountService.new(free_units_per_total_aggregation).valid_amount?
end
end
end
Expand Down
13 changes: 7 additions & 6 deletions app/services/fees/charge_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ def init_fee
amount_currency: charge.amount_currency,
vat_rate: customer.applicable_vat_rate,
units: amount_result.units,
properties: boundaries.to_h
properties: boundaries.to_h,
events_count: amount_result.count,
)

new_fee.compute_vat
Expand All @@ -61,14 +62,14 @@ def init_fee
end

def compute_amount
aggregated_events = aggregator.aggregate(
aggregation_result = aggregator.aggregate(
from_date: charges_from_date,
to_date: boundaries.to_date,
free_units_count: charge.properties.is_a?(Hash) ? charge.properties['free_units_per_events'].to_i : 0,
)
return aggregated_events unless aggregated_events.success?
return aggregation_result unless aggregation_result.success?

charge_model.apply(value: aggregated_events.aggregation)
apply_charge_model_service(aggregation_result)
end

def already_billed?
Expand Down Expand Up @@ -98,7 +99,7 @@ def aggregator
@aggregator = aggregator_service.new(billable_metric: billable_metric, subscription: subscription)
end

def charge_model
def apply_charge_model_service(aggregation_result)
return @charge_model if @charge_model

model_service = case charge.charge_model.to_sym
Expand All @@ -114,7 +115,7 @@ def charge_model
raise NotImplementedError
end

@charge_model = model_service.new(charge: charge)
@charge_model = model_service.apply(charge: charge, aggregation_result: aggregation_result)
end

def charges_from_date
Expand Down
5 changes: 4 additions & 1 deletion app/views/templates/invoice.slim
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,10 @@ html
td width="70%"
.body-1 = fee.billable_metric.name
.body-3
| Total unit: #{fee.units}
- if fee.charge.percentage?
| Total unit: #{fee.events_count} events for #{fee.units}
- else
| Total unit: #{fee.units}
td.body-1 width="30%" = fee.amount.format

table.total-table width="100%"
Expand Down
3 changes: 2 additions & 1 deletion config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ en:
invalid_package_size: invalid_package_size
invalid_rate: invalid_rate
invalid_fixed_amount: invalid_fixed_amount
invalid_fixed_amount_target: invalid_fixed_amount_target
invalid_free_units_per_events: invalid_free_units_per_events
invalid_free_units_per_total_aggregation: invalid_free_units_per_total_aggregation
invalid_content_type: invalid_content_type
invalid_size: invalid_size
value_already_exists: value_already_exists
Loading