From 6eca54338dbb572367d6e665825be0db8ee211d2 Mon Sep 17 00:00:00 2001 From: Vincent Pochet Date: Mon, 28 Aug 2023 10:34:37 +0200 Subject: [PATCH] feat(charge): Apply per transaction min/max in percentage charge model (#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 --- app/graphql/types/plans/object.rb | 4 + .../aggregations/base_service.rb | 2 + ...ply_pay_in_advance_charge_model_service.rb | 7 +- .../charge_models/percentage_service.rb | 81 ++++++++++++ .../charge_models/percentage_spec.rb | 72 +++++++++++ spec/scenarios/pay_in_advance_charges_spec.rb | 117 ++++++++++++++++++ ...ay_in_advance_charge_model_service_spec.rb | 12 +- .../charge_models/percentage_service_spec.rb | 81 ++++++++++++ 8 files changed, 374 insertions(+), 2 deletions(-) diff --git a/app/graphql/types/plans/object.rb b/app/graphql/types/plans/object.rb index 66934009959..6cf607d6cb4 100644 --- a/app/graphql/types/plans/object.rb +++ b/app/graphql/types/plans/object.rb @@ -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 diff --git a/app/services/billable_metrics/aggregations/base_service.rb b/app/services/billable_metrics/aggregations/base_service.rb index 9a479c1e8be..d9ad2e1c5e8 100644 --- a/app/services/billable_metrics/aggregations/base_service.rb +++ b/app/services/billable_metrics/aggregations/base_service.rb @@ -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: {}) diff --git a/app/services/charges/apply_pay_in_advance_charge_model_service.rb b/app/services/charges/apply_pay_in_advance_charge_model_service.rb index f2915371237..5c824f20c7f 100644 --- a/app/services/charges/apply_pay_in_advance_charge_model_service.rb +++ b/app/services/charges/apply_pay_in_advance_charge_model_service.rb @@ -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 diff --git a/app/services/charges/charge_models/percentage_service.rb b/app/services/charges/charge_models/percentage_service.rb index c59e1663a2e..d17a01cb248 100644 --- a/app/services/charges/charge_models/percentage_service.rb +++ b/app/services/charges/charge_models/percentage_service.rb @@ -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 @@ -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 diff --git a/spec/scenarios/charge_models/percentage_spec.rb b/spec/scenarios/charge_models/percentage_spec.rb index 3675018a2cd..4bb17b0ca76 100644 --- a/spec/scenarios/charge_models/percentage_spec.rb +++ b/spec/scenarios/charge_models/percentage_spec.rb @@ -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 diff --git a/spec/scenarios/pay_in_advance_charges_spec.rb b/spec/scenarios/pay_in_advance_charges_spec.rb index 10d24d280d6..573aed00407 100644 --- a/spec/scenarios/pay_in_advance_charges_spec.rb +++ b/spec/scenarios/pay_in_advance_charges_spec.rb @@ -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) diff --git a/spec/services/charges/apply_pay_in_advance_charge_model_service_spec.rb b/spec/services/charges/apply_pay_in_advance_charge_model_service_spec.rb index 2444a5f3aeb..9f31839a276 100644 --- a/spec/services/charges/apply_pay_in_advance_charge_model_service_spec.rb +++ b/spec/services/charges/apply_pay_in_advance_charge_model_service_spec.rb @@ -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) } @@ -38,6 +47,7 @@ result.aggregation = 9 result.count = 4 result.options = {} + result.aggregator = aggregator end allow(charge_model_class).to receive(:apply) @@ -45,7 +55,7 @@ .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 diff --git a/spec/services/charges/charge_models/percentage_service_spec.rb b/spec/services/charges/charge_models/percentage_service_spec.rb index 7b7a085f616..01473e3e895 100644 --- a/spec/services/charges/charge_models/percentage_service_spec.rb +++ b/spec/services/charges/charge_models/percentage_service_spec.rb @@ -27,6 +27,9 @@ let(:expected_percentage_amount) { (800 - 250) * (1.3 / 100) } let(:expected_fixed_amount) { (4 - 2) * 2.0 } + let(:per_transaction_max_amount) { nil } + let(:per_transaction_min_amount) { nil } + let(:rate) { '1.3' } let(:charge) do create( @@ -36,6 +39,8 @@ fixed_amount:, free_units_per_events:, free_units_per_total_aggregation:, + per_transaction_max_amount:, + per_transaction_min_amount:, }, ) end @@ -127,4 +132,80 @@ expect(apply_percentage_service.amount).to eq(0) end end + + context 'when applying min / max amount per transaction' do + let(:per_transaction_max_amount) { '12' } + let(:per_transaction_min_amount) { '1.75' } + + let(:aggregator) do + BillableMetrics::Aggregations::SumService.new(billable_metric: nil, subscription: nil, boundaries: nil) + end + + let(:aggregation) { 11_100 } + + let(:fixed_amount) { '0' } + let(:free_units_per_events) { nil } + let(:free_units_per_total_aggregation) { '0' } + let(:rate) { '1' } + + let(:per_event_aggregation) { BaseService::Result.new.tap { |r| r.event_aggregation = [100, 1_000, 10_000] } } + let(:running_total) { [] } + + before do + aggregation_result.aggregator = aggregator + aggregation_result.count = 3 + + allow(aggregator).to receive(:per_event_aggregation).and_return(per_event_aggregation) + end + + it 'does not apply max and min if not premium' do + expect(apply_percentage_service.amount).to eq(111) # (100 + 1000 + 10000) * 0.01 + end + + context 'when premium' do + around { |test| lago_premium!(&test) } + + it 'applies the min and max per transaction' do + # 1.75 (min as 100 * 0.01 < 1.75) + 10 + 12 (max as 10000 * 0.01 > 12) + expect(apply_percentage_service.amount).to eq(23.75) + end + + context 'with fixed_amount' do + let(:fixed_amount) { '1.0' } + + it 'applies the min and max per transaction' do + # 2 + 11 + 12 (max as 10001 * 0.01 > 12) + expect(apply_percentage_service.amount).to eq(25) + end + end + + context 'with free units per events' do + let(:free_units_per_events) { 2 } + + it 'applies the min and max only on paying transaction' do + # 10000 * 0.01 > 12 + expect(apply_percentage_service.amount).to eq(12) + end + end + + context 'with free units per total aggregation' do + let(:free_units_per_total_aggregation) { '300' } + + it 'takes the free amount into account' do + # (100 + 1000 - 300) * 0.01 + 12 (max as 10000 * 0.01 > 12) + expect(apply_percentage_service.amount).to eq(20) + end + end + + context 'when both free units per events and per total aggregation are applied' do + let(:free_units_per_events) { 3 } + let(:free_units_per_total_aggregation) { '10000' } + + it 'takes the free amounts into account' do + # (100 + 1000 + 10000 - 10000) * 0.01 (max as 10000 * 0.01 > 12) + expect(apply_percentage_service.amount).to eq(11) + end + end + end + end end