diff --git a/app/models/fee/applied_tax.rb b/app/models/fee/applied_tax.rb index 9936f8e7f21..d73bb3392cf 100644 --- a/app/models/fee/applied_tax.rb +++ b/app/models/fee/applied_tax.rb @@ -7,6 +7,6 @@ class AppliedTax < ApplicationRecord include PaperTrailTraceable belongs_to :fee - belongs_to :tax + belongs_to :tax, optional: true end end diff --git a/app/models/invoice/applied_tax.rb b/app/models/invoice/applied_tax.rb index e0a4840ce70..1a1b6f9d257 100644 --- a/app/models/invoice/applied_tax.rb +++ b/app/models/invoice/applied_tax.rb @@ -7,7 +7,7 @@ class AppliedTax < ApplicationRecord include PaperTrailTraceable belongs_to :invoice - belongs_to :tax + belongs_to :tax, optional: true monetize :amount_cents, :fees_amount_cents, diff --git a/app/services/fees/apply_provider_taxes_service.rb b/app/services/fees/apply_provider_taxes_service.rb new file mode 100644 index 00000000000..1cdb77f10ac --- /dev/null +++ b/app/services/fees/apply_provider_taxes_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Fees + class ApplyProviderTaxesService < BaseService + def initialize(fee:, fee_taxes:) + @fee = fee + @fee_taxes = fee_taxes + + super + end + + def call + result.applied_taxes = [] + return result if fee.applied_taxes.any? + + applied_taxes_amount_cents = 0 + applied_taxes_rate = 0 + + fee_taxes.tax_breakdown.each do |tax| + tax_rate = tax.rate.to_f * 100 + + applied_tax = Fee::AppliedTax.new( + tax_description: tax.type, + tax_code: tax.name.parameterize(separator: '_'), + tax_name: tax.name, + tax_rate: tax_rate, + amount_currency: fee.amount_currency + ) + fee.applied_taxes << applied_tax + + tax_amount_cents = tax.tax_amount + applied_tax.amount_cents = tax_amount_cents.round + applied_tax.save! if fee.persisted? + + applied_taxes_amount_cents += tax_amount_cents + applied_taxes_rate += tax_rate + + result.applied_taxes << applied_tax + end + + fee.taxes_amount_cents = applied_taxes_amount_cents.round + fee.taxes_rate = applied_taxes_rate + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :fee, :fee_taxes + end +end diff --git a/app/services/invoices/apply_provider_taxes_service.rb b/app/services/invoices/apply_provider_taxes_service.rb new file mode 100644 index 00000000000..5e25a3e7c64 --- /dev/null +++ b/app/services/invoices/apply_provider_taxes_service.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Invoices + class ApplyProviderTaxesService < BaseService + def initialize(invoice:) + @invoice = invoice + @provider_taxes = fetch_provider_taxes_result + + super + end + + def call + provider_taxes.raise_if_error! + + result.applied_taxes = [] + applied_taxes_amount_cents = 0 + taxes_rate = 0 + + applicable_taxes.values.each do |tax| + tax_rate = tax.rate.to_f * 100 + + applied_tax = invoice.applied_taxes.new( + tax_description: tax.type, + tax_code: tax.name.parameterize(separator: '_'), + tax_name: tax.name, + tax_rate: tax_rate, + amount_currency: invoice.currency + ) + invoice.applied_taxes << applied_tax + + tax_amount_cents = compute_tax_amount_cents(tax) + applied_tax.fees_amount_cents = fees_amount_cents(tax) + applied_tax.amount_cents = tax_amount_cents.round + + # NOTE: when applied on user current usage, the invoice is + # not created in DB + applied_tax.save! if invoice.persisted? + + applied_taxes_amount_cents += tax_amount_cents + taxes_rate += pro_rated_taxes_rate(tax) + + result.applied_taxes << applied_tax + end + + invoice.taxes_amount_cents = applied_taxes_amount_cents.round + invoice.taxes_rate = taxes_rate.round(5) + result.invoice = invoice + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :provider_taxes + + def applicable_taxes + return @applicable_taxes if defined? @applicable_taxes + + output = {} + provider_taxes.fees.each do |fee_taxes| + fee_taxes.tax_breakdown.each do |tax| + key = calculate_key(tax) + + next if output[key] + + output[key] = tax + end + end + + @applicable_taxes = output + + @applicable_taxes + end + + def indexed_fees + @indexed_fees ||= invoice.fees.each_with_object({}) do |fee, applied_taxes| + fee.applied_taxes.each do |applied_tax| + tax = OpenStruct.new( + name: applied_tax.tax_name, + rate: applied_tax.tax_rate, + type: applied_tax.tax_description + ) + key = calculate_key(tax) + + applied_taxes[key] ||= [] + applied_taxes[key] << fee + end + end + end + + def compute_tax_amount_cents(tax) + key = calculate_key(tax) + + indexed_fees[key] + .sum { |fee| fee.sub_total_excluding_taxes_amount_cents * tax.rate.to_f } + end + + def pro_rated_taxes_rate(tax) + tax_rate = tax.rate.is_a?(String) ? tax.rate.to_f * 100 : tax.rate + + fees_rate = if invoice.sub_total_excluding_taxes_amount_cents.positive? + fees_amount_cents(tax).fdiv(invoice.sub_total_excluding_taxes_amount_cents) + else + # NOTE: when invoice have a 0 amount. The prorata is on the number of fees + key = calculate_key(tax) + indexed_fees[key].count.fdiv(invoice.fees.count) + end + + fees_rate * tax_rate + end + + def fees_amount_cents(tax) + key = calculate_key(tax) + + indexed_fees[key].sum(&:sub_total_excluding_taxes_amount_cents) + end + + def fetch_provider_taxes_result + Integrations::Aggregator::Taxes::Invoices::CreateService.call(invoice:) + end + + def calculate_key(tax) + tax_rate = tax.rate.is_a?(String) ? tax.rate.to_f * 100 : tax.rate + + "#{tax.type}-#{tax.name.parameterize(separator: "_")}-#{tax_rate}" + end + end +end diff --git a/db/migrate/20240708195226_remove_null_constraint_on_applied_taxes.rb b/db/migrate/20240708195226_remove_null_constraint_on_applied_taxes.rb new file mode 100644 index 00000000000..5faacdb7d42 --- /dev/null +++ b/db/migrate/20240708195226_remove_null_constraint_on_applied_taxes.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class RemoveNullConstraintOnAppliedTaxes < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + change_column_null :fees_taxes, :tax_id, true + change_column_null :invoices_taxes, :tax_id, true + + remove_index :fees_taxes, %i[fee_id tax_id] + remove_index :invoices_taxes, %i[invoice_id tax_id] + + add_index :fees_taxes, + %i[fee_id tax_id], + unique: true, + where: "tax_id IS NOT NULL AND created_at >= '2023-09-12'", + algorithm: :concurrently + + add_index :invoices_taxes, + %i[invoice_id tax_id], + unique: true, + where: "tax_id IS NOT NULL AND created_at >= '2023-09-12'", + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d4fa075e2b..9cc1a7a0e7c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -536,7 +536,7 @@ create_table "fees_taxes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "fee_id", null: false - t.uuid "tax_id", null: false + t.uuid "tax_id" t.string "tax_description" t.string "tax_code", null: false t.string "tax_name", null: false @@ -545,7 +545,7 @@ t.string "amount_currency", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["fee_id", "tax_id"], name: "index_fees_taxes_on_fee_id_and_tax_id", unique: true, where: "(created_at >= '2023-09-12 00:00:00'::timestamp without time zone)" + t.index ["fee_id", "tax_id"], name: "index_fees_taxes_on_fee_id_and_tax_id", unique: true, where: "((tax_id IS NOT NULL) AND (created_at >= '2023-09-12 00:00:00'::timestamp without time zone))" t.index ["fee_id"], name: "index_fees_taxes_on_fee_id" t.index ["tax_id"], name: "index_fees_taxes_on_tax_id" end @@ -744,7 +744,7 @@ create_table "invoices_taxes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "invoice_id", null: false - t.uuid "tax_id", null: false + t.uuid "tax_id" t.string "tax_description" t.string "tax_code", null: false t.string "tax_name", null: false @@ -754,7 +754,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "fees_amount_cents", default: 0, null: false - t.index ["invoice_id", "tax_id"], name: "index_invoices_taxes_on_invoice_id_and_tax_id", unique: true, where: "(created_at >= '2023-09-12 00:00:00'::timestamp without time zone)" + t.index ["invoice_id", "tax_id"], name: "index_invoices_taxes_on_invoice_id_and_tax_id", unique: true, where: "((tax_id IS NOT NULL) AND (created_at >= '2023-09-12 00:00:00'::timestamp without time zone))" t.index ["invoice_id"], name: "index_invoices_taxes_on_invoice_id" t.index ["tax_id"], name: "index_invoices_taxes_on_tax_id" end diff --git a/spec/services/fees/apply_provider_taxes_service_spec.rb b/spec/services/fees/apply_provider_taxes_service_spec.rb new file mode 100644 index 00000000000..944df5c7217 --- /dev/null +++ b/spec/services/fees/apply_provider_taxes_service_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Fees::ApplyProviderTaxesService, type: :service do + subject(:apply_service) { described_class.new(fee:, fee_taxes:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + + let(:invoice) { create(:invoice, organization:, customer:) } + + let(:fee) { create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents:) } + let(:precise_coupons_amount_cents) { 0 } + + let(:fee_taxes) do + OpenStruct.new( + tax_breakdown: [ + OpenStruct.new(name: 'tax 2', type: 'type2', rate: '0.12', tax_amount: 120), + OpenStruct.new(name: 'tax 3', type: 'type3', rate: '0.05', tax_amount: 50) + ] + ) + end + + before do + fee_taxes + end + + describe 'call' do + context 'when there is no applied taxes yet' do + it 'creates applied_taxes based on the provider taxes' do + result = apply_service.call + + aggregate_failures do + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.map(&:tax_code)).to contain_exactly('tax_2', 'tax_3') + expect(fee).to have_attributes(taxes_amount_cents: 170, taxes_rate: 17) + end + end + end + + context 'when fee already have taxes' do + before { create(:fee_applied_tax, fee:) } + + it 'does not re-apply taxes' do + expect do + result = apply_service.call + + expect(result).to be_success + end.not_to change { fee.applied_taxes.count } + end + end + end +end diff --git a/spec/services/invoices/apply_provider_taxes_service_spec.rb b/spec/services/invoices/apply_provider_taxes_service_spec.rb new file mode 100644 index 00000000000..c98079884f7 --- /dev/null +++ b/spec/services/invoices/apply_provider_taxes_service_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Invoices::ApplyProviderTaxesService, type: :service do + subject(:apply_service) { described_class.new(invoice:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + fees_amount_cents:, + coupons_amount_cents:, + sub_total_excluding_taxes_amount_cents: fees_amount_cents - coupons_amount_cents + ) + end + let(:fees_amount_cents) { 3000 } + let(:coupons_amount_cents) { 0 } + let(:result) { BaseService::Result.new } + + let(:fee_taxes) do + [ + OpenStruct.new( + tax_breakdown: [ + OpenStruct.new(name: 'tax 1', type: 'type1', rate: '0.10') + ] + ), + OpenStruct.new( + tax_breakdown: [ + OpenStruct.new(name: 'tax 1', type: 'type1', rate: '0.10'), + OpenStruct.new(name: 'tax 2', type: 'type2', rate: '0.12') + ] + ) + ] + end + + describe 'call' do + before do + result.fees = fee_taxes + allow(Integrations::Aggregator::Taxes::Invoices::CreateService).to receive(:call) + .with(invoice:) + .and_return(result) + end + + context 'with non zero fees amount' do + before do + fee1 = create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents: 0) + create( + :fee_applied_tax, + fee: fee1, + amount_cents: 100, + tax_name: 'tax 1', + tax_code: 'tax_1', + tax_rate: 10.0, + tax_description: 'type1' + ) + + fee2 = create(:fee, invoice:, amount_cents: 2000, precise_coupons_amount_cents: 0) + + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 200, + tax_name: 'tax 1', + tax_code: 'tax_1', + tax_rate: 10.0, + tax_description: 'type1' + ) + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 240, + tax_name: 'tax 2', + tax_code: 'tax_2', + tax_rate: 12.0, + tax_description: 'type2' + ) + end + + it 'creates applied taxes' do + result = apply_service.call + + aggregate_failures do + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.find { |item| item.tax_code == 'tax_1' }).to have_attributes( + invoice:, + tax_description: 'type1', + tax_code: 'tax_1', + tax_name: 'tax 1', + tax_rate: 10, + amount_currency: invoice.currency, + amount_cents: 300, + fees_amount_cents: 3000 + ) + + expect(applied_taxes.find { |item| item.tax_code == 'tax_2' }).to have_attributes( + invoice:, + tax_description: 'type2', + tax_code: 'tax_2', + tax_name: 'tax 2', + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 240, + fees_amount_cents: 2000 + ) + + expect(invoice).to have_attributes( + taxes_amount_cents: 540, + taxes_rate: 18, + fees_amount_cents: 3000 + ) + end + end + end + + context 'when invoices fees_amount_cents is zero' do + let(:fees_amount_cents) { 0 } + + before do + fee1 = create(:fee, invoice:, amount_cents: 0, precise_coupons_amount_cents: 0) + create( + :fee_applied_tax, + fee: fee1, + amount_cents: 0, + tax_name: 'tax 1', + tax_code: 'tax_1', + tax_rate: 10.0, + tax_description: 'type1' + ) + + fee2 = create(:fee, invoice:, amount_cents: 0, precise_coupons_amount_cents: 0) + + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 0, + tax_name: 'tax 1', + tax_code: 'tax_1', + tax_rate: 10.0, + tax_description: 'type1' + ) + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 0, + tax_name: 'tax 2', + tax_code: 'tax_2', + tax_rate: 12.0, + tax_description: 'type2' + ) + end + + it 'creates applied_taxes' do + result = apply_service.call + + aggregate_failures do + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.find { |item| item.tax_code == 'tax_1' }).to have_attributes( + invoice:, + tax_description: 'type1', + tax_code: 'tax_1', + tax_name: 'tax 1', + tax_rate: 10, + amount_currency: invoice.currency, + amount_cents: 0, + fees_amount_cents: 0 + ) + + expect(applied_taxes.find { |item| item.tax_code == 'tax_2' }).to have_attributes( + invoice:, + tax_description: 'type2', + tax_code: 'tax_2', + tax_name: 'tax 2', + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 0, + fees_amount_cents: 0 + ) + + expect(invoice).to have_attributes( + taxes_amount_cents: 0, + taxes_rate: 16, + fees_amount_cents: 0 + ) + end + end + end + end +end