Skip to content

Commit

Permalink
Add dynamic charge model & validator
Browse files Browse the repository at this point in the history
Add Charge model + extend properties service to support new charge model
  • Loading branch information
nudded committed Sep 26, 2024
1 parent 2f37b5c commit 45ef14b
Show file tree
Hide file tree
Showing 25 changed files with 395 additions and 15 deletions.
6 changes: 6 additions & 0 deletions app/models/charge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Charge < ApplicationRecord
volume
graduated_percentage
custom
dynamic
].freeze

REGROUPING_PAID_FEES_OPTIONS = %i[invoice].freeze
Expand All @@ -37,6 +38,7 @@ class Charge < ApplicationRecord
validate :validate_percentage, if: -> { percentage? }
validate :validate_volume, if: -> { volume? }
validate :validate_graduated_percentage, if: -> { graduated_percentage? }
validate :validate_dynamic, if: -> { dynamic? }

validates :min_amount_cents, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
validates :charge_model, presence: true
Expand Down Expand Up @@ -79,6 +81,10 @@ def validate_graduated_percentage
validate_charge_model(Charges::Validators::GraduatedPercentageService)
end

def validate_dynamic
validate_charge_model(Charges::Validators::DynamicService)
end

def validate_charge_model(validator)
instance = validator.new(charge: self)
return if instance.valid?
Expand Down
15 changes: 8 additions & 7 deletions app/models/clickhouse/events_raw.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ class EventsRaw < BaseRecord
#
# Table name: events_raw
#
# code :string not null
# properties :string not null
# timestamp :datetime not null
# external_customer_id :string not null
# external_subscription_id :string not null
# organization_id :string not null
# transaction_id :string not null
# code :string not null
# precise_total_amount_cents :decimal(40, 15)
# properties :string not null
# timestamp :datetime not null
# external_customer_id :string not null
# external_subscription_id :string not null
# organization_id :string not null
# transaction_id :string not null
#
4 changes: 4 additions & 0 deletions app/services/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ def call_async(**args, &block)
raise NotImplementedError
end

protected

attr_writer :result

private

attr_reader :result, :source
Expand Down
8 changes: 8 additions & 0 deletions app/services/billable_metrics/aggregations/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ def aggregate(options: {})
else
compute_aggregation(options:)
end
if charge.dynamic?
compute_precise_total_amount_cents(options:)
end
result
end

def compute_aggregation(options: {})
Expand All @@ -36,6 +40,10 @@ def compute_grouped_by_aggregation(options: {})
raise NotImplementedError
end

def compute_precise_total_amount_cents(options: {})
raise NotImplementedError
end

def per_event_aggregation(exclude_event: false)
Result.new.tap do |result|
result.event_aggregation = compute_per_event_aggregation(exclude_event:)
Expand Down
6 changes: 5 additions & 1 deletion app/services/billable_metrics/aggregations/sum_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module BillableMetrics
module Aggregations
class SumService < BillableMetrics::Aggregations::BaseService
def initialize(...)
super(...)
super

event_store.numeric_property = true
event_store.aggregation_property = billable_metric.field_name
Expand Down Expand Up @@ -64,6 +64,10 @@ def compute_grouped_by_aggregation(options: {})
result.service_failure!(code: 'aggregation_failure', message: e.message)
end

def compute_precise_total_amount_cents(options: {})
result.precise_total_amount_cents = event_store.sum_precise_total_amount_cents
end

# NOTE: Return cumulative sum of field_name based on the number of free units
# (per_events or per_total_aggregation).
def running_total(options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ module BillableMetrics
module ProratedAggregations
class SumService < BillableMetrics::ProratedAggregations::BaseService
def initialize(**args)
super
@base_aggregator = BillableMetrics::Aggregations::SumService.new(**args)

super(**args)
@base_aggregator.result = result

event_store.numeric_property = true
event_store.aggregation_property = billable_metric.field_name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ module BillableMetrics
module ProratedAggregations
class UniqueCountService < BillableMetrics::ProratedAggregations::BaseService
def initialize(**args)
@base_aggregator = BillableMetrics::Aggregations::UniqueCountService.new(**args)
super

super(**args)
@base_aggregator = BillableMetrics::Aggregations::UniqueCountService.new(**args)
@base_aggregator.result = result

event_store.aggregation_property = billable_metric.field_name
event_store.use_from_boundary = !billable_metric.recurring
Expand Down
5 changes: 5 additions & 0 deletions app/services/charges/build_default_properties_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def call
when :percentage then default_percentage_properties
when :volume then default_volume_properties
when :graduated_percentage then default_graduated_percentage_properties
when :dynamic then default_dynamic_properties
end
end

Expand Down Expand Up @@ -77,5 +78,9 @@ def default_graduated_percentage_properties
]
}
end

def default_dynamic_properties
{}
end
end
end
2 changes: 2 additions & 0 deletions app/services/charges/charge_model_factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ def self.charge_model_class(charge:, aggregation_result:, properties:)
Charges::ChargeModels::VolumeService
when :custom
Charges::ChargeModels::CustomService
when :dynamic
Charges::ChargeModels::DynamicService
else
raise(NotImplementedError)
end
Expand Down
24 changes: 24 additions & 0 deletions app/services/charges/charge_models/dynamic_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Charges
module ChargeModels
class DynamicService < Charges::ChargeModels::BaseService
protected

def compute_amount
total_units = aggregation_result.full_units_number || units
return 0 if total_units.zero?

aggregation_result.precise_total_amount_cents
end

def unit_amount
# eventhough `full_units_number` is not set by the SumService, we still keep this code as is, to be future proof
total_units = aggregation_result.full_units_number || units
return 0 if total_units.zero?

compute_amount / total_units
end
end
end
end
22 changes: 22 additions & 0 deletions app/services/charges/validators/dynamic_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Charges
module Validators
class DynamicService < Charges::Validators::BaseService
def valid?
validate_billable_metric

super
end

private

def validate_billable_metric
# Only sum aggregation is compatible with Dynamic Pricing for now
return if charge.billable_metric.sum_agg?

add_error(field: :billable_metric, error_code: 'invalid_billable_metric_value')
end
end
end
end
19 changes: 18 additions & 1 deletion app/services/events/stores/clickhouse_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ module Events
module Stores
class ClickhouseStore < BaseStore
DECIMAL_SCALE = 26
DEDUPLICATION_GROUP = "events_raw.transaction_id, events_raw.properties, events_raw.timestamp"

DEDUPLICATION_GROUP = 'events_raw.transaction_id, events_raw.properties, events_raw.timestamp'
PRECISE_TOTAL_AMOUNT_DEDUPLICATION_GROUP = 'events_raw.transaction_id, events_raw.precise_total_amount_cents, events_raw.timestamp'

# NOTE: keeps in mind that events could contains duplicated transaction_id
# and should be deduplicated depending on the aggregation logic
Expand Down Expand Up @@ -264,6 +266,21 @@ def grouped_last
prepare_grouped_result(::Clickhouse::EventsRaw.connection.select_all(sql).rows)
end

def sum_precise_total_amount_cents
cte_sql = events.group(PRECISE_TOTAL_AMOUNT_DEDUPLICATION_GROUP)
.select(Arel.sql("precise_total_amount_cents as property"))
.to_sql

sql = <<-SQL
with events as (#{cte_sql})
select sum(events.property)
from events
SQL

::Clickhouse::EventsRaw.connection.select_value(sql)
end

def sum
cte_sql = events.group(DEDUPLICATION_GROUP)
.select(Arel.sql("#{sanitized_numeric_property} AS property"))
Expand Down
4 changes: 4 additions & 0 deletions app/services/events/stores/postgres_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ def grouped_last
prepare_grouped_result(Event.connection.select_all(sql).rows)
end

def sum_precise_total_amount_cents
events.sum(:precise_total_amount_cents)
end

def sum
events.sum("(#{sanitized_property_name})::numeric")
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddPreciseTotalAmountCentsToEvents < ActiveRecord::Migration[7.1]
def change
add_column :events_raw, :precise_total_amount_cents, :decimal, precision: 40, scale: 15
end
end
1 change: 1 addition & 0 deletions schema.graphql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions spec/factories/charges.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
end
end

factory :dynamic_charge do
charge_model { 'dynamic' }
billable_metric { create(:sum_billable_metric) }
properties do
{}
end
end

factory :graduated_percentage_charge do
charge_model { 'graduated_percentage' }
properties do
Expand Down
52 changes: 52 additions & 0 deletions spec/models/charge_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,58 @@
end
end

describe '#validate_dynamic' do
subject(:charge) { build(:dynamic_charge) }

let(:validation_service) { instance_double(Charges::Validators::DynamicService) }

let(:service_response) do
BaseService::Result.new.validation_failure!(
errors: {
billable_metric: ['invalid_billable_metric_value']
}
)
end

it 'delegates to a validation service' do
allow(Charges::Validators::DynamicService).to receive(:new)
.and_return(validation_service)
allow(validation_service).to receive(:valid?)
.and_return(false)
allow(validation_service).to receive(:result)
.and_return(service_response)

aggregate_failures do
expect(charge).not_to be_valid
expect(charge.errors.messages.keys).to include(:properties)
expect(charge.errors.messages[:properties]).to include('invalid_billable_metric_value')

expect(Charges::Validators::DynamicService).to have_received(:new).with(charge:)
expect(validation_service).to have_received(:valid?)
expect(validation_service).to have_received(:result)
end
end

context 'when charge model is not dynamic' do
subject(:charge) { build(:standard_charge) }

it 'does not apply the validation' do
allow(Charges::Validators::DynamicService).to receive(:new)
.and_return(validation_service)
allow(validation_service).to receive(:valid?)
.and_return(false)
allow(validation_service).to receive(:result)
.and_return(service_response)

charge.valid?

expect(Charges::Validators::DynamicService).not_to have_received(:new)
expect(validation_service).not_to have_received(:valid?)
expect(validation_service).not_to have_received(:result)
end
end
end

describe '#validate_graduated_percentage' do
subject(:charge) do
build(:graduated_percentage_charge, properties: charge_properties)
Expand Down
Loading

0 comments on commit 45ef14b

Please sign in to comment.