diff --git a/app/models/payment.rb b/app/models/payment.rb index b926190a852..de9e40e5dfb 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -23,11 +23,31 @@ class Payment < ApplicationRecord enum payable_payment_status: PAYABLE_PAYMENT_STATUS.map { |s| [s, s] }.to_h + validate :max_invoice_paid_amount_cents, on: :create + validate :payment_request_succeeded, on: :create + def should_sync_payment? return false unless payable.is_a?(Invoice) payable.finalized? && customer.integration_customers.accounting_kind.any? { |c| c.integration.sync_payments } end + + private + + def max_invoice_paid_amount_cents + return if !payable.is_a?(Invoice) || payment_type_provider? + return if amount_cents + payable.total_paid_amount_cents <= payable.total_amount_cents + + errors.add(:amount_cents, :greater_than) + end + + def payment_request_succeeded + return if !payable.is_a?(Invoice) || payment_type_provider? + + if payable.payment_requests.where(payment_status: 'succeeded').exists? + errors.add(:base, :payment_request_is_already_succeeded) + end + end end # == Schema Information diff --git a/app/services/manual_payments/create_service.rb b/app/services/manual_payments/create_service.rb new file mode 100644 index 00000000000..c41a1990d1b --- /dev/null +++ b/app/services/manual_payments/create_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module ManualPayments + class CreateService < BaseService + def initialize(invoice:, params:) + @invoice = invoice + @params = params + + super + end + + def call + check_preconditions + return result if result.error + + amount_cents = params[:amount_cents] + + ActiveRecord::Base.transaction do + payment = invoice.payments.create!( + amount_cents:, + reference: params[:reference], + amount_currency: invoice.currency, + status: 'succeeded', + payable_payment_status: 'succeeded', + payment_type: :manual + ) + + invoice.update!(total_paid_amount_cents: invoice.total_paid_amount_cents + amount_cents) + + result.payment = payment + + if invoice.payments.where(payable_payment_status: 'succeeded').sum(:amount_cents) == invoice.total_amount_cents + payment.payable.update!(payment_status: 'succeeded') + end + + Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) if result.payment&.should_sync_payment? + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :params + + def check_preconditions + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "invoice") unless invoice + result.forbidden_failure! unless invoice.organization.premium_integrations.include?('manual_payments') + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2a49ff3a45c..20654816e64 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -40,6 +40,7 @@ en: not_compatible_with_aggregation_type: not_compatible_with_aggregation_type not_compatible_with_pay_in_advance: not_compatible_with_pay_in_advance only_compatible_with_pay_in_advance_and_non_invoiceable: only_compatible_with_pay_in_advance_and_non_invoiceable + payment_request_is_already_succeeded: payment_request_is_already_succeeded required: relation_must_exist taken: value_already_exist too_long: value_is_too_long diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index bfb31a3a020..b05b010c3d9 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -3,11 +3,13 @@ require 'rails_helper' RSpec.describe Payment, type: :model do - subject(:payment) { build(:payment, payment_type:, provider_payment_id:, reference:) } + subject(:payment) { build(:payment, payable:, payment_type:, provider_payment_id:, reference:, amount_cents:) } + let(:payable) { create(:invoice, total_amount_cents: 10000) } let(:payment_type) { 'provider' } let(:provider_payment_id) { SecureRandom.uuid } let(:reference) { nil } + let(:amount_cents) { 200 } it_behaves_like 'paper_trail traceable' @@ -29,6 +31,130 @@ before { payment.valid? } + describe 'of max invoice paid amount cents' do + before { payment.save } + + context 'when payable is an invoice' do + context 'when payment type is provider' do + let(:payment_type) { 'provider' } + + context 'when amount cents + total paid amount cents is smaller or equal than invoice total amount cents' do + let(:payment_request) { create(:payment_request, payment_status: :succeeded) } + + it 'does not add an error' do + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + end + + context 'when amount cents + total paid amount cents is greater than invoice total amount cents' do + let(:amount_cents) { 10001 } + + it 'does not add an error' do + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + end + end + + context 'when payment type is manual' do + let(:payment_type) { 'manual' } + + context 'when amount cents + total paid amount cents is smaller or equal than invoice total amount cents' do + let(:payment_request) { create(:payment_request, payment_status: :succeeded) } + + it 'does not add an error' do + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + end + + context 'when amount cents + total paid amount cents is greater than invoice total amount cents' do + let(:amount_cents) { 10001 } + + it 'adds an error' do + expect(errors.where(:amount_cents, :greater_than)).to be_present + end + end + end + end + end + + describe 'of payment request succeeded' do + context 'when payable is an invoice' do + context 'when payment type is provider' do + let(:payment_type) { 'provider' } + + context 'when succeeded payment requests exist' do + let(:payment_request) { create(:payment_request, payment_status: :succeeded) } + + before do + create(:payment_request_applied_invoice, payment_request:, invoice: payable) + payment.save + end + + it 'does not add an error' do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + + context 'when no succeeded payment requests exist' do + before { payment.save } + + it 'does not add an error' do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + end + + context 'when payment type is manual' do + let(:payment_type) { 'manual' } + + context 'when succeeded payment request exist' do + let(:payment_request) { create(:payment_request, payment_status: 'succeeded') } + + before do + create(:payment_request_applied_invoice, payment_request:, invoice: payable) + payment.save + end + + it 'adds an error' do + expect(payment.errors.where(:base, :payment_request_is_already_succeeded)).to be_present + end + end + + context 'when no succeeded payment requests exist' do + before { payment.save } + + it 'does not add an error' do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + end + end + + context 'when payable is not an invoice' do + let(:payable) { create(:payment_request) } + + context 'when payment type is provider' do + let(:payment_type) { 'provider' } + + before { payment.save } + + it 'does not add an error' do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + + context 'when payment type is manual' do + let(:payment_type) { 'manual' } + + before { payment.save } + + it 'does not add an error' do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + end + end + describe 'of reference' do context 'when payment type is provider' do context 'when reference is present' do diff --git a/spec/services/manual_payments/create_service_spec.rb b/spec/services/manual_payments/create_service_spec.rb new file mode 100644 index 00000000000..f8a0116e806 --- /dev/null +++ b/spec/services/manual_payments/create_service_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ManualPayments::CreateService, type: :service do + subject(:service) { described_class.new(invoice:, params:) } + + let(:invoice) { create(:invoice, customer:, organization:, total_amount_cents: 10000, status: :finalized) } + let(:organization) { create(:organization, premium_integrations:) } + let(:customer) { create(:customer, organization:) } + let(:params) { {amount_cents:, reference: "ref1"} } + let(:amount_cents) { 10000 } + + describe "#call" do + context "when organization is not premium" do + let(:premium_integrations) { %w[] } + + it "returns forbidden failure" do + result = service.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + end + + context "when organization is premium" do + around { |test| lago_premium!(&test) } + + context "with the premium_integration disabled" do + let(:premium_integrations) { %w[] } + + it "returns forbidden failure" do + result = service.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + end + + context "with the premium_integration enabled" do + let(:premium_integrations) { %w[manual_payments] } + + context "when invoice does not exist" do + let(:invoice) { nil } + + it "returns not found failure" do + result = service.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + end + + context "when invoice's payment request is succeeded" do + let(:payment_request) { create(:payment_request, payment_status: "succeeded") } + + before do + create(:payment_request_applied_invoice, invoice:, payment_request:) + end + + it "returns validation failure" do + result = service.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + context "when payment amount cents is greater than invoice's remaining amount cents" do + let(:amount_cents) { 10001 } + + it "returns validation failure" do + result = service.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + context "when payment amount cents is smaller than invoice remaining amount cents" do + let(:amount_cents) { 2000 } + + it "creates a payment" do + result = service.call + + expect(result).to be_success + expect(result.payment.payment_type).to eq("manual") + end + + it "updates invoice's total paid amount cents" do + expect { service.call }.to change(invoice, :total_paid_amount_cents).from(0).to(amount_cents) + end + + context "when there is an integration customer" do + let(:integration) do + create( + :netsuite_integration, + organization:, + settings: { + account_id: "acc_12345", + client_id: "cli_12345", + script_endpoint_url: Faker::Internet.url, + sync_payments: true + } + ) + end + + before { create(:netsuite_customer, integration:, customer:) } + + it "enqueues an aggregator payment job" do + expect { service.call }.to have_enqueued_job(Integrations::Aggregator::Payments::CreateJob) + end + end + end + + context "when payment amount cents is equal to invoice remaining amount cents" do + let(:amount_cents) { 10000 } + + it "creates a payment" do + result = service.call + + expect(result).to be_success + expect(result.payment.payment_type).to eq("manual") + end + + it "updates invoice's total paid amount cents" do + expect { service.call }.to change(invoice, :total_paid_amount_cents).from(0).to(amount_cents) + end + + it "updates invoice's payment status to suceeded" do + result = service.call + + aggregate_failures do + expect(result.payment.payable.payment_status).to eq("succeeded") + expect(result.payment.payable_payment_status).to eq("succeeded") + end + end + end + end + end + end +end