diff --git a/app/models/event.rb b/app/models/event.rb index 22997d4d02e..a9ffe2952ef 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -6,4 +6,7 @@ class Event < ApplicationRecord validates :transaction_id, presence: true, uniqueness: { scope: :organization_id } validates :code, presence: true + + scope :from_date, ->(from_date) { where('events.timestamp >= ?', from_date.beginning_of_day) } + scope :to_date, ->(to_date) { where('events.timestamp <= ?', to_date.end_of_day) } end diff --git a/app/models/fee.rb b/app/models/fee.rb index a15f303d711..8d66df3d742 100644 --- a/app/models/fee.rb +++ b/app/models/fee.rb @@ -18,6 +18,7 @@ class Fee < ApplicationRecord validates :vat_amount_currency, inclusion: { in: currency_list } scope :subscription_kind, -> { where(charge_id: nil) } + scope :charge_kind, -> { where.not(charge_id: nil) } def subscription_fee? charge_id.blank? diff --git a/app/services/billable_metrics/aggregations/base_service.rb b/app/services/billable_metrics/aggregations/base_service.rb new file mode 100644 index 00000000000..b7edde22d85 --- /dev/null +++ b/app/services/billable_metrics/aggregations/base_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class BaseService < ::BaseService + def initialize(billable_metric:, subscription:) + super(nil) + @billable_metric = billable_metric + @subscription = subscription + end + + def aggregate(from_date:, to_date:) + raise NotImplementedError + end + + protected + + attr_accessor :billable_metric, :subscription + + delegate :customer, to: :subscription + end + end +end diff --git a/app/services/billable_metrics/aggregations/count_service.rb b/app/services/billable_metrics/aggregations/count_service.rb new file mode 100644 index 00000000000..b79a604974b --- /dev/null +++ b/app/services/billable_metrics/aggregations/count_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class CountService < BillableMetrics::Aggregations::BaseService + def aggregate(from_date:, to_date:) + # TODO: different behavior for one shot and recurring events + + result.aggregation = customer.events + .from_date(from_date) + .to_date(to_date) + .where(code: billable_metric.code) + .count + + result + end + end + end +end diff --git a/app/services/charges/charge_models/base_service.rb b/app/services/charges/charge_models/base_service.rb new file mode 100644 index 00000000000..166d56a9fcb --- /dev/null +++ b/app/services/charges/charge_models/base_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Charges + module ChargeModels + class BaseService < ::BaseService + def initialize(charge:) + super(nil) + @charge = charge + end + + def apply(value:) + raise NotImplementedError + end + + protected + + attr_accessor :charge + end + end +end diff --git a/app/services/charges/charge_models/standard_service.rb b/app/services/charges/charge_models/standard_service.rb new file mode 100644 index 00000000000..69bf2783311 --- /dev/null +++ b/app/services/charges/charge_models/standard_service.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Charges + module ChargeModels + class StandardService < Charges::ChargeModels::BaseService + def apply(value:) + result.amount_cents = value * charge.amount_cents + result + end + end + end +end diff --git a/app/services/fees/charge_service.rb b/app/services/fees/charge_service.rb new file mode 100644 index 00000000000..47d7d4db576 --- /dev/null +++ b/app/services/fees/charge_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Fees + class ChargeService < BaseService + def initialize(invoice:, charge:) + @invoice = invoice + @charge = charge + super(nil) + end + + def create + return result if already_billed? + + new_amount_cents = compute_amount + + new_fee = Fee.new( + invoice: invoice, + subscription: subscription, + charge: charge, + amount_cents: new_amount_cents, + amount_currency: charge.amount_currency, + vat_rate: charge.vat_rate, + ) + + new_fee.compute_vat + new_fee.save! + + result.fee = new_fee + result + rescue ActiveRecord::RecordInvalid => e + result.fail_with_validations!(e.record) + end + + private + + attr_accessor :invoice, :charge + + delegate :plan, :subscription, to: :invoice + delegate :billable_metric, to: :charge + + def compute_amount + aggregated_events = aggregator.aggregate(from_date: invoice.from_date, to_date: invoice.to_date) + return result.fail!('aggregation_failure') unless aggregated_events.success? + + amount_result = charge_model.apply(value: aggregated_events.aggregation) + return result.fail!('charge_model_failure') unless amount_result.success? + + amount_result.amount_cents + end + + def already_billed? + existing_fee = invoice.fees.find_by(charge_id: charge.id) + return false unless existing_fee + + result.fee = existing_fee + true + end + + def aggregator + return @aggregator if @aggregator + + aggregator_service = case billable_metric.aggregation_type.to_sym + when :count_agg + BillableMetrics::Aggregations::CountService + else + raise NotImplementedError + end + + @aggregator = aggregator_service.new(billable_metric: billable_metric, subscription: subscription) + end + + def charge_model + return @charge_model if @charge_model + + model_service = case charge.charge_model.to_sym + when :standard + Charges::ChargeModels::StandardService + else + raise NotImplementedError + end + + @charge_model = model_service.new(charge: charge) + end + end +end diff --git a/app/services/invoices/create_service.rb b/app/services/invoices/create_service.rb index 6559932af44..5fd43220a41 100644 --- a/app/services/invoices/create_service.rb +++ b/app/services/invoices/create_service.rb @@ -10,18 +10,22 @@ def initialize(subscription:, timestamp:) end def create - invoice = Invoice.find_or_create_by!( - subscription: subscription, - from_date: from_date, - to_date: to_date, - issuing_date: issuing_date, - ) + ActiveRecord::Base.transaction do + invoice = Invoice.find_or_create_by!( + subscription: subscription, + from_date: from_date, + to_date: to_date, + issuing_date: issuing_date, + ) - Fees::SubscriptionService.new(invoice).create + create_subscription_fee(invoice) + create_charges_fees(invoice) - compute_amounts(invoice) + compute_amounts(invoice) + + result.invoice = invoice + end - result.invoice = invoice result rescue ActiveRecord::RecordInvalid => e result.fail_with_validations!(e.record) @@ -91,5 +95,17 @@ def compute_amounts(invoice) invoice.save! end + + def create_subscription_fee(invoice) + fee_result = Fees::SubscriptionService.new(invoice).create + result.throw_error unless fee_result.success? + end + + def create_charges_fees(invoice) + subscription.plan.charges.each do |charge| + fee_result = Fees::ChargeService.new(invoice: invoice, charge: charge).create + result.throw_error unless fee_result.success? + end + end end end diff --git a/spec/factories/charge_factory.rb b/spec/factories/charge_factory.rb index 470c2514b35..8f05d9873d0 100644 --- a/spec/factories/charge_factory.rb +++ b/spec/factories/charge_factory.rb @@ -5,8 +5,10 @@ amount_cents { Faker::Number.between(from: 100, to: 500) } amount_currency { 'EUR' } - + vat_rate { 20 } + pro_rata { false } + charge_model { 'standard' } factory :one_time_charge do frequency { :one_time } diff --git a/spec/services/billable_metrics/aggregations/count_service_spec.rb b/spec/services/billable_metrics/aggregations/count_service_spec.rb new file mode 100644 index 00000000000..30bee198b78 --- /dev/null +++ b/spec/services/billable_metrics/aggregations/count_service_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe BillableMetrics::Aggregations::CountService, type: :service do + subject(:count_service) do + described_class.new(billable_metric: billable_metric, subscription: subscription) + end + + let(:subscription) { create(:subscription) } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + + let(:billable_metric) do + create( + :billable_metric, + organization: organization, + aggregation_type: 'count_agg', + billable_period: 'one_shot', + ) + end + + let(:from_date) { Time.zone.today - 1.month } + let(:to_date) { Time.zone.today } + + before do + create_list( + :event, + 4, + code: billable_metric.code, + customer: customer, + timestamp: Time.zone.now, + ) + end + + it 'aggregates the events' do + result = count_service.aggregate(from_date: from_date, to_date: to_date) + + expect(result.aggregation).to eq(4) + end + + context 'when events are out of bounds' do + let(:to_date) { Time.zone.now - 2.days } + + it 'does not take events into account' do + result = count_service.aggregate(from_date: from_date, to_date: to_date) + + expect(result.aggregation).to eq(0) + end + end +end diff --git a/spec/services/charges/charge_models/standard_service_spec.rb b/spec/services/charges/charge_models/standard_service_spec.rb new file mode 100644 index 00000000000..e0439daf548 --- /dev/null +++ b/spec/services/charges/charge_models/standard_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Charges::ChargeModels::StandardService, type: :service do + subject(:standard_service) { described_class.new(charge: charge) } + + let(:charge) do + create( + :one_time_charge, + amount_cents: 500, + charge_model: 'standard', + ) + end + + it 'apply the charge model to the value' do + expect(standard_service.apply(value: 10).amount_cents).to eq(5000) + end +end diff --git a/spec/services/fees/charge_service_spec.rb b/spec/services/fees/charge_service_spec.rb new file mode 100644 index 00000000000..cb86226c7c4 --- /dev/null +++ b/spec/services/fees/charge_service_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fees::ChargeService do + subject(:charge_subscription_service) do + described_class.new(invoice: invoice, charge: charge) + end + + let(:subscription) { create(:subscription) } + let(:invoice) { create(:invoice, subscription: subscription) } + let(:billable_metric) { create(:billable_metric, aggregation_type: 'count_agg') } + let(:charge) { create(:one_time_charge, plan: subscription.plan, charge_model: 'standard') } + + describe '.create' do + it 'creates a fee' do + result = charge_subscription_service.create + + expect(result).to be_success + + created_fee = result.fee + + aggregate_failures do + expect(created_fee.id).not_to be_nil + expect(created_fee.invoice_id).to eq(invoice.id) + expect(created_fee.charge_id).to eq(charge.id) + expect(created_fee.amount_cents).to eq(0) + expect(created_fee.amount_currency).to eq('EUR') + expect(created_fee.vat_amount_cents).to eq(0) + expect(created_fee.vat_rate).to eq(20.0) + end + end + + context 'when fee already exists on the period' do + before do + create( + :fee, + charge: charge, + subscription: subscription, + invoice: invoice, + ) + end + + it 'does not create a new fee' do + expect { charge_subscription_service.create }.not_to change(Fee, :count) + end + end + end +end diff --git a/spec/services/invoices/create_service_spec.rb b/spec/services/invoices/create_service_spec.rb index ddfc8a49f2c..a088822546a 100644 --- a/spec/services/invoices/create_service_spec.rb +++ b/spec/services/invoices/create_service_spec.rb @@ -10,6 +10,12 @@ describe 'create' do let(:subscription) { create(:subscription, plan: plan, started_at: Time.zone.now - 2.years) } + let(:billable_metric) { create(:billable_metric, aggregation_type: 'count_agg') } + + before do + create(:one_time_charge, plan: subscription.plan, charge_model: 'standard') + end + context 'when billed monthly on begging of period' do let(:timestamp) { Time.zone.now.beginning_of_month } @@ -27,7 +33,8 @@ expect(result.invoice.from_date).to eq(timestamp - 1.month) expect(result.invoice.subscription).to eq(subscription) expect(result.invoice.issuing_date.to_date).to eq(timestamp - 1.day) - expect(result.invoice.fees.count).to eq(1) + expect(result.invoice.fees.subscription_kind.count).to eq(1) + expect(result.invoice.fees.charge_kind.count).to eq(1) expect(result.invoice.amount_cents).to eq(100) expect(result.invoice.amount_currency).to eq('EUR') @@ -54,7 +61,8 @@ expect(result.invoice.to_date).to eq((timestamp - 1.day).to_date) expect(result.invoice.from_date).to eq((timestamp - 1.month).to_date) expect(result.invoice.subscription).to eq(subscription) - expect(result.invoice.fees.count).to eq(1) + expect(result.invoice.fees.subscription_kind.count).to eq(1) + expect(result.invoice.fees.charge_kind.count).to eq(1) end end end @@ -78,7 +86,8 @@ expect(result.invoice.to_date).to eq(timestamp - 1.day) expect(result.invoice.from_date).to eq(started_at) expect(result.invoice.subscription).to eq(subscription) - expect(result.invoice.fees.count).to eq(1) + expect(result.invoice.fees.subscription_kind.count).to eq(1) + expect(result.invoice.fees.charge_kind.count).to eq(1) end end end @@ -99,7 +108,8 @@ expect(result.invoice.to_date).to eq(timestamp - 1.day) expect(result.invoice.from_date).to eq(timestamp - 1.year) expect(result.invoice.subscription).to eq(subscription) - expect(result.invoice.fees.count).to eq(1) + expect(result.invoice.fees.subscription_kind.count).to eq(1) + expect(result.invoice.fees.charge_kind.count).to eq(1) end end end @@ -120,7 +130,8 @@ expect(result.invoice.to_date).to eq((timestamp - 1.day).to_date) expect(result.invoice.from_date).to eq((timestamp - 1.year).to_date) expect(result.invoice.subscription).to eq(subscription) - expect(result.invoice.fees.count).to eq(1) + expect(result.invoice.fees.subscription_kind.count).to eq(1) + expect(result.invoice.fees.charge_kind.count).to eq(1) end end end @@ -143,7 +154,8 @@ expect(result.invoice.to_date).to eq(timestamp - 1.day) expect(result.invoice.from_date).to eq(started_at) expect(result.invoice.subscription).to eq(subscription) - expect(result.invoice.fees.count).to eq(1) + expect(result.invoice.fees.subscription_kind.count).to eq(1) + expect(result.invoice.fees.charge_kind.count).to eq(1) end end end