From 8d687044ce10644c8989927a5fdd039d25529f02 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Tue, 24 Dec 2024 15:18:07 +0100 Subject: [PATCH 01/13] refactor Events::ValidateCreationService --- app/controllers/api/v1/events_controller.rb | 20 +++++ .../events/validate_creation_service.rb | 58 +++++-------- .../fees/estimate_pay_in_advance_service.rb | 18 ++-- db/schema.rb | 9 ++ .../events/validate_creation_service_spec.rb | 82 +++++++++---------- 5 files changed, 97 insertions(+), 90 deletions(-) diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb index 379ce75653e..58b44df701b 100644 --- a/app/controllers/api/v1/events_controller.rb +++ b/app/controllers/api/v1/events_controller.rb @@ -85,6 +85,26 @@ def index end end + def estimate_instant_fees + result = Fees::EstimateInstantPayInAdvanceService.call( + organization: current_organization, + params: create_params + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.fees, + ::V1::FeesSerializer, + collection_name: 'fees', + includes: %i[applied_taxes] + ) + ) + else + render_error_response(result) + end + end + def estimate_fees result = Fees::EstimatePayInAdvanceService.call( organization: current_organization, diff --git a/app/services/events/validate_creation_service.rb b/app/services/events/validate_creation_service.rb index 46578d639cb..132492a08f6 100644 --- a/app/services/events/validate_creation_service.rb +++ b/app/services/events/validate_creation_service.rb @@ -1,39 +1,21 @@ # frozen_string_literal: true module Events - class ValidateCreationService - def self.call(...) - new(...).call - end - - def initialize(organization:, params:, result:, customer:, subscriptions: []) + class ValidateCreationService < BaseService + def initialize(organization:, event_params:, customer:, subscriptions: []) @organization = organization - @params = params - @result = result + @event_params = event_params @customer = customer @subscriptions = subscriptions - end - def call - LagoTracer.in_span("Events::ValidateCreationService#call") do - validate_create - end + super end - private - - attr_reader :organization, :params, :result, :customer, :subscriptions - - def validate_create - return invalid_customer_error if params[:external_customer_id] && !customer - - if params[:external_subscription_id].blank? && subscriptions.count(&:active?) > 1 - return missing_subscription_error - end + def call + return missing_subscription_error if event_params[:external_subscription_id].blank? return missing_subscription_error if subscriptions.empty? - if params[:external_subscription_id] && - subscriptions.pluck(:external_id).exclude?(params[:external_subscription_id]) + if subscriptions.pluck(:external_id).exclude?(event_params[:external_subscription_id]) return missing_subscription_error end @@ -42,24 +24,28 @@ def validate_create return invalid_code_error unless valid_code? return invalid_properties_error unless valid_properties? - nil + result end + private + + attr_reader :organization, :event_params, :customer, :subscriptions + def valid_timestamp? - return true if params[:timestamp].blank? + return true if event_params[:timestamp].blank? # timestamp is a number of seconds - valid_number?(params[:timestamp]) + valid_number?(event_params[:timestamp]) end def valid_transaction_id? - return false if params[:transaction_id].blank? + return false if event_params[:transaction_id].blank? - Event.where( + !Event.where( organization_id: organization.id, - transaction_id: params[:transaction_id], + transaction_id: event_params[:transaction_id], external_subscription_id: subscriptions.first.external_id - ).none? + ).exists? end def valid_code? @@ -71,7 +57,7 @@ def valid_code? def valid_properties? return true unless billable_metric.max_agg? || billable_metric.sum_agg? || billable_metric.latest_agg? - valid_number?((params[:properties] || {})[billable_metric.field_name.to_sym]) + valid_number?((event_params[:properties] || {})[billable_metric.field_name.to_sym]) end def valid_number?(value) @@ -96,16 +82,12 @@ def invalid_properties_error result.validation_failure!(errors: {properties: ['value_is_not_valid_number']}) end - def invalid_customer_error - result.not_found_failure!(resource: 'customer') - end - def invalid_timestamp_error result.validation_failure!(errors: {timestamp: ['invalid_format']}) end def billable_metric - @billable_metric ||= organization.billable_metrics.find_by(code: params[:code]) + @billable_metric ||= organization.billable_metrics.find_by(code: event_params[:code]) end end end diff --git a/app/services/fees/estimate_pay_in_advance_service.rb b/app/services/fees/estimate_pay_in_advance_service.rb index 8dccb663396..4813c3f214d 100644 --- a/app/services/fees/estimate_pay_in_advance_service.rb +++ b/app/services/fees/estimate_pay_in_advance_service.rb @@ -5,14 +5,14 @@ class EstimatePayInAdvanceService < BaseService def initialize(organization:, params:) @organization = organization # NOTE: validation is shared with event creation and is expecting a transaction_id - @params = params.merge(transaction_id: SecureRandom.uuid) + @event_params = params.merge(transaction_id: SecureRandom.uuid) super end def call - Events::ValidateCreationService.call(organization:, params:, customer:, subscriptions:, result:) - return result unless result.success? + validation_result = Events::ValidateCreationService.call(organization:, event_params:, customer:, subscriptions:) + return validation_result unless validation_result.success? if charges.none? return result.single_validation_failure!(field: :code, error_code: 'does_not_match_an_instant_charge') @@ -38,17 +38,17 @@ def call private - attr_reader :organization, :params + attr_reader :organization, :event_params def event return @event if @event @event = Event.new( organization_id: organization.id, - code: params[:code], + code: event_params[:code], external_customer_id: customer&.external_id, external_subscription_id: subscriptions.first&.external_id, - properties: params[:properties] || {}, + properties: event_params[:properties] || {}, transaction_id: SecureRandom.uuid, timestamp: Time.current ) @@ -58,9 +58,9 @@ def customer return @customer if @customer @customer = if params[:external_subscription_id] - organization.subscriptions.find_by(external_id: params[:external_subscription_id])&.customer + organization.subscriptions.find_by(external_id: event_params[:external_subscription_id])&.customer else - Customer.find_by(external_id: params[:external_customer_id], organization_id: organization.id) + Customer.find_by(external_id: event_params[:external_customer_id], organization_id: organization.id) end end @@ -68,7 +68,7 @@ def subscriptions return @subscriptions if defined? @subscriptions timestamp = Time.current - subscriptions = if customer && params[:external_subscription_id].blank? + subscriptions = if customer && event_params[:external_subscription_id].blank? customer.subscriptions else organization.subscriptions.where(external_id: params[:external_subscription_id]) diff --git a/db/schema.rb b/db/schema.rb index ba2d5b10cf5..9f6d4aa3b03 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1225,6 +1225,15 @@ t.index ["payment_provider_id"], name: "index_refunds_on_payment_provider_id" end + create_table "subscription_event_triggers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "organization_id", null: false + t.string "external_subscription_id", null: false + t.datetime "start_processing_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.index ["external_subscription_id", "organization_id"], name: "idx_on_external_subscription_id_organization_id_40aa74e2eb", unique: true, where: "(start_processing_at IS NULL)" + t.index ["start_processing_at", "external_subscription_id", "organization_id"], name: "idx_on_start_processing_at_external_subscription_id_31b81116ce", unique: true + end + create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "customer_id", null: false t.uuid "plan_id", null: false diff --git a/spec/services/events/validate_creation_service_spec.rb b/spec/services/events/validate_creation_service_spec.rb index 58685b453e1..9733cd410f9 100644 --- a/spec/services/events/validate_creation_service_spec.rb +++ b/spec/services/events/validate_creation_service_spec.rb @@ -6,38 +6,36 @@ subject(:validate_event) do described_class.call( organization:, - params:, - result:, + event_params:, customer:, subscriptions: [subscription] ) end let(:organization) { create(:organization) } - let(:result) { BaseService::Result.new } let(:customer) { create(:customer, organization:) } let!(:subscription) { create(:subscription, customer:, organization:) } let(:billable_metric) { create(:billable_metric, organization:) } let(:transaction_id) { SecureRandom.uuid } - let(:params) do - {external_customer_id: customer.external_id, code: billable_metric.code, transaction_id:} + let(:event_params) do + {external_subscription_id: subscription.external_id, code: billable_metric.code, transaction_id:} end describe '.call' do context 'when customer has only one active subscription and external_subscription_id is not given' do it 'does not return any validation errors' do - expect(validate_event).to be_nil + result = validate_event expect(result).to be_success end end context 'when customer has only one active subscription and customer is not given' do - let(:params) do + let(:event_params) do {code: billable_metric.code, external_subscription_id: subscription.external_id, transaction_id:} end it 'does not return any validation errors' do - expect(validate_event).to be_nil + result = validate_event expect(result).to be_success end end @@ -45,52 +43,51 @@ context 'when customer has two active subscriptions' do before { create(:subscription, customer:, organization:) } - let(:params) do + let(:event_params) do {code: billable_metric.code, external_subscription_id: subscription.external_id, transaction_id:} end it 'does not return any validation errors' do - expect(validate_event).to be_nil + result = validate_event expect(result).to be_success end end context 'when customer is not given but subscription is present' do - let(:params) do - {code: billable_metric.code, transaction_id:} + let(:event_params) do + {code: billable_metric.code, external_subscription_id: subscription.external_id, transaction_id:} end let(:validate_event) do described_class.call( organization:, - params:, - result:, + event_params:, customer: nil, subscriptions: [subscription] ) end it 'does not return any validation errors' do - expect(validate_event).to be_nil + result = validate_event expect(result).to be_success end end context 'when there are two active subscriptions but external_subscription_id is not given' do let(:subscription2) { create(:subscription, customer:, organization:) } + let(:event_params) { {code: billable_metric.code, transaction_id:} } let(:validate_event) do described_class.call( organization:, - params:, - result:, + event_params:, customer:, subscriptions: [subscription, subscription2] ) end it 'returns a subscription_not_found error' do - validate_event + result = validate_event aggregate_failures do expect(result).not_to be_success @@ -101,7 +98,7 @@ end context 'when there are two active subscriptions but external_subscription_id is invalid' do - let(:params) do + let(:event_params) do { code: billable_metric.code, external_subscription_id: SecureRandom.uuid, @@ -115,15 +112,14 @@ let(:validate_event) do described_class.call( organization:, - params:, - result:, + event_params:, customer:, subscriptions: [subscription, subscription2] ) end it 'returns a not found error' do - validate_event + result = validate_event aggregate_failures do expect(result).not_to be_success @@ -138,7 +134,7 @@ create(:subscription, customer:, organization:, external_id:, status: :terminated) end let(:external_id) { SecureRandom.uuid } - let(:params) do + let(:event_params) do { code: billable_metric.code, external_subscription_id: external_id, @@ -153,7 +149,7 @@ end it 'does not return any validation errors' do - expect(validate_event).to be_nil + result = validate_event expect(result).to be_success end end @@ -170,7 +166,7 @@ end it 'returns a validation error' do - validate_event + result = validate_event expect(result).not_to be_success expect(result.error).to be_a(BaseService::ValidationFailure) @@ -180,12 +176,12 @@ end context 'when code does not exist' do - let(:params) do - {external_customer_id: customer.external_id, code: 'event_code', transaction_id:} + let(:event_params) do + {external_subscription_id: subscription.external_id, code: 'event_code', transaction_id:} end it 'returns an event_not_found error' do - validate_event + result = validate_event aggregate_failures do expect(result).not_to be_success @@ -197,10 +193,10 @@ context 'when field_name value is not a number' do let(:billable_metric) { create(:sum_billable_metric, organization:) } - let(:params) do + let(:event_params) do { code: billable_metric.code, - external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, properties: { item_id: 'test' }, @@ -209,7 +205,7 @@ end it 'returns an value_is_not_valid_number error' do - validate_event + result = validate_event aggregate_failures do expect(result).not_to be_success @@ -220,10 +216,10 @@ end context 'when field_name cannot be found' do - let(:params) do + let(:event_params) do { code: billable_metric.code, - external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, properties: { invalid_key: 'test' }, @@ -232,23 +228,23 @@ end it 'does not raise error' do - validate_event + result = validate_event expect(result).to be_success end end context 'when properties are missing' do - let(:params) do + let(:event_params) do { code: billable_metric.code, - external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, transaction_id: } end it 'does not raise error' do - validate_event + result = validate_event expect(result).to be_success end @@ -256,12 +252,12 @@ end context 'when timestamp is in a wrong format' do - let(:params) do - {external_customer_id: customer.external_id, code: billable_metric.code, transaction_id:, timestamp: '2025-01-01'} + let(:event_params) do + {external_subscription_id: subscription.external_id, code: billable_metric.code, transaction_id:, timestamp: '2025-01-01'} end it 'returns a timestamp_is_not_valid error' do - validate_event + result = validate_event expect(result).not_to be_success expect(result.error).to be_a(BaseService::ValidationFailure) @@ -271,12 +267,12 @@ end context 'when timestamp is valid' do - let(:params) do - {external_customer_id: customer.external_id, code: billable_metric.code, transaction_id:, timestamp: Time.current.to_i + 0.11} + let(:event_params) do + {external_subscription_id: subscription.external_id, code: billable_metric.code, transaction_id:, timestamp: Time.current.to_i + 0.11} end it 'does not raise any errors' do - expect(validate_event).to be_nil + result = validate_event expect(result).to be_success end end From 5071b1acb16e978ea85085dc907b57d71131e511 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Tue, 24 Dec 2024 15:18:15 +0100 Subject: [PATCH 02/13] Add service for instant fees --- app/controllers/api/v1/events_controller.rb | 3 +- .../estimate_instant/percentage_service.rb | 51 +++++++ ...estimate_instant_pay_in_advance_service.rb | 135 ++++++++++++++++++ .../fees/estimate_pay_in_advance_service.rb | 4 +- db/schema.rb | 9 -- .../percentage_service_spec.rb | 110 ++++++++++++++ ...ate_instant_pay_in_advance_service_spec.rb | 124 ++++++++++++++++ .../estimate_pay_in_advance_service_spec.rb | 21 +-- 8 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 app/services/charges/estimate_instant/percentage_service.rb create mode 100644 app/services/fees/estimate_instant_pay_in_advance_service.rb create mode 100644 spec/services/charges/estimate_instant/percentage_service_spec.rb create mode 100644 spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb index 58b44df701b..14269237766 100644 --- a/app/controllers/api/v1/events_controller.rb +++ b/app/controllers/api/v1/events_controller.rb @@ -96,8 +96,7 @@ def estimate_instant_fees json: ::CollectionSerializer.new( result.fees, ::V1::FeesSerializer, - collection_name: 'fees', - includes: %i[applied_taxes] + collection_name: 'fees' ) ) else diff --git a/app/services/charges/estimate_instant/percentage_service.rb b/app/services/charges/estimate_instant/percentage_service.rb new file mode 100644 index 00000000000..63ad9adf193 --- /dev/null +++ b/app/services/charges/estimate_instant/percentage_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Charges + module EstimateInstant + class PercentageService < BaseService + def initialize(properties:, units:) + @properties = properties + @units = units + super + end + + def call + result.units = units + if units.negative? + result.units = 0 + result.amount = 0 + return result + end + + amount = units * rate.fdiv(100) + amount += fixed_amount + amount = amount.clamp(per_transaction_min_amount, per_transaction_max_amount) + + result.amount = amount + result + end + + private + + attr_reader :properties, :units + + def rate + BigDecimal(properties['rate'].to_s) + end + + def per_transaction_max_amount + return nil if properties['per_transaction_max_amount'].blank? + BigDecimal(properties['per_transaction_max_amount']) + end + + def fixed_amount + BigDecimal((properties['fixed_amount'] || 0).to_s) + end + + def per_transaction_min_amount + return nil if properties['per_transaction_min_amount'].blank? + BigDecimal(properties['per_transaction_min_amount']) + end + end + end +end diff --git a/app/services/fees/estimate_instant_pay_in_advance_service.rb b/app/services/fees/estimate_instant_pay_in_advance_service.rb new file mode 100644 index 00000000000..f43faab58fa --- /dev/null +++ b/app/services/fees/estimate_instant_pay_in_advance_service.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Fees + class EstimateInstantPayInAdvanceService < BaseService + def initialize(organization:, params:) + @organization = organization + # NOTE: validation is shared with event creation and is expecting a transaction_id + @event_params = params.merge(transaction_id: SecureRandom.uuid) + @billing_at = event.timestamp + + super + end + + def call + validation_result = Events::ValidateCreationService.call(organization:, event_params:, customer:, subscriptions:) + return validation_result unless validation_result.success? + + if charges.none? + return result.single_validation_failure!(field: :code, error_code: 'does_not_match_an_instant_charge') + end + + fees = charges.map { |charge| estimate_charge_fees(charge) } + + result.fees = fees + result + end + + private + + attr_reader :event_params, :organization, :billing_at + delegate :subscription, to: :event + delegate :customer, to: :subscription, allow_nil: true + + def estimate_charge_fees(charge) + charge_filter = ChargeFilters::EventMatchingService.call(charge:, event:).charge_filter + properties = charge_filter&.properties || charge.properties + + units = event.properties[charge.billable_metric.field_name] || 0 + estimate_result = Charges::EstimateInstant::PercentageService.call!(properties:, units:) + + amount = estimate_result.amount + # NOTE: amount_result should be a BigDecimal, we need to round it + # to the currency decimals and transform it into currency cents + rounded_amount = amount.round(currency.exponent) + amount_cents = rounded_amount * currency.subunit_to_unit + unit_amount = rounded_amount.zero? ? BigDecimal("0") : rounded_amount / units + unit_amount_cents = unit_amount * currency.subunit_to_unit + + Fee.new( + subscription:, + charge:, + organization:, + amount_cents:, + precise_amount_cents: amount * currency.subunit_to_unit.to_d, + amount_currency: subscription.plan.amount_currency, + fee_type: :charge, + invoiceable: charge, + units: estimate_result.units, + total_aggregated_units: estimate_result.units, + properties: boundaries, + events_count: 1, + charge_filter_id: charge_filter&.id, + pay_in_advance_event_id: nil, + pay_in_advance_event_transaction_id: nil, + payment_status: :pending, + pay_in_advance: true, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + unit_amount_cents:, + precise_unit_amount: unit_amount, + grouped_by: {}, + amount_details: {} + ) + end + + def boundaries + @boundaries ||= { + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + charges_duration: date_service.charges_duration_in_days, + timestamp: billing_at + } + end + + def event + return @event if @event + + @event = Event.new( + organization_id: organization.id, + code: event_params[:code], + external_subscription_id: event_params[:external_subscription_id], + properties: event_params[:properties] || {}, + transaction_id: SecureRandom.uuid, + timestamp: Time.current + ) + end + + def date_service + @date_service ||= Subscriptions::DatesService.new_instance( + subscription, + billing_at, + current_usage: true + ) + end + + def subscriptions + return @subscriptions if defined? @subscriptions + + subscriptions = organization.subscriptions.where(external_id: event.external_subscription_id) + return unless subscriptions + + timestamp = event.timestamp + @subscriptions = subscriptions + .where("date_trunc('second', started_at::timestamp) <= ?", timestamp) + .where("terminated_at IS NULL OR date_trunc('second', terminated_at::timestamp) >= ?", timestamp) + .order('terminated_at DESC NULLS FIRST, started_at DESC') + end + + def charges + @charges ||= subscriptions.first + .plan + .charges + .percentage + .pay_in_advance + .joins(:billable_metric) + .where(billable_metric: {code: event.code}) + end + + def currency + subscription.plan.amount.currency + end + end +end diff --git a/app/services/fees/estimate_pay_in_advance_service.rb b/app/services/fees/estimate_pay_in_advance_service.rb index 4813c3f214d..dd3f00ead61 100644 --- a/app/services/fees/estimate_pay_in_advance_service.rb +++ b/app/services/fees/estimate_pay_in_advance_service.rb @@ -57,7 +57,7 @@ def event def customer return @customer if @customer - @customer = if params[:external_subscription_id] + @customer = if event_params[:external_subscription_id] organization.subscriptions.find_by(external_id: event_params[:external_subscription_id])&.customer else Customer.find_by(external_id: event_params[:external_customer_id], organization_id: organization.id) @@ -71,7 +71,7 @@ def subscriptions subscriptions = if customer && event_params[:external_subscription_id].blank? customer.subscriptions else - organization.subscriptions.where(external_id: params[:external_subscription_id]) + organization.subscriptions.where(external_id: event_params[:external_subscription_id]) end return unless subscriptions diff --git a/db/schema.rb b/db/schema.rb index 9f6d4aa3b03..ba2d5b10cf5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1225,15 +1225,6 @@ t.index ["payment_provider_id"], name: "index_refunds_on_payment_provider_id" end - create_table "subscription_event_triggers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "organization_id", null: false - t.string "external_subscription_id", null: false - t.datetime "start_processing_at", precision: nil - t.datetime "created_at", precision: nil, null: false - t.index ["external_subscription_id", "organization_id"], name: "idx_on_external_subscription_id_organization_id_40aa74e2eb", unique: true, where: "(start_processing_at IS NULL)" - t.index ["start_processing_at", "external_subscription_id", "organization_id"], name: "idx_on_start_processing_at_external_subscription_id_31b81116ce", unique: true - end - create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "customer_id", null: false t.uuid "plan_id", null: false diff --git a/spec/services/charges/estimate_instant/percentage_service_spec.rb b/spec/services/charges/estimate_instant/percentage_service_spec.rb new file mode 100644 index 00000000000..88c80bc37d2 --- /dev/null +++ b/spec/services/charges/estimate_instant/percentage_service_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::EstimateInstant::PercentageService, type: :service do + subject { described_class.new(properties:, units:) } + + let(:properties) do + { + "rate" => rate, + "fixed_amount" => fixed_amount, + "per_transaction_max_amount" => per_transaction_max_amount, + "per_transaction_min_amount" => per_transaction_min_amount + } + end + let(:units) { 0 } + let(:rate) { 0 } + let(:fixed_amount) { nil } + let(:per_transaction_max_amount) { nil } + let(:per_transaction_min_amount) { nil } + + describe "call" do + it "returns zero amounts" do + result = subject.call + expect(result.amount).to be_zero + expect(result.units).to be_zero + end + + context "when units is negative" do + let(:units) { -1 } + + it "returns zero amounts" do + result = subject.call + expect(result.amount).to be_zero + expect(result.units).to be_zero + end + end + + context "when units and rate are positive" do + let(:units) { 20 } + let(:rate) { 20 } + + it "returns the percentage amount" do + result = subject.call + expect(result.amount).to eq(4) + expect(result.units).to eq(20) + end + + context "when fixed_amount is configured" do + let(:fixed_amount) { 10 } + + it "includes the fixed amount" do + result = subject.call + expect(result.amount).to eq(14) + expect(result.units).to eq(20) + end + end + + context "when a maximum is set" do + let(:per_transaction_max_amount) { 3 } + + it "returns the percentage amount capped at the max" do + result = subject.call + expect(result.amount).to eq(3) + expect(result.units).to eq(20) + end + end + + context "when a minimum is set" do + let(:per_transaction_min_amount) { 5 } + + it "returns the percentage amount and at least the min" do + result = subject.call + expect(result.amount).to eq(5) + expect(result.units).to eq(20) + end + end + end + end + + context "with all combinations of testcases" do + let(:test_cases) do + # array consisting of units, rate, fixed_amount, max, min, expected_amount + [ + [100, 2, nil, nil, nil, 2], + [100, 0, nil, nil, nil, 0], + [100, 0, 12, nil, nil, 12], + [100, 0, 2, 15, 0, 2], + [100, 15, 3, 2, 1, 2], + [100, 15, 0, nil, 16, 16], + [0, 12, 2, nil, nil, 2], + [0, 12, 2, nil, 13, 13] + ] + end + + it "validates all testcases" do + test_cases.each do |arr| + expected_amount = arr.pop + units, rate, fixed_amount, per_transaction_max_amount, per_transaction_min_amount = *arr + properties = { + "rate" => rate, + "fixed_amount" => fixed_amount, + "per_transaction_max_amount" => per_transaction_max_amount, + "per_transaction_min_amount" => per_transaction_min_amount + } + expect(described_class.call(properties:, units:).amount).to eq(expected_amount) + end + end + end +end diff --git a/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb b/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb new file mode 100644 index 00000000000..7d49dce6e18 --- /dev/null +++ b/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fees::EstimateInstantPayInAdvanceService do + subject { described_class.new(organization:, params:) } + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric:, properties: {rate: '0.1', fixed_amount: '0'}) } + + let(:customer) { create(:customer, organization:) } + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + started_at: 1.year.ago + ) + end + + let(:params) do + { + organization_id:, + code:, + external_customer_id:, + external_subscription_id:, + timestamp:, + properties: + } + end + + let(:properties) { nil } + + let(:code) { billable_metric&.code } + let(:external_customer_id) { customer&.external_id } + let(:external_subscription_id) { subscription&.external_id } + let(:organization_id) { organization.id } + let(:timestamp) { Time.current.to_i.to_s } + let(:currency) { subscription.plan.amount.currency } + + before { charge } + + describe '#call' do + it 'returns a list of fees' do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee).not_to be_persisted + expect(fee).to have_attributes( + subscription:, + charge:, + fee_type: 'charge', + pay_in_advance: true, + invoiceable: charge, + events_count: 1, + pay_in_advance_event_id: nil, + pay_in_advance_event_transaction_id: nil + ) + end + + context 'when setting event properties' do + let(:properties) { {billable_metric.field_name => 500} } + + it 'calculates the fee correctly' do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee.amount_cents).to eq(50) + end + end + + context 'when event code does not match an pay_in_advance charge' do + let(:charge) { create(:percentage_charge, plan:, billable_metric:) } + + it 'fails with a validation error' do + result = subject.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(['does_not_match_an_instant_charge']) + end + end + end + + context 'when event matches multiple charges' do + let(:charge2) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric:) } + + before { charge2 } + + it 'returns a fee per charges' do + result = subject.call + + aggregate_failures do + expect(result).to be_success + expect(result.fees.count).to eq(2) + end + end + end + + context 'when external subscription is not found' do + let(:external_subscription_id) { nil } + + it 'fails with a not found error' do + result = subject.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq('subscription_not_found') + end + end + end + end +end diff --git a/spec/services/fees/estimate_pay_in_advance_service_spec.rb b/spec/services/fees/estimate_pay_in_advance_service_spec.rb index 910fedabce7..97128dacbc9 100644 --- a/spec/services/fees/estimate_pay_in_advance_service_spec.rb +++ b/spec/services/fees/estimate_pay_in_advance_service_spec.rb @@ -102,7 +102,7 @@ aggregate_failures do expect(result).not_to be_success expect(result.error).to be_a(BaseService::NotFoundFailure) - expect(result.error.error_code).to eq('customer_not_found') + expect(result.error.error_code).to eq('subscription_not_found') end end end @@ -132,25 +132,12 @@ before { subscription } - it 'returns a list of fees' do + it 'returns nothing' do result = estimate_service.call aggregate_failures do - expect(result).to be_success - expect(result.fees.count).to eq(1) - - fee = result.fees.first - expect(fee).not_to be_persisted - expect(fee).to have_attributes( - subscription:, - charge:, - fee_type: 'charge', - pay_in_advance: true, - invoiceable: charge, - events_count: 1, - pay_in_advance_event_id: nil, - pay_in_advance_event_transaction_id: String - ) + expect(result).not_to be_success + expect(result.fees).to be_nil end end end From 3efdf96e6a4f6eb3e102dc41bec0a85e6c5c61ae Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Tue, 21 Jan 2025 13:57:16 +0100 Subject: [PATCH 03/13] finalize events controller action --- app/controllers/api/v1/events_controller.rb | 2 +- config/routes.rb | 1 + .../requests/api/v1/events_controller_spec.rb | 82 +++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb index 14269237766..a6a87a81abc 100644 --- a/app/controllers/api/v1/events_controller.rb +++ b/app/controllers/api/v1/events_controller.rb @@ -95,7 +95,7 @@ def estimate_instant_fees render( json: ::CollectionSerializer.new( result.fees, - ::V1::FeesSerializer, + ::V1::FeeSerializer, collection_name: 'fees' ) ) diff --git a/config/routes.rb b/config/routes.rb index 31452f1c346..cabf990fb04 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,6 +51,7 @@ end resources :events, only: %i[create show index] do post :estimate_fees, on: :collection + post :estimate_instant_fees, on: :collection end resources :applied_coupons, only: %i[create index] resources :fees, only: %i[show update index destroy] diff --git a/spec/requests/api/v1/events_controller_spec.rb b/spec/requests/api/v1/events_controller_spec.rb index e602b3447c1..d37d4a6f10d 100644 --- a/spec/requests/api/v1/events_controller_spec.rb +++ b/spec/requests/api/v1/events_controller_spec.rb @@ -384,4 +384,86 @@ end end end + + describe 'POST /api/v1/events/estimate_instant_fees' do + subject do + post_with_token(organization, '/api/v1/events/estimate_instant_fees', event: event_params) + end + + let(:metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric: metric, properties: {rate: '0.1', fixed_amount: '0'}) } + + let(:event_params) do + { + code: metric.code, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: SecureRandom.uuid, + properties: { + metric.field_name => 400 + } + } + end + + before do + charge + end + + include_examples 'requires API permission', 'event', 'write' + + it 'returns a success' do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fees].count).to eq(1) + + fee = json[:fees].first + expect(fee[:lago_id]).to be_nil + expect(fee[:lago_group_id]).to be_nil + expect(fee[:item][:type]).to eq('charge') + expect(fee[:item][:code]).to eq(metric.code) + expect(fee[:item][:name]).to eq(metric.name) + expect(fee[:amount_cents]).to be_an(Integer) + expect(fee[:amount_currency]).to eq('EUR') + expect(fee[:units]).to eq('400.0') + expect(fee[:events_count]).to eq(1) + end + + context 'with missing subscription id' do + let(:event_params) do + { + code: metric.code, + external_subscription_id: nil, + properties: { + foo: 'bar' + } + } + end + + it 'returns a not found error' do + subject + expect(response).to have_http_status(:not_found) + end + end + + context 'when metric code does not match an percentage charge' do + let(:charge) { create(:standard_charge, plan:, billable_metric: metric) } + + let(:event_params) do + { + code: metric.code, + external_subscription_id: subscription.external_id, + properties: { + foo: 'bar' + } + } + end + + it 'returns a validation error' do + subject + expect(response).to have_http_status(:unprocessable_entity) + end + end + end end From 4435e484ac1533d247f2e911e1ef7982532e6b76 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Wed, 22 Jan 2025 10:16:48 +0100 Subject: [PATCH 04/13] apply rounding to billable metric result --- app/services/fees/estimate_instant_pay_in_advance_service.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/services/fees/estimate_instant_pay_in_advance_service.rb b/app/services/fees/estimate_instant_pay_in_advance_service.rb index f43faab58fa..20038367c56 100644 --- a/app/services/fees/estimate_instant_pay_in_advance_service.rb +++ b/app/services/fees/estimate_instant_pay_in_advance_service.rb @@ -35,7 +35,10 @@ def estimate_charge_fees(charge) charge_filter = ChargeFilters::EventMatchingService.call(charge:, event:).charge_filter properties = charge_filter&.properties || charge.properties - units = event.properties[charge.billable_metric.field_name] || 0 + # fetch value and apply rounding + units = BigDecimal(event.properties[charge.billable_metric.field_name] || 0) + units = BillableMetrics::Aggregations::ApplyRoundingService.call!(billable_metric: charge.billable_metric, units:).units + estimate_result = Charges::EstimateInstant::PercentageService.call!(properties:, units:) amount = estimate_result.amount From 4a06e22c079ddfe2548718e3d5f240065b91532b Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Wed, 22 Jan 2025 10:32:36 +0100 Subject: [PATCH 05/13] speed up endpoint by creating payload directly --- app/controllers/api/v1/events_controller.rb | 6 +- ...estimate_instant_pay_in_advance_service.rb | 78 ++++++++++++------- .../requests/api/v1/events_controller_spec.rb | 2 +- ...ate_instant_pay_in_advance_service_spec.rb | 17 ++-- 4 files changed, 62 insertions(+), 41 deletions(-) diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb index a6a87a81abc..4d80b91aae9 100644 --- a/app/controllers/api/v1/events_controller.rb +++ b/app/controllers/api/v1/events_controller.rb @@ -93,11 +93,7 @@ def estimate_instant_fees if result.success? render( - json: ::CollectionSerializer.new( - result.fees, - ::V1::FeeSerializer, - collection_name: 'fees' - ) + json: {fees: result.fees} ) else render_error_response(result) diff --git a/app/services/fees/estimate_instant_pay_in_advance_service.rb b/app/services/fees/estimate_instant_pay_in_advance_service.rb index 20038367c56..746b03c2451 100644 --- a/app/services/fees/estimate_instant_pay_in_advance_service.rb +++ b/app/services/fees/estimate_instant_pay_in_advance_service.rb @@ -5,7 +5,7 @@ class EstimateInstantPayInAdvanceService < BaseService def initialize(organization:, params:) @organization = organization # NOTE: validation is shared with event creation and is expecting a transaction_id - @event_params = params.merge(transaction_id: SecureRandom.uuid) + @event_params = params @billing_at = event.timestamp super @@ -36,8 +36,9 @@ def estimate_charge_fees(charge) properties = charge_filter&.properties || charge.properties # fetch value and apply rounding + billable_metric = charge.billable_metric units = BigDecimal(event.properties[charge.billable_metric.field_name] || 0) - units = BillableMetrics::Aggregations::ApplyRoundingService.call!(billable_metric: charge.billable_metric, units:).units + units = BillableMetrics::Aggregations::ApplyRoundingService.call!(billable_metric:, units:).units estimate_result = Charges::EstimateInstant::PercentageService.call!(properties:, units:) @@ -49,31 +50,56 @@ def estimate_charge_fees(charge) unit_amount = rounded_amount.zero? ? BigDecimal("0") : rounded_amount / units unit_amount_cents = unit_amount * currency.subunit_to_unit - Fee.new( - subscription:, - charge:, - organization:, - amount_cents:, - precise_amount_cents: amount * currency.subunit_to_unit.to_d, - amount_currency: subscription.plan.amount_currency, - fee_type: :charge, - invoiceable: charge, - units: estimate_result.units, - total_aggregated_units: estimate_result.units, - properties: boundaries, - events_count: 1, - charge_filter_id: charge_filter&.id, - pay_in_advance_event_id: nil, - pay_in_advance_event_transaction_id: nil, - payment_status: :pending, + # construct payload directly + { + lago_id: nil, + lago_charge_id: charge.id, + lago_charge_filter_id: charge_filter&.id, + lago_invoice_id: nil, + lago_true_up_fee_id: nil, + lago_true_up_parent_fee_id: nil, + lago_subscription_id: subscription.id, + external_subscription_id: subscription.external_id, + lago_customer_id: customer.id, + external_customer_id: customer.external_id, + item: { + type: 'charge', + code: billable_metric.code, + name: billable_metric.name, + description: billable_metric.description, + invoice_display_name: charge.invoice_display_name.presence || billable_metric.name, + filters: charge_filter&.to_h, + filter_invoice_display_name: charge_filter&.display_name, + lago_item_id: billable_metric.id, + item_type: BillableMetric.name, + grouped_by: {} + }, pay_in_advance: true, + invoiceable: charge.invoiceable, + amount_cents:, + amount_currency: currency.iso_code, + precise_amount: amount, + precise_total_amount: amount, taxes_amount_cents: 0, - taxes_precise_amount_cents: 0.to_d, - unit_amount_cents:, - precise_unit_amount: unit_amount, - grouped_by: {}, - amount_details: {} - ) + taxes_precise_amount: 0, + taxes_rate: 0, + total_amount_cents: amount_cents, + total_amount_currency: currency.iso_code, + units: units, + description: nil, + precise_unit_amount: unit_amount_cents, + precise_coupons_amount_cents: "0.0", + events_count: 1, + payment_status: "pending", + created_at: nil, + succeeded_at: nil, + failed_at: nil, + refunded_at: nil, + amount_details: nil, + from_date: boundaries[:charges_from_datetime]&.to_datetime&.iso8601, + to_date: boundaries[:charges_to_datetime]&.to_datetime&.end_of_day&.iso8601, + event_transaction_id: event.transaction_id + } end def boundaries @@ -95,7 +121,7 @@ def event code: event_params[:code], external_subscription_id: event_params[:external_subscription_id], properties: event_params[:properties] || {}, - transaction_id: SecureRandom.uuid, + transaction_id: event_params[:transaction_id] || SecureRandom.uuid, timestamp: Time.current ) end diff --git a/spec/requests/api/v1/events_controller_spec.rb b/spec/requests/api/v1/events_controller_spec.rb index d37d4a6f10d..833ca7ec3e1 100644 --- a/spec/requests/api/v1/events_controller_spec.rb +++ b/spec/requests/api/v1/events_controller_spec.rb @@ -424,7 +424,7 @@ expect(fee[:item][:type]).to eq('charge') expect(fee[:item][:code]).to eq(metric.code) expect(fee[:item][:name]).to eq(metric.name) - expect(fee[:amount_cents]).to be_an(Integer) + expect(fee[:amount_cents]).to eq('40.0') expect(fee[:amount_currency]).to eq('EUR') expect(fee[:units]).to eq('400.0') expect(fee[:events_count]).to eq(1) diff --git a/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb b/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb index 7d49dce6e18..bbd136bf6f8 100644 --- a/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb +++ b/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb @@ -25,6 +25,7 @@ { organization_id:, code:, + transaction_id:, external_customer_id:, external_subscription_id:, timestamp:, @@ -32,6 +33,8 @@ } end + let(:transaction_id) { SecureRandom.uuid } + let(:properties) { nil } let(:code) { billable_metric&.code } @@ -51,16 +54,12 @@ expect(result.fees.count).to eq(1) fee = result.fees.first - expect(fee).not_to be_persisted - expect(fee).to have_attributes( - subscription:, - charge:, - fee_type: 'charge', + expect(fee).to be_a(Hash) + expect(fee).to include( pay_in_advance: true, - invoiceable: charge, + invoiceable: charge.invoiceable, events_count: 1, - pay_in_advance_event_id: nil, - pay_in_advance_event_transaction_id: nil + event_transaction_id: transaction_id ) end @@ -74,7 +73,7 @@ expect(result.fees.count).to eq(1) fee = result.fees.first - expect(fee.amount_cents).to eq(50) + expect(fee[:amount_cents]).to eq(50) end end From 1f9e3934368df71912412146b3e65d74be8839b3 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Thu, 23 Jan 2025 09:29:20 +0100 Subject: [PATCH 06/13] Evaluate expression before estimating --- app/services/fees/estimate_instant_pay_in_advance_service.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/fees/estimate_instant_pay_in_advance_service.rb b/app/services/fees/estimate_instant_pay_in_advance_service.rb index 746b03c2451..d53cf09eff3 100644 --- a/app/services/fees/estimate_instant_pay_in_advance_service.rb +++ b/app/services/fees/estimate_instant_pay_in_advance_service.rb @@ -35,7 +35,8 @@ def estimate_charge_fees(charge) charge_filter = ChargeFilters::EventMatchingService.call(charge:, event:).charge_filter properties = charge_filter&.properties || charge.properties - # fetch value and apply rounding + # Todo: perhaps this should live in its own service + Events::CalculateExpressionService.call!(organization:, event:) billable_metric = charge.billable_metric units = BigDecimal(event.properties[charge.billable_metric.field_name] || 0) units = BillableMetrics::Aggregations::ApplyRoundingService.call!(billable_metric:, units:).units From 634227acf3b692facb0a1b580afaa99b7e525c98 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Thu, 23 Jan 2025 09:45:18 +0100 Subject: [PATCH 07/13] Add expression evaluation spec --- ...stimate_instant_pay_in_advance_service_spec.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb b/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb index bbd136bf6f8..b9df1b4e3f7 100644 --- a/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb +++ b/spec/services/fees/estimate_instant_pay_in_advance_service_spec.rb @@ -77,6 +77,21 @@ end end + context 'when billable metric has an expression configured' do + let(:billable_metric) { create(:sum_billable_metric, organization:, expression: 'event.properties.test * 2') } + let(:properties) { {'test' => 200} } + + it 'calculates evaluates the expression before estimating' do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(40) + end + end + context 'when event code does not match an pay_in_advance charge' do let(:charge) { create(:percentage_charge, plan:, billable_metric:) } From 2ad3fe2c09497cc41a07873a480240419ade1548 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Thu, 23 Jan 2025 11:08:22 +0100 Subject: [PATCH 08/13] speed up by doing less --- ...estimate_instant_pay_in_advance_service.rb | 41 +------------------ 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/app/services/fees/estimate_instant_pay_in_advance_service.rb b/app/services/fees/estimate_instant_pay_in_advance_service.rb index d53cf09eff3..8520d2c255b 100644 --- a/app/services/fees/estimate_instant_pay_in_advance_service.rb +++ b/app/services/fees/estimate_instant_pay_in_advance_service.rb @@ -12,9 +12,6 @@ def initialize(organization:, params:) end def call - validation_result = Events::ValidateCreationService.call(organization:, event_params:, customer:, subscriptions:) - return validation_result unless validation_result.success? - if charges.none? return result.single_validation_failure!(field: :code, error_code: 'does_not_match_an_instant_charge') end @@ -97,23 +94,10 @@ def estimate_charge_fees(charge) failed_at: nil, refunded_at: nil, amount_details: nil, - from_date: boundaries[:charges_from_datetime]&.to_datetime&.iso8601, - to_date: boundaries[:charges_to_datetime]&.to_datetime&.end_of_day&.iso8601, event_transaction_id: event.transaction_id } end - def boundaries - @boundaries ||= { - from_datetime: date_service.from_datetime, - to_datetime: date_service.to_datetime, - charges_from_datetime: date_service.charges_from_datetime, - charges_to_datetime: date_service.charges_to_datetime, - charges_duration: date_service.charges_duration_in_days, - timestamp: billing_at - } - end - def event return @event if @event @@ -127,29 +111,8 @@ def event ) end - def date_service - @date_service ||= Subscriptions::DatesService.new_instance( - subscription, - billing_at, - current_usage: true - ) - end - - def subscriptions - return @subscriptions if defined? @subscriptions - - subscriptions = organization.subscriptions.where(external_id: event.external_subscription_id) - return unless subscriptions - - timestamp = event.timestamp - @subscriptions = subscriptions - .where("date_trunc('second', started_at::timestamp) <= ?", timestamp) - .where("terminated_at IS NULL OR date_trunc('second', terminated_at::timestamp) >= ?", timestamp) - .order('terminated_at DESC NULLS FIRST, started_at DESC') - end - def charges - @charges ||= subscriptions.first + @charges ||= subscription .plan .charges .percentage @@ -159,7 +122,7 @@ def charges end def currency - subscription.plan.amount.currency + @currency ||= subscription.plan.amount.currency end end end From 2549ead1fb05685edbc4bedfe07c1023a880ce4e Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Thu, 23 Jan 2025 11:26:40 +0100 Subject: [PATCH 09/13] simple subscription validation --- app/services/fees/estimate_instant_pay_in_advance_service.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/services/fees/estimate_instant_pay_in_advance_service.rb b/app/services/fees/estimate_instant_pay_in_advance_service.rb index 8520d2c255b..ba1e1374b65 100644 --- a/app/services/fees/estimate_instant_pay_in_advance_service.rb +++ b/app/services/fees/estimate_instant_pay_in_advance_service.rb @@ -12,6 +12,8 @@ def initialize(organization:, params:) end def call + return result.not_found_failure!(resource: 'subscription') unless subscription + if charges.none? return result.single_validation_failure!(field: :code, error_code: 'does_not_match_an_instant_charge') end From ab6eb1654b99f3a75401d0783dd7cf431318d79a Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Thu, 23 Jan 2025 17:55:06 +0100 Subject: [PATCH 10/13] Add batch endpoint for instant estimate --- app/controllers/api/v1/events_controller.rb | 14 +++ config/routes.rb | 1 + .../requests/api/v1/events_controller_spec.rb | 93 +++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb index 4d80b91aae9..185342eac75 100644 --- a/app/controllers/api/v1/events_controller.rb +++ b/app/controllers/api/v1/events_controller.rb @@ -100,6 +100,20 @@ def estimate_instant_fees end end + def batch_estimate_instant_fees + fees = [] + batch_params[:events].each do |create_params| + fees += Fees::EstimateInstantPayInAdvanceService.call!( + organization: current_organization, + params: create_params + ).fees + end + + render( + json: {fees: fees} + ) + end + def estimate_fees result = Fees::EstimatePayInAdvanceService.call( organization: current_organization, diff --git a/config/routes.rb b/config/routes.rb index cabf990fb04..a3c9557ada5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -52,6 +52,7 @@ resources :events, only: %i[create show index] do post :estimate_fees, on: :collection post :estimate_instant_fees, on: :collection + post :batch_estimate_instant_fees, on: :collection end resources :applied_coupons, only: %i[create index] resources :fees, only: %i[show update index destroy] diff --git a/spec/requests/api/v1/events_controller_spec.rb b/spec/requests/api/v1/events_controller_spec.rb index 833ca7ec3e1..96358d47d6c 100644 --- a/spec/requests/api/v1/events_controller_spec.rb +++ b/spec/requests/api/v1/events_controller_spec.rb @@ -385,6 +385,99 @@ end end + describe 'POST /api/v1/events/batch_estimate_instant_fees' do + subject do + post_with_token(organization, '/api/v1/events/batch_estimate_instant_fees', events: batch_params) + end + + let(:metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric: metric, properties: {rate: '0.1', fixed_amount: '0'}) } + + let(:event_params) do + { + code: metric.code, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: SecureRandom.uuid, + properties: { + metric.field_name => 400 + } + } + end + + let(:batch_params) { [event_params] } + + before do + charge + end + + include_examples 'requires API permission', 'event', 'write' + + it 'returns a success' do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fees].count).to eq(1) + + fee = json[:fees].first + expect(fee[:lago_id]).to be_nil + expect(fee[:lago_group_id]).to be_nil + expect(fee[:item][:type]).to eq('charge') + expect(fee[:item][:code]).to eq(metric.code) + expect(fee[:item][:name]).to eq(metric.name) + expect(fee[:amount_cents]).to eq('40.0') + expect(fee[:amount_currency]).to eq('EUR') + expect(fee[:units]).to eq('400.0') + expect(fee[:events_count]).to eq(1) + end + + context 'with multiple events' do + let(:event2_params) do + { + code: metric.code, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: SecureRandom.uuid, + properties: { + metric.field_name => 300 + } + } + end + + let(:batch_params) { [event_params, event2_params] } + + it 'returns a success' do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fees].count).to eq(2) + fee1 = json[:fees].find { |f| f[:event_transaction_id] == event_params[:transaction_id] } + fee2 = json[:fees].find { |f| f[:event_transaction_id] == event2_params[:transaction_id] } + + expect(fee1[:lago_id]).to be_nil + expect(fee1[:lago_group_id]).to be_nil + expect(fee1[:item][:type]).to eq('charge') + expect(fee1[:item][:code]).to eq(metric.code) + expect(fee1[:item][:name]).to eq(metric.name) + expect(fee1[:amount_cents]).to eq('40.0') + expect(fee1[:amount_currency]).to eq('EUR') + expect(fee1[:units]).to eq('400.0') + expect(fee1[:events_count]).to eq(1) + expect(fee2[:lago_id]).to be_nil + expect(fee2[:lago_group_id]).to be_nil + expect(fee2[:item][:type]).to eq('charge') + expect(fee2[:item][:code]).to eq(metric.code) + expect(fee2[:item][:name]).to eq(metric.name) + expect(fee2[:amount_cents]).to eq('30.0') + expect(fee2[:amount_currency]).to eq('EUR') + expect(fee2[:units]).to eq('300.0') + expect(fee2[:events_count]).to eq(1) + end + end + end + describe 'POST /api/v1/events/estimate_instant_fees' do subject do post_with_token(organization, '/api/v1/events/estimate_instant_fees', event: event_params) From 31f066f3c592a3346c4a3084d3783939bef4b2c0 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Mon, 27 Jan 2025 08:29:01 +0100 Subject: [PATCH 11/13] catch runtime error in calculate expression service --- app/services/events/calculate_expression_service.rb | 2 ++ app/services/fees/estimate_instant_pay_in_advance_service.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/services/events/calculate_expression_service.rb b/app/services/events/calculate_expression_service.rb index edb918a1b13..ce8b2bfdd35 100644 --- a/app/services/events/calculate_expression_service.rb +++ b/app/services/events/calculate_expression_service.rb @@ -24,6 +24,8 @@ def call event.properties[field_name] = value result + rescue RuntimeError => e + result.service_failure!(code: :expression_evaluation_failed, message: e.message) end private diff --git a/app/services/fees/estimate_instant_pay_in_advance_service.rb b/app/services/fees/estimate_instant_pay_in_advance_service.rb index ba1e1374b65..fcf77e42273 100644 --- a/app/services/fees/estimate_instant_pay_in_advance_service.rb +++ b/app/services/fees/estimate_instant_pay_in_advance_service.rb @@ -35,7 +35,7 @@ def estimate_charge_fees(charge) properties = charge_filter&.properties || charge.properties # Todo: perhaps this should live in its own service - Events::CalculateExpressionService.call!(organization:, event:) + Events::CalculateExpressionService.call(organization:, event:) billable_metric = charge.billable_metric units = BigDecimal(event.properties[charge.billable_metric.field_name] || 0) units = BillableMetrics::Aggregations::ApplyRoundingService.call!(billable_metric:, units:).units From 8a3f87257c2ef713cf9984161045a4e5ae9f627e Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Tue, 28 Jan 2025 15:13:37 +0100 Subject: [PATCH 12/13] Add special service for dealing with batches --- app/controllers/api/v1/events_controller.rb | 7 +- ...estimate_instant_pay_in_advance_service.rb | 144 ++++++++++++++++++ ...estimate_instant_pay_in_advance_service.rb | 4 +- 3 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 app/services/fees/batch_estimate_instant_pay_in_advance_service.rb diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb index 185342eac75..080d729fe37 100644 --- a/app/controllers/api/v1/events_controller.rb +++ b/app/controllers/api/v1/events_controller.rb @@ -102,10 +102,11 @@ def estimate_instant_fees def batch_estimate_instant_fees fees = [] - batch_params[:events].each do |create_params| - fees += Fees::EstimateInstantPayInAdvanceService.call!( + batch_params[:events].group_by { |h| h[:external_subscription_id] }.each do |external_subscription_id, events| + fees += Fees::BatchEstimateInstantPayInAdvanceService.call!( organization: current_organization, - params: create_params + external_subscription_id:, + events: ).fees end diff --git a/app/services/fees/batch_estimate_instant_pay_in_advance_service.rb b/app/services/fees/batch_estimate_instant_pay_in_advance_service.rb new file mode 100644 index 00000000000..f1035f70a9d --- /dev/null +++ b/app/services/fees/batch_estimate_instant_pay_in_advance_service.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module Fees + class BatchEstimateInstantPayInAdvanceService < BaseService + def initialize(organization:, external_subscription_id:, events:) + @organization = organization + @external_subscription_id = external_subscription_id + @timestamp = Time.current + + @events = events.map do |e| + Event.new( + organization_id: organization.id, + code: e[:code], + external_subscription_id: e[:external_subscription_id], + properties: e[:properties] || {}, + transaction_id: e[:transaction_id] || SecureRandom.uuid, + timestamp: + ) + end + + super + end + + def call + return result.not_found_failure!(resource: 'subscription') unless subscription + + if charges.none? + return result.single_validation_failure!(field: :code, error_code: 'does_not_match_an_instant_charge') + end + + fees = [] + + events.each do |event| + # find all charges that match this event + matched_charges = charges.select { |c| c.billable_metric.code == event.code } + next unless matched_charges + fees += matched_charges.map { |charge| estimate_charge_fees(charge, event) } + end + + result.fees = fees + result + end + + private + + attr_reader :events, :timestamp, :external_subscription_id, :organization + delegate :customer, to: :subscription + + def estimate_charge_fees(charge, event) + charge_filter = ChargeFilters::EventMatchingService.call(charge:, event:).charge_filter + properties = charge_filter&.properties || charge.properties + + # Todo: perhaps this should live in its own service + Events::CalculateExpressionService.call(organization:, event:) + billable_metric = charge.billable_metric + units = BigDecimal(event.properties[charge.billable_metric.field_name] || 0) + units = BillableMetrics::Aggregations::ApplyRoundingService.call!(billable_metric:, units:).units + + estimate_result = Charges::EstimateInstant::PercentageService.call!(properties:, units:) + + amount = estimate_result.amount + # NOTE: amount_result should be a BigDecimal, we need to round it + # to the currency decimals and transform it into currency cents + rounded_amount = amount.round(currency.exponent) + amount_cents = rounded_amount * currency.subunit_to_unit + unit_amount = rounded_amount.zero? ? BigDecimal("0") : rounded_amount / units + unit_amount_cents = unit_amount * currency.subunit_to_unit + + # construct payload directly + { + lago_id: nil, + lago_charge_id: charge.id, + lago_charge_filter_id: charge_filter&.id, + lago_invoice_id: nil, + lago_true_up_fee_id: nil, + lago_true_up_parent_fee_id: nil, + lago_subscription_id: subscription.id, + external_subscription_id: subscription.external_id, + lago_customer_id: customer.id, + external_customer_id: customer.external_id, + item: { + type: 'charge', + code: billable_metric.code, + name: billable_metric.name, + description: billable_metric.description, + invoice_display_name: charge.invoice_display_name.presence || billable_metric.name, + filters: charge_filter&.to_h, + filter_invoice_display_name: charge_filter&.display_name, + lago_item_id: billable_metric.id, + item_type: BillableMetric.name, + grouped_by: {} + }, + pay_in_advance: true, + invoiceable: charge.invoiceable, + amount_cents:, + amount_currency: currency.iso_code, + precise_amount: amount, + precise_total_amount: amount, + taxes_amount_cents: 0, + taxes_precise_amount: 0, + taxes_rate: 0, + total_amount_cents: amount_cents, + total_amount_currency: currency.iso_code, + units: units, + description: nil, + precise_unit_amount: unit_amount_cents, + precise_coupons_amount_cents: "0.0", + events_count: 1, + payment_status: "pending", + created_at: nil, + succeeded_at: nil, + failed_at: nil, + refunded_at: nil, + amount_details: nil, + event_transaction_id: event.transaction_id + } + end + + def subscription + @subscription ||= + organization.subscriptions.where(external_id: external_subscription_id) + .where("date_trunc('millisecond', started_at::timestamp) <= ?::timestamp", timestamp) + .where( + "terminated_at IS NULL OR date_trunc('millisecond', terminated_at::timestamp) >= ?", + timestamp + ) + .order('terminated_at DESC NULLS FIRST, started_at DESC') + .first + end + + def charges + @charges ||= subscription + .plan + .charges + .percentage + .pay_in_advance + .includes(:billable_metric) + end + + def currency + @currency ||= subscription.plan.amount.currency + end + end +end diff --git a/app/services/fees/estimate_instant_pay_in_advance_service.rb b/app/services/fees/estimate_instant_pay_in_advance_service.rb index fcf77e42273..4dcedeba854 100644 --- a/app/services/fees/estimate_instant_pay_in_advance_service.rb +++ b/app/services/fees/estimate_instant_pay_in_advance_service.rb @@ -4,9 +4,7 @@ module Fees class EstimateInstantPayInAdvanceService < BaseService def initialize(organization:, params:) @organization = organization - # NOTE: validation is shared with event creation and is expecting a transaction_id @event_params = params - @billing_at = event.timestamp super end @@ -26,7 +24,7 @@ def call private - attr_reader :event_params, :organization, :billing_at + attr_reader :event_params, :organization delegate :subscription, to: :event delegate :customer, to: :subscription, allow_nil: true From b35b1c7533f8b5e0a4b7b53d1413a5544ac928f0 Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Wed, 29 Jan 2025 11:11:09 +0100 Subject: [PATCH 13/13] Add specs for batch service --- ...ate_instant_pay_in_advance_service_spec.rb | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 spec/services/fees/batch_estimate_instant_pay_in_advance_service_spec.rb diff --git a/spec/services/fees/batch_estimate_instant_pay_in_advance_service_spec.rb b/spec/services/fees/batch_estimate_instant_pay_in_advance_service_spec.rb new file mode 100644 index 00000000000..fcc915fe0b0 --- /dev/null +++ b/spec/services/fees/batch_estimate_instant_pay_in_advance_service_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fees::BatchEstimateInstantPayInAdvanceService do + subject { described_class.new(organization:, external_subscription_id:, events:) } + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric:, properties: {rate: '0.1', fixed_amount: '0'}) } + + let(:customer) { create(:customer, organization:) } + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + started_at: 1.year.ago + ) + end + + let(:event) do + { + organization_id:, + code:, + transaction_id:, + external_customer_id:, + external_subscription_id:, + timestamp:, + properties: + } + end + let(:events) { [event] } + + let(:transaction_id) { SecureRandom.uuid } + + let(:properties) { nil } + + let(:code) { billable_metric&.code } + let(:external_customer_id) { customer&.external_id } + let(:external_subscription_id) { subscription&.external_id } + let(:organization_id) { organization.id } + let(:timestamp) { Time.current.to_i.to_s } + let(:currency) { subscription.plan.amount.currency } + + before { charge } + + # TODO: these are copied from the non-batch service. does it make sense to creata a shared_example? + context "with 1 event" do + describe '#call' do + it 'returns a list of fees' do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee).to be_a(Hash) + expect(fee).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: transaction_id + ) + end + + context 'when setting event properties' do + let(:properties) { {billable_metric.field_name => 500} } + + it 'calculates the fee correctly' do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(50) + end + end + + context 'when billable metric has an expression configured' do + let(:billable_metric) { create(:sum_billable_metric, organization:, expression: 'event.properties.test * 2') } + let(:properties) { {'test' => 200} } + + it 'calculates evaluates the expression before estimating' do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(40) + end + end + + context 'when event code does not match an pay_in_advance charge' do + let(:charge) { create(:percentage_charge, plan:, billable_metric:) } + + it 'fails with a validation error' do + result = subject.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(['does_not_match_an_instant_charge']) + end + end + end + + context 'when event matches multiple charges' do + let(:charge2) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric:) } + + before { charge2 } + + it 'returns a fee per charges' do + result = subject.call + + aggregate_failures do + expect(result).to be_success + expect(result.fees.count).to eq(2) + end + end + end + + context 'when external subscription is not found' do + let(:external_subscription_id) { nil } + + it 'fails with a not found error' do + result = subject.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq('subscription_not_found') + end + end + end + end + end + + context "with multiple events" do + let(:billable_metric2) { create(:sum_billable_metric, organization:) } + let(:charge2) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric: billable_metric2, properties: {rate: '0.1', fixed_amount: '1'}) } + let(:event2) do + { + organization_id:, + code: billable_metric2.code, + transaction_id: SecureRandom.uuid, + external_customer_id:, + external_subscription_id:, + timestamp:, + properties: properties2 + } + end + let(:properties2) { nil } + let(:events) { [event, event2] } + + before { charge2 } + + describe "#call" do + it 'returns a list of fees' do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f[:event_transaction_id] == event[:transaction_id] } + fee2 = result.fees.find { |f| f[:event_transaction_id] == event2[:transaction_id] } + expect(fee1).to be_a(Hash) + expect(fee1).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: transaction_id + ) + expect(fee2).to be_a(Hash) + expect(fee2).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: event2[:transaction_id] + ) + end + end + + context "when properties are set" do + let(:properties) { {billable_metric.field_name => 100} } + let(:properties2) { {billable_metric2.field_name => 500} } + + it 'calculates the fee correctly' do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f[:event_transaction_id] == event[:transaction_id] } + fee2 = result.fees.find { |f| f[:event_transaction_id] == event2[:transaction_id] } + expect(fee1[:amount_cents]).to eq(10) + expect(fee2[:amount_cents]).to eq(150) + end + end + + context 'when external subscription is not found' do + let(:external_subscription_id) { nil } + + it 'fails with a not found error' do + result = subject.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq('subscription_not_found') + end + end + end + end +end