Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feat(InstantEstimate) - estimate instant fees #3033

Merged
merged 13 commits into from
Jan 29, 2025
30 changes: 30 additions & 0 deletions app/controllers/api/v1/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,36 @@ def index
end
end

def estimate_instant_fees
result = Fees::EstimateInstantPayInAdvanceService.call(
organization: current_organization,
params: create_params
)

if result.success?
render(
json: {fees: result.fees}
)
else
render_error_response(result)
end
end

def batch_estimate_instant_fees
fees = []
batch_params[:events].group_by { |h| h[:external_subscription_id] }.each do |external_subscription_id, events|
fees += Fees::BatchEstimateInstantPayInAdvanceService.call!(
organization: current_organization,
external_subscription_id:,
events:
).fees
end

render(
json: {fees: fees}
)
end

def estimate_fees
result = Fees::EstimatePayInAdvanceService.call(
organization: current_organization,
Expand Down
51 changes: 51 additions & 0 deletions app/services/charges/estimate_instant/percentage_service.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/services/events/calculate_expression_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 20 additions & 38 deletions app/services/events/validate_creation_service.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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?
Expand All @@ -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)
Expand All @@ -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
144 changes: 144 additions & 0 deletions app/services/fees/batch_estimate_instant_pay_in_advance_service.rb
Original file line number Diff line number Diff line change
@@ -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
Loading