diff --git a/app/services/integrations/aggregator/base_service.rb b/app/services/integrations/aggregator/base_service.rb index c45f4234f4e..4c8446b8c93 100644 --- a/app/services/integrations/aggregator/base_service.rb +++ b/app/services/integrations/aggregator/base_service.rb @@ -86,8 +86,8 @@ def fallback_item @fallback_item ||= collection_mapping(:fallback_item) end - def amount(amount_cents) - currency = invoice.total_amount.currency + def amount(amount_cents, resource:) + currency = resource.total_amount.currency amount_cents.round.fdiv(currency.subunit_to_unit) end diff --git a/app/services/integrations/aggregator/credit_notes/create_service.rb b/app/services/integrations/aggregator/credit_notes/create_service.rb new file mode 100644 index 00000000000..9a959dcfc36 --- /dev/null +++ b/app/services/integrations/aggregator/credit_notes/create_service.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module CreditNotes + class CreateService < Integrations::Aggregator::Invoices::BaseService + def initialize(credit_note:) + @credit_note = credit_note + + super(invoice:) + end + + def action_path + "v1/#{provider}/creditnotes" + end + + def call + return result unless integration + return result unless integration.sync_credit_notes + return result unless credit_note.finalized? + + response = http_client.post_with_response(payload, headers) + result.external_id = JSON.parse(response.body) + + IntegrationResource.create!( + integration:, + external_id: result.external_id, + syncable_id: credit_note.id, + syncable_type: 'CreditNote', + resource_type: :credit_note, + ) + + result + rescue LagoHttpClient::HttpError => e + error = e.json_message + code = error['type'] + message = error.dig('payload', 'message') + + deliver_error_webhook(customer:, code:, message:) + + raise e + end + + def call_async + return result.not_found_failure!(resource: 'credit_note') unless credit_note + + ::Integrations::Aggregator::CreditNotes::CreateJob.perform_later(credit_note:) + + result.credit_note_id = credit_note.id + result + end + + private + + attr_reader :credit_note + + delegate :customer, to: :credit_note, allow_nil: true + delegate :invoice, to: :credit_note + + def coupons + output = [] + + if credit_note.coupons_adjustment_amount_cents > 0 + output << { + 'item' => coupon_item&.external_id, + 'account' => coupon_item&.external_account_code, + 'quantity' => 1, + 'rate' => -amount(credit_note.coupons_adjustment_amount_cents, resource: credit_note) + } + end + + output + end + + def payload + { + 'type' => 'creditmemo', + 'isDynamic' => true, + 'columns' => { + 'tranid' => credit_note.number, + 'entity' => integration_customer.external_customer_id, + 'istaxable' => true, + 'taxitem' => tax_item.external_id, + 'taxamountoverride' => amount(credit_note.taxes_amount_cents, resource: credit_note), + 'otherrefnum' => credit_note.number, + 'custbody_lago_id' => credit_note.id, + 'tranId' => credit_note.id + }, + 'lines' => [ + { + 'sublistId' => 'item', + 'lineItems' => credit_note.items.map { |item| item(item.fee) } + coupons + } + ], + 'options' => { + 'ignoreMandatoryFields' => false + } + } + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/base_service.rb b/app/services/integrations/aggregator/invoices/base_service.rb index 99b4ed1a175..76fb8049081 100644 --- a/app/services/integrations/aggregator/invoices/base_service.rb +++ b/app/services/integrations/aggregator/invoices/base_service.rb @@ -77,7 +77,7 @@ def discounts 'item' => coupon_item.external_id, 'account' => coupon_item.external_account_code, 'quantity' => 1, - 'rate' => -amount(invoice.coupons_amount_cents) + 'rate' => -amount(invoice.coupons_amount_cents, resource: invoice) } end @@ -86,7 +86,7 @@ def discounts 'item' => credit_item.external_id, 'account' => credit_item.external_account_code, 'quantity' => 1, - 'rate' => -amount(invoice.prepaid_credit_amount_cents) + 'rate' => -amount(invoice.prepaid_credit_amount_cents, resource: invoice) } end @@ -95,7 +95,7 @@ def discounts 'item' => credit_note_item.external_id, 'account' => credit_note_item.external_account_code, 'quantity' => 1, - 'rate' => -amount(invoice.credit_notes_amount_cents) + 'rate' => -amount(invoice.credit_notes_amount_cents, resource: invoice) } end @@ -111,7 +111,7 @@ def payload(type) 'entity' => integration_customer.external_customer_id, 'istaxable' => true, 'taxitem' => tax_item&.external_id, - 'taxamountoverride' => amount(invoice.taxes_amount_cents), + 'taxamountoverride' => amount(invoice.taxes_amount_cents, resource: invoice), 'otherrefnum' => invoice.number, 'custbody_lago_id' => invoice.id, 'custbody_ava_disable_tax_calculation' => true diff --git a/spec/fixtures/integration_aggregator/credit_notes/success_response.json b/spec/fixtures/integration_aggregator/credit_notes/success_response.json new file mode 100644 index 00000000000..063c4171ba0 --- /dev/null +++ b/spec/fixtures/integration_aggregator/credit_notes/success_response.json @@ -0,0 +1 @@ +"456" diff --git a/spec/services/integrations/aggregator/credit_notes/create_service_spec.rb b/spec/services/integrations/aggregator/credit_notes/create_service_spec.rb new file mode 100644 index 00000000000..ea8135cfebb --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/create_service_spec.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::Aggregator::CreditNotes::CreateService do + subject(:service_call) { described_class.call(credit_note: credit_note.reload) } + + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { 'https://api.nango.dev/v1/netsuite/creditnotes' } + let(:add_on) { create(:add_on, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:) } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: '1', external_account_code: '11', external_name: ''} + ) + end + let(:integration_collection_mapping2) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :coupon, + settings: {external_id: '2', external_account_code: '22', external_name: ''} + ) + end + let(:integration_collection_mapping3) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :subscription_fee, + settings: {external_id: '3', external_account_code: '33', external_name: ''} + ) + end + let(:integration_collection_mapping4) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :minimum_commitment, + settings: {external_id: '4', external_account_code: '44', external_name: ''} + ) + end + let(:integration_collection_mapping5) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :tax, + settings: {external_id: '5', external_account_code: '55', external_name: ''} + ) + end + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: 'AddOn', + mappable_id: add_on.id, + settings: {external_id: 'm1', external_account_code: 'm11', external_name: ''} + ) + end + let(:integration_mapping_bm) do + create( + :netsuite_mapping, + integration:, + mappable_type: 'BillableMetric', + mappable_id: billable_metric.id, + settings: {external_id: 'm2', external_account_code: 'm22', external_name: ''} + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 8000, + ) + end + let(:credit_note) do + create( + :credit_note, + customer:, + invoice:, + status: 'finalized', + organization:, + coupons_adjustment_amount_cents: 2000, + taxes_amount_cents: 8000, + ) + end + let(:fee_sub) do + create( + :fee, + invoice:, + ) + end + let(:minimum_commitment_fee) do + create( + :minimum_commitment_fee, + invoice:, + ) + end + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + units: 2, + precise_unit_amount: 4.12, + ) + end + + let(:credit_note_item1) { create(:credit_note_item, credit_note:, fee: fee_sub) } + let(:credit_note_item2) { create(:credit_note_item, credit_note:, fee: minimum_commitment_fee) } + let(:credit_note_item3) { create(:credit_note_item, credit_note:, fee: charge_fee) } + + let(:headers) do + { + 'Connection-Id' => integration.connection_id, + 'Authorization' => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + 'Provider-Config-Key' => 'netsuite' + } + end + + let(:params) do + { + 'type' => 'creditmemo', + 'isDynamic' => true, + 'columns' => { + 'tranid' => credit_note.number, + 'entity' => integration_customer.external_customer_id, + 'istaxable' => true, + 'taxitem' => integration_collection_mapping5.external_id, + 'taxamountoverride' => 80.0, + 'otherrefnum' => credit_note.number, + 'custbody_lago_id' => credit_note.id, + 'tranId' => credit_note.id + }, + 'lines' => [ + { + 'sublistId' => 'item', + 'lineItems' => [ + { + 'item' => '3', + 'account' => '33', + 'quantity' => 0.0, + 'rate' => 0.0 + }, + { + 'item' => '4', + 'account' => '44', + 'quantity' => 0.0, + 'rate' => 0.0 + }, + { + 'item' => 'm2', + 'account' => 'm22', + 'quantity' => 2, + 'rate' => 4.12 + }, + { + 'item' => '2', + 'account' => '22', + 'quantity' => 1, + 'rate' => -20.0 + } + ] + } + ], + 'options' => { + 'ignoreMandatoryFields' => false + } + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + + integration_customer + charge + credit_note + integration_collection_mapping1 + integration_collection_mapping2 + integration_collection_mapping3 + integration_collection_mapping4 + integration_collection_mapping5 + integration_mapping_add_on + integration_mapping_bm + fee_sub + minimum_commitment_fee + charge_fee + + if credit_note + credit_note_item1 + credit_note_item2 + credit_note_item3 + credit_note.reload + end + + integration.sync_credit_notes = true + integration.save! + end + + # TODO: uncomment after jobs are merged + # describe '#call_async' do + # subject(:service_call_async) { described_class.new(credit_note:).call_async } + + # context 'when credit_note exists' do + # it 'enqueues credit_note create job' do + # expect { service_call_async }.to enqueue_job(Integrations::Aggregator::CreditNotes::CreateJob) + # end + # end + + # context 'when credit_note does not exist' do + # let(:credit_note) { nil } + + # it 'returns an error' do + # result = service_call_async + + # aggregate_failures do + # expect(result).not_to be_success + # expect(result.error.error_code).to eq('credit_note_not_found') + # end + # end + # end + # end + + describe '#call' do + context 'when service call is successful' do + let(:response) { instance_double(Net::HTTPOK) } + + let(:body) do + path = Rails.root.join('spec/fixtures/integration_aggregator/credit_notes/success_response.json') + File.read(path) + end + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it 'returns external id' do + result = service_call + + aggregate_failures do + expect(result).to be_success + expect(result.external_id).to eq('456') + end + end + + it 'creates integration resource object' do + expect { service_call } + .to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(credit_note.id) + expect(integration_resource.syncable_type).to eq('CreditNote') + expect(integration_resource.resource_type).to eq('credit_note') + end + end + + context 'when service call is not successful' do + let(:body) do + path = Rails.root.join('spec/fixtures/integration_aggregator/error_response.json') + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(500, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + it 'returns an error' do + expect do + service_call + end.to raise_error(http_error) + end + + it 'enqueues a SendWebhookJob' do + expect { service_call } + .to have_enqueued_job(SendWebhookJob) + .and raise_error(http_error) + end + end + end +end