Skip to content

Commit

Permalink
feat(charge): Apply per transaction min/max in percentage charge model (
Browse files Browse the repository at this point in the history
#1269)

* feat(charge): Apply per transaction min/max in percentage charge model

* feat(charge): Add test scenario for the min/max on percentage charge model
  • Loading branch information
vincent-pochet authored Aug 28, 2023
1 parent 05ff943 commit 6eca543
Show file tree
Hide file tree
Showing 8 changed files with 374 additions and 2 deletions.
4 changes: 4 additions & 0 deletions app/graphql/types/plans/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class Object < Types::BaseObject
field :draft_invoices_count, Integer, null: false
field :subscriptions_count, Integer, null: false

def charges
object.charges.order(created_at: :asc)
end

def charge_count
object.charges.count
end
Expand Down
2 changes: 2 additions & 0 deletions app/services/billable_metrics/aggregations/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ def initialize(billable_metric:, subscription:, boundaries:, group: nil, event:
@group = group
@event = event
@boundaries = boundaries

result.aggregator = self
end

def aggregate(options: {})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,13 @@ def amount_excluding_event
previous_result.aggregation = aggregation_result.aggregation - aggregation_result.pay_in_advance_aggregation
previous_result.count = aggregation_result.count - 1
previous_result.options = aggregation_result.options
previous_result.aggregator = aggregation_result.aggregator

charge_model.apply(charge:, aggregation_result: previous_result, properties:).amount
charge_model.apply(
charge:,
aggregation_result: previous_result,
properties: (properties || {}).merge(ignore_last_event: true),
).amount
end

def currency
Expand Down
81 changes: 81 additions & 0 deletions app/services/charges/charge_models/percentage_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class PercentageService < Charges::ChargeModels::BaseService
protected

def compute_amount
# NOTE: if min/max per transacton are applied, we have to compute amount on a per transaction basis.
# In the future, this logic could also be applied for the free units / amount without min/max
return compute_amount_with_transaction_min_max if should_apply_min_max?

compute_percentage_amount + compute_fixed_amount
end

Expand Down Expand Up @@ -58,6 +62,83 @@ def rate
def fixed_amount
@fixed_amount ||= BigDecimal((properties['fixed_amount'] || 0).to_s)
end

def per_transaction_max_amount?
properties['per_transaction_max_amount'].present?
end

def per_transaction_min_amount?
properties['per_transaction_min_amount'].present?
end

def per_transaction_max_amount
BigDecimal(properties['per_transaction_max_amount'])
end

def per_transaction_min_amount
BigDecimal(properties['per_transaction_min_amount'])
end

def should_apply_min_max?
return false unless License.premium?

per_transaction_max_amount? || per_transaction_min_amount?
end

def events_values
values = aggregation_result.aggregator.per_event_aggregation.event_aggregation

# NOTE: when performing aggregation for pay in advance, we have to ignore the last event
# for computing the diff between event included and excluded
# see app/services/charges/apply_pay_in_advance_charge_model_service.rb:18
values = values[0...-1] if properties[:ignore_last_event]

values
end

def compute_amount_with_transaction_min_max
remaining_free_events = free_units_per_events
remaining_free_amount = free_units_per_total_aggregation

events_values.reduce(0) do |total_amount, event_value|
value = event_value

# NOTE: apply free events
if remaining_free_events.positive? || remaining_free_amount.positive?
remaining_free_events -= 1

next 0 unless remaining_free_amount.positive?

# NOTE: apply free amount
if remaining_free_amount > value
remaining_free_amount -= value
next 0
else
value -= remaining_free_amount
remaining_free_amount = 0
remaining_free_events = 0
end
end

# NOTE: apply rate
event_amount = (value * rate).fdiv(100)

# NOTE: apply fixed amount
event_amount += fixed_amount

# NOTE: apply min and max amount per transaction
event_amount = apply_min_max(event_amount)

total_amount + event_amount
end
end

def apply_min_max(amount)
return per_transaction_min_amount if per_transaction_min_amount? && amount < per_transaction_min_amount
return per_transaction_max_amount if per_transaction_max_amount? && amount > per_transaction_max_amount

amount
end
end
end
end
72 changes: 72 additions & 0 deletions spec/scenarios/charge_models/percentage_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -319,5 +319,77 @@
end
end
end

describe 'with min and max per transaction amount' do
around { |test| lago_premium!(&test) }

it 'returns the expected customer usage' do
travel_to(DateTime.new(2023, 3, 5)) do
create_subscription(
{
external_customer_id: customer.external_id,
external_id: customer.external_id,
plan_code: plan.code,
},
)
end

create(
:percentage_charge,
plan:,
billable_metric:,
properties: {
rate: '1',
fixed_amount: '1',
per_transaction_max_amount: '12',
per_transaction_min_amount: '1.75',
},
)

travel_to(DateTime.new(2023, 3, 6)) do
create_event(
{
code: billable_metric.code,
transaction_id: SecureRandom.uuid,
external_customer_id: customer.external_id,
properties: { amount: '100' },
},
)

fetch_current_usage(customer:)
expect(json[:customer_usage][:total_amount_cents]).to eq(240)
expect(json[:customer_usage][:charges_usage][0][:units]).to eq('100.0')
expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(200)

create_event(
{
code: billable_metric.code,
transaction_id: SecureRandom.uuid,
external_customer_id: customer.external_id,
properties: { amount: '1000' },
},
)

fetch_current_usage(customer:)
expect(json[:customer_usage][:total_amount_cents]).to eq(1560)
expect(json[:customer_usage][:charges_usage][0][:units]).to eq('1100.0')
expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(1300)

create_event(
{
code: billable_metric.code,
transaction_id: SecureRandom.uuid,
external_customer_id: customer.external_id,
properties: { amount: '10000' },
},
)

fetch_current_usage(customer:)
expect(json[:customer_usage][:total_amount_cents]).to eq(3000)
expect(json[:customer_usage][:charges_usage][0][:units]).to eq('11100.0')
expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(2500)
end
end
end
end
end
117 changes: 117 additions & 0 deletions spec/scenarios/pay_in_advance_charges_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,123 @@
end
end

describe 'with min / max per transaction' do
around { |test| lago_premium!(&test) }

it 'creates a pay_in_advance fee ' do
### 24 january: Create subscription.
jan24 = DateTime.new(2023, 1, 24)

travel_to(jan24) do
create_subscription(
{
external_customer_id: customer.external_id,
external_id: customer.external_id,
plan_code: plan.code,
},
)
end

charge = create(
:percentage_charge,
:pay_in_advance,
invoiceable: false,
plan:,
billable_metric:,
properties: {
rate: '1',
fixed_amount: '0.5',
per_transaction_max_amount: '2',
per_transaction_min_amount: '1.75',
},
)

subscription = customer.subscriptions.first

### 14 february: Send an event.
travel_to(DateTime.new(2023, 2, 14)) do
expect do
create_event(
{
code: billable_metric.code,
transaction_id: SecureRandom.uuid,
external_customer_id: customer.external_id,
properties: { amount: '100' },
},
)
end.to change { subscription.reload.fees.count }.from(0).to(1)

fee = subscription.fees.order(created_at: :desc).first
expect(fee).to have_attributes(
invoice_id: nil,
charge_id: charge.id,
fee_type: 'charge',
pay_in_advance: true,
units: 100,
events_count: 1,
amount_cents: 175, # Apply minimum amount
)
end

### 15 february: Send an event.
feb15 = DateTime.new(2023, 2, 15)

travel_to(feb15) do
expect do
create_event(
{
code: billable_metric.code,
transaction_id: SecureRandom.uuid,
external_customer_id: customer.external_id,
properties: { amount: '1000' },
},
)
end.to change { subscription.reload.fees.count }.from(1).to(2)

fee = subscription.fees.order(created_at: :desc).first
expect(fee).to have_attributes(
invoice_id: nil,
charge_id: charge.id,
fee_type: 'charge',
pay_in_advance: true,
units: 1_000,
events_count: 1,
amount_cents: 200, # Apply maximum amount
)
end

### 16 february: Send an event.
feb16 = DateTime.new(2023, 2, 16)

travel_to(feb16) do
expect do
create_event(
{
code: billable_metric.code,
transaction_id: SecureRandom.uuid,
external_customer_id: customer.external_id,
properties: { amount: '10000' },
},
)
end.to change { subscription.reload.fees.count }.from(2).to(3)

fee = subscription.fees.order(created_at: :desc).first
expect(fee).to have_attributes(
invoice_id: nil,
charge_id: charge.id,
fee_type: 'charge',
pay_in_advance: true,
units: 10_000,
events_count: 1,
amount_cents: 200, # Apply maximum amount
)

fetch_current_usage(customer:)
expect(json[:customer_usage][:total_amount_cents]).to eq(575)
end
end
end

it 'creates an pay_in_advance fee' do
### 24 january: Create subscription.
jan24 = DateTime.new(2023, 1, 24)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@
result.pay_in_advance_aggregation = 1
result.count = 5
result.options = {}
result.aggregator = aggregator
end
end
let(:properties) { {} }

let(:aggregator) do
BillableMetrics::Aggregations::CountService.new(
billable_metric: charge.billable_metric,
subscription: nil,
boundaries: nil,
)
end

describe '#call' do
context 'when charge is not pay_in_advance' do
let(:charge) { create(:standard_charge) }
Expand All @@ -38,14 +47,15 @@
result.aggregation = 9
result.count = 4
result.options = {}
result.aggregator = aggregator
end

allow(charge_model_class).to receive(:apply)
.with(charge:, aggregation_result:, properties:)
.and_return(BaseService::Result.new.tap { |r| r.amount = 10 })

allow(charge_model_class).to receive(:apply)
.with(charge:, aggregation_result: previous_agg_result, properties:)
.with(charge:, aggregation_result: previous_agg_result, properties: properties.merge(ignore_last_event: true))
.and_return(BaseService::Result.new.tap { |r| r.amount = 8 })

result = charge_service.call
Expand Down
Loading

0 comments on commit 6eca543

Please sign in to comment.