From eb7e4cd4088a5402fcb330d03b4ae1dda9f4c36c Mon Sep 17 00:00:00 2001 From: LovroColic Date: Mon, 8 Jul 2024 11:36:46 +0200 Subject: [PATCH] feat (tax-integrations): Add anrok fetch services (#2247) ## Context Currently, integration with tax provider Anrok is being added to the Lago app ## Description This PR adds logic for feching tax data from Anrok through aggregator called Nango. There are two types of fetching data for invoices: tax data for finalized invoices and tax data for draft invoices. Those two types will be used for creating one-off invoices --- .../anrok/fetch_draft_invoice_taxes.rb | 60 ++++ .../anrok_objects/breakdown_object.rb | 20 ++ .../integrations/anrok_objects/fee_object.rb | 18 + app/graphql/types/mutation_type.rb | 2 + .../integrations/aggregator/base_payload.rb | 2 +- .../integrations/aggregator/base_service.rb | 4 + .../aggregator/taxes/invoices/base_service.rb | 78 +++++ .../taxes/invoices/create_draft_service.rb | 44 +++ .../taxes/invoices/create_service.rb | 49 +++ .../aggregator/taxes/invoices/payload.rb | 54 +++ schema.graphql | 43 +++ schema.json | 321 ++++++++++++++++++ .../taxes/invoices/failure_response.json | 29 ++ .../taxes/invoices/success_response.json | 33 ++ .../anrok/fetch_draft_invoice_taxes_spec.rb | 125 +++++++ .../invoices/create_draft_service_spec.rb | 181 ++++++++++ .../taxes/invoices/create_service_spec.rb | 182 ++++++++++ 17 files changed, 1244 insertions(+), 1 deletion(-) create mode 100644 app/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes.rb create mode 100644 app/graphql/types/integrations/anrok_objects/breakdown_object.rb create mode 100644 app/graphql/types/integrations/anrok_objects/fee_object.rb create mode 100644 app/services/integrations/aggregator/taxes/invoices/base_service.rb create mode 100644 app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb create mode 100644 app/services/integrations/aggregator/taxes/invoices/create_service.rb create mode 100644 app/services/integrations/aggregator/taxes/invoices/payload.rb create mode 100644 spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json create mode 100644 spec/fixtures/integration_aggregator/taxes/invoices/success_response.json create mode 100644 spec/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes_spec.rb create mode 100644 spec/services/integrations/aggregator/taxes/invoices/create_draft_service_spec.rb create mode 100644 spec/services/integrations/aggregator/taxes/invoices/create_service_spec.rb diff --git a/app/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes.rb b/app/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes.rb new file mode 100644 index 00000000000..2a714b795e5 --- /dev/null +++ b/app/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Anrok + class FetchDraftInvoiceTaxes < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = 'invoices:create' + + description 'Fetches taxes for one-off invoice' + + input_object_class Types::Invoices::CreateInvoiceInput + + type Types::Integrations::AnrokObjects::FeeObject.collection_type + + def resolve(**args) + customer = Customer.find_by( + id: args[:customer_id], + organization_id: current_organization.id + ) + + result = ::Integrations::Aggregator::Taxes::Invoices::CreateDraftService.new( + invoice: invoice(customer, args), + fees: fees(args) + ).call + + result.success? ? result.fees : result_error(result) + end + + private + + # Note: We need to pass invoice object to the service that return taxes. This service should + # work with real invoice objects. In this case, it should also work with invoice that is not stored yet, + # because we need to fetch taxes for one-off invoice UI form. + def invoice(customer, args) + OpenStruct.new( + issuing_date: Time.current.in_time_zone(customer.applicable_timezone).to_date, + currency: args[:currency], + customer: + ) + end + + def fees(args) + args[:fees].map do |fee| + unit_amount_cents = fee[:unit_amount_cents] + units = fee[:units]&.to_f || 1 + + OpenStruct.new( + add_on_id: fee[:add_on_id], + item_id: fee[:add_on_id], + amount_cents: (unit_amount_cents * units).round + ) + end + end + end + end + end +end diff --git a/app/graphql/types/integrations/anrok_objects/breakdown_object.rb b/app/graphql/types/integrations/anrok_objects/breakdown_object.rb new file mode 100644 index 00000000000..7fa16b94284 --- /dev/null +++ b/app/graphql/types/integrations/anrok_objects/breakdown_object.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Integrations + module AnrokObjects + class BreakdownObject < Types::BaseObject + graphql_name 'AnrokBreakdownObject' + + field :name, String, null: true + field :rate, GraphQL::Types::Float, null: true + field :tax_amount, GraphQL::Types::BigInt, null: true + field :type, String, null: true + + def rate + BigDecimal(object.rate) + end + end + end + end +end diff --git a/app/graphql/types/integrations/anrok_objects/fee_object.rb b/app/graphql/types/integrations/anrok_objects/fee_object.rb new file mode 100644 index 00000000000..c6b4188137d --- /dev/null +++ b/app/graphql/types/integrations/anrok_objects/fee_object.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Integrations + module AnrokObjects + class FeeObject < Types::BaseObject + graphql_name 'AnrokFeeObject' + + field :amount_cents, GraphQL::Types::BigInt, null: true + field :item_code, String, null: true + field :item_id, String, null: true + field :tax_amount_cents, GraphQL::Types::BigInt, null: true + + field :tax_breakdown, [Types::Integrations::AnrokObjects::BreakdownObject] + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index d1cf3de56f2..c6b6d340938 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -125,6 +125,8 @@ class MutationType < Types::BaseObject field :create_anrok_integration, mutation: Mutations::Integrations::Anrok::Create field :update_anrok_integration, mutation: Mutations::Integrations::Anrok::Update + field :fetch_draft_invoice_taxes, mutation: Mutations::Integrations::Anrok::FetchDraftInvoiceTaxes + field :create_xero_integration, mutation: Mutations::Integrations::Xero::Create field :update_xero_integration, mutation: Mutations::Integrations::Xero::Update diff --git a/app/services/integrations/aggregator/base_payload.rb b/app/services/integrations/aggregator/base_payload.rb index 0af8a5fdfb9..43cdf46eff7 100644 --- a/app/services/integrations/aggregator/base_payload.rb +++ b/app/services/integrations/aggregator/base_payload.rb @@ -16,7 +16,7 @@ def billable_metric_item(fee) def add_on_item(fee) integration .integration_mappings - .find_by(mappable_type: 'AddOn', mappable_id: fee.add_on.id) || fallback_item + .find_by(mappable_type: 'AddOn', mappable_id: fee.add_on_id) || fallback_item end def tax_item diff --git a/app/services/integrations/aggregator/base_service.rb b/app/services/integrations/aggregator/base_service.rb index b366291f8e0..3c85a2969d6 100644 --- a/app/services/integrations/aggregator/base_service.rb +++ b/app/services/integrations/aggregator/base_service.rb @@ -29,6 +29,8 @@ def provider 'netsuite' when 'Integrations::XeroIntegration' 'xero' + when 'Integrations::AnrokIntegration' + 'anrok' end end @@ -38,6 +40,8 @@ def provider_key 'netsuite-tba' when 'Integrations::XeroIntegration' 'xero' + when 'Integrations::AnrokIntegration' + 'anrok' end end diff --git a/app/services/integrations/aggregator/taxes/invoices/base_service.rb b/app/services/integrations/aggregator/taxes/invoices/base_service.rb new file mode 100644 index 00000000000..b1e423138d8 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/base_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class BaseService < Integrations::Aggregator::BaseService + def initialize(invoice:, fees: nil) + @invoice = invoice + @fees = fees || invoice.fees + + super(integration:) + end + + private + + attr_reader :invoice, :fees + + delegate :customer, to: :invoice, allow_nil: true + + def integration + return nil unless integration_customer + + integration_customer&.integration + end + + def integration_customer + @integration_customer ||= + customer + .integration_customers + .where(type: 'IntegrationCustomers::AnrokCustomer') + .first + end + + def headers + { + 'Connection-Id' => integration.connection_id, + 'Authorization' => "Bearer #{secret_key}", + 'Provider-Config-Key' => provider_key + } + end + + def process_response(body) + fees = body['succeededInvoices']&.first.try(:[], 'fees') + + if fees + result.fees = fees.map do |fee| + OpenStruct.new( + item_id: fee['item_id'], + item_code: fee['item_code'], + amount_cents: fee['amount_cents'], + tax_amount_cents: fee['tax_amount_cents'], + tax_breakdown: tax_breakdown(fee['tax_breakdown']) + ) + end + else + code = body['failedInvoices'].first['validation_errors']['type'] + message = 'Service failure' + + result.service_failure!(code:, message:) + end + end + + def tax_breakdown(breakdown) + breakdown.map do |b| + OpenStruct.new( + name: b['name'], + rate: b['rate'], + tax_amount: b['tax_amount'], + type: b['type'] + ) + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb b/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb new file mode 100644 index 00000000000..ed8f4514351 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class CreateDraftService < BaseService + def action_path + "v1/#{provider}/draft_invoices" + end + + def call + return result unless integration + return result unless integration.type == 'Integrations::AnrokIntegration' + + response = http_client.post_with_response(payload, headers) + body = JSON.parse(response.body) + + process_response(body) + + result + rescue LagoHttpClient::HttpError => e + error = e.json_message + code = error['type'] + message = error.dig('payload', 'message') + + result.service_failure!(code:, message:) + end + + private + + def payload + Integrations::Aggregator::Taxes::Invoices::Payload.new( + integration:, + invoice:, + customer:, + fees: + ).body + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/create_service.rb b/app/services/integrations/aggregator/taxes/invoices/create_service.rb new file mode 100644 index 00000000000..2ff3227ab63 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/create_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class CreateService < BaseService + def action_path + "v1/#{provider}/finalized_invoices" + end + + def call + return result unless integration + return result unless integration.type == 'Integrations::AnrokIntegration' + + response = http_client.post_with_response(payload, headers) + body = JSON.parse(response.body) + + process_response(body) + + result + rescue LagoHttpClient::HttpError => e + error = e.json_message + code = error['type'] + message = error.dig('payload', 'message') + + result.service_failure!(code:, message:) + end + + private + + def payload + payload_body = Integrations::Aggregator::Taxes::Invoices::Payload.new( + integration:, + invoice:, + customer:, + fees: + ).body + + invoice_data = payload_body.first + invoice_data['id'] = invoice.id + + [invoice_data] + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/payload.rb b/app/services/integrations/aggregator/taxes/invoices/payload.rb new file mode 100644 index 00000000000..8abe0e52d25 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/payload.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class Payload < BasePayload + def initialize(integration:, customer:, invoice:, fees: []) + super(integration:) + + @customer = customer + @invoice = invoice + @fees = fees.is_a?(Array) ? fees : fees.order(created_at: :asc) + end + + def body + [ + { + 'issuing_date' => invoice.issuing_date, + 'currency' => invoice.currency, + 'contact' => { + 'external_id' => customer.external_id, + 'name' => customer.name, + 'address_line_1' => customer.shipping_address_line1, + 'city' => customer.shipping_city, + 'zip' => customer.shipping_zipcode, + 'country' => customer.shipping_country, + 'taxable' => customer.tax_identification_number.present?, + 'tax_number' => customer.tax_identification_number + }, + 'fees' => fees.map { |fee| fee_item(fee) } + } + ] + end + + def fee_item(fee) + # TODO: Update later with other fee types + mapped_item = add_on_item(fee) + + { + 'item_id' => fee.item_id, + 'item_code' => mapped_item.external_id, + 'amount_cents' => fee.amount_cents + } + end + + private + + attr_reader :customer, :invoice, :fees + end + end + end + end +end diff --git a/schema.graphql b/schema.graphql index 3df46be908d..b2518944b4c 100644 --- a/schema.graphql +++ b/schema.graphql @@ -126,6 +126,13 @@ enum AggregationTypeEnum { weighted_sum_agg } +type AnrokBreakdownObject { + name: String + rate: Float + taxAmount: BigInt + type: String +} + type AnrokCustomer { externalCustomerId: String id: ID! @@ -135,6 +142,19 @@ type AnrokCustomer { syncWithProvider: Boolean } +type AnrokFeeObject { + amountCents: BigInt + itemCode: String + itemId: String + taxAmountCents: BigInt + taxBreakdown: [AnrokBreakdownObject!] +} + +type AnrokFeeObjectCollection { + collection: [AnrokFeeObject!]! + metadata: CollectionMetadata! +} + type AnrokIntegration { apiKey: String! code: String! @@ -3558,6 +3578,19 @@ enum FeeTypesEnum { subscription } +""" +Create Invoice input arguments +""" +input FetchDraftInvoiceTaxesInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + currency: CurrencyEnum + customerId: ID! + fees: [FeeInput!]! +} + """ Autogenerated input type of FetchIntegrationItems """ @@ -4501,6 +4534,16 @@ type Mutation { input: DownloadInvoiceInput! ): Invoice + """ + Fetches taxes for one-off invoice + """ + fetchDraftInvoiceTaxes( + """ + Parameters for FetchDraftInvoiceTaxes + """ + input: FetchDraftInvoiceTaxesInput! + ): AnrokFeeObjectCollection + """ Fetch integration items """ diff --git a/schema.json b/schema.json index d5816b57357..6c202814959 100644 --- a/schema.json +++ b/schema.json @@ -1037,6 +1037,75 @@ } ] }, + { + "kind": "OBJECT", + "name": "AnrokBreakdownObject", + "description": null, + "interfaces": [ + + ], + "possibleTypes": null, + "fields": [ + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "rate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "taxAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "type", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + } + ], + "inputFields": null, + "enumValues": null + }, { "kind": "OBJECT", "name": "AnrokCustomer", @@ -1138,6 +1207,154 @@ "inputFields": null, "enumValues": null }, + { + "kind": "OBJECT", + "name": "AnrokFeeObject", + "description": null, + "interfaces": [ + + ], + "possibleTypes": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "itemCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "itemId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "taxAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "taxBreakdown", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AnrokBreakdownObject", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + } + ], + "inputFields": null, + "enumValues": null + }, + { + "kind": "OBJECT", + "name": "AnrokFeeObjectCollection", + "description": null, + "interfaces": [ + + ], + "possibleTypes": null, + "fields": [ + { + "name": "collection", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AnrokFeeObject", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + } + ], + "inputFields": null, + "enumValues": null + }, { "kind": "OBJECT", "name": "AnrokIntegration", @@ -16020,6 +16237,81 @@ } ] }, + { + "kind": "INPUT_OBJECT", + "name": "FetchDraftInvoiceTaxesInput", + "description": "Create Invoice input arguments", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fees", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FeeInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "INPUT_OBJECT", "name": "FetchIntegrationItemsInput", @@ -22049,6 +22341,35 @@ } ] }, + { + "name": "fetchDraftInvoiceTaxes", + "description": "Fetches taxes for one-off invoice", + "type": { + "kind": "OBJECT", + "name": "AnrokFeeObjectCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for FetchDraftInvoiceTaxes", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FetchDraftInvoiceTaxesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "fetchIntegrationItems", "description": "Fetch integration items", diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json new file mode 100644 index 00000000000..c2149976633 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json @@ -0,0 +1,29 @@ +{ + "succeededInvoices": [], + "failedInvoices": [ + { + "issuing_date": "2024-12-03", + "currency": "EUR", + "contact": { + "external_id": "sync-1-test-1", + "name": "sync-1-test-1", + "address_line_1": "88 rue du Chemin Vert", + "city": "Paris", + "zip": "75011", + "country": "fr", + "taxable": true, + "tax_number": "FR86894827773" + }, + "fees": [ + { + "item_id": "sync-1", + "item_code": "lago_default_b2b", + "amount_cents": 2000 + } + ], + "validation_errors": { + "type": "taxDateTooFarInFuture" + } + } + ] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/success_response.json b/spec/fixtures/integration_aggregator/taxes/invoices/success_response.json new file mode 100644 index 00000000000..9a519112eed --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/success_response.json @@ -0,0 +1,33 @@ +{ + "succeededInvoices": [ + { + "issuing_date": "2024-03-07", + "sub_total_excluding_taxes": 9900, + "taxes_amount_cents": 99, + "currency": "USD", + "contact": { + "external_id": "cus_lago_12345", + "name": "John Doe", + "taxable": true, + "tax_number": "1234567890" + }, + "fees": [ + { + "item_id": "lago_fee_id", + "item_code": "lago_default_b2b", + "amount_cents": 9900, + "tax_amount_cents": 990, + "tax_breakdown": [ + { + "name": "GST/HST", + "rate": "0.10", + "tax_amount": 990, + "type": "tax_exempt" + } + ] + } + ] + } + ], + "failedInvoices": [] +} diff --git a/spec/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes_spec.rb b/spec/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes_spec.rb new file mode 100644 index 00000000000..170734e6113 --- /dev/null +++ b/spec/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Integrations::Anrok::FetchDraftInvoiceTaxes, type: :graphql do + let(:required_permission) { 'invoices:create' } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:currency) { 'EUR' } + let(:customer) { create(:customer, organization:) } + let(:add_on_first) { create(:add_on, organization:) } + let(:add_on_second) { create(:add_on, amount_cents: 400, organization:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { 'https://api.nango.dev/v1/anrok/draft_invoices' } + let(:fees) do + [ + { + addOnId: add_on_first.id, + unitAmountCents: 1200, + units: 2, + description: 'desc-123', + invoiceDisplayName: 'fee-123' + }, + { + addOnId: add_on_second.id, + unitAmountCents: 400, + units: 1, + description: 'desc-12345', + invoiceDisplayName: 'fee-12345' + } + ] + end + 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_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: 'AddOn', + mappable_id: add_on_first.id, + settings: {external_id: 'm1', external_account_code: 'm11', external_name: ''} + ) + end + let(:body) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response.json') + File.read(path) + end + let(:mutation) do + <<-GQL + mutation($input: FetchDraftInvoiceTaxesInput!) { + fetchDraftInvoiceTaxes(input: $input) { + collection { + itemId + itemCode + amountCents + taxAmountCents + taxBreakdown { + name + rate + taxAmount + type + } + } + } + } + GQL + end + + before do + integration_customer + integration_collection_mapping1 + integration_mapping_add_on + + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it_behaves_like 'requires current user' + it_behaves_like 'requires current organization' + it_behaves_like 'requires permission', 'invoices:create' + + it 'fetches tax results' do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + currency:, + fees: + } + } + ) + + result_data = result['data']['fetchDraftInvoiceTaxes']['collection'] + + aggregate_failures do + fee = result_data.first + + expect(fee['itemId']).to eq('lago_fee_id') + expect(fee['itemCode']).to eq('lago_default_b2b') + expect(fee['amountCents']).to eq('9900') + expect(fee['taxAmountCents']).to eq('990') + + breakdown = fee['taxBreakdown'].first + + expect(breakdown['name']).to eq('GST/HST') + expect(breakdown['rate']).to eq(0.1) + expect(breakdown['taxAmount']).to eq('990') + expect(breakdown['type']).to eq('tax_exempt') + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/invoices/create_draft_service_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/create_draft_service_spec.rb new file mode 100644 index 00000000000..fb9c5efd804 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/invoices/create_draft_service_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::Aggregator::Taxes::Invoices::CreateDraftService do + subject(:service_call) { described_class.call(invoice:) } + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_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/anrok/draft_invoices' } + let(:add_on) { create(:add_on, organization:) } + let(:add_on_two) { create(:add_on, organization:) } + let(:current_time) { Time.current } + + 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_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(:invoice) do + create( + :invoice, + customer:, + organization: + ) + end + let(:fee_add_on) do + create( + :fee, + invoice:, + add_on:, + created_at: current_time - 3.seconds + ) + end + let(:fee_add_on_two) do + create( + :fee, + invoice:, + add_on: add_on_two, + created_at: current_time - 2.seconds + ) + end + + let(:headers) do + { + 'Connection-Id' => integration.connection_id, + 'Authorization' => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + 'Provider-Config-Key' => 'anrok' + } + end + + let(:params) do + [ + { + 'issuing_date' => invoice.issuing_date, + 'currency' => invoice.currency, + 'contact' => { + 'external_id' => customer.external_id, + 'name' => customer.name, + 'address_line_1' => customer.shipping_address_line1, + 'city' => customer.shipping_city, + 'zip' => customer.shipping_zipcode, + 'country' => customer.shipping_country, + 'taxable' => false, + 'tax_number' => nil + }, + 'fees' => [ + { + 'item_id' => fee_add_on.item_id, + 'item_code' => 'm1', + 'amount_cents' => 200 + }, + { + 'item_id' => fee_add_on_two.item_id, + 'item_code' => '1', + 'amount_cents' => 200 + } + ] + } + ] + end + + before do + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + + integration_customer + integration_collection_mapping1 + integration_mapping_add_on + fee_add_on + fee_add_on_two + end + + describe '#call' do + context 'when service call is successful' do + let(:response) { instance_double(Net::HTTPOK) } + + 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 + + context 'when taxes are successfully fetched' do + let(:body) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response.json') + File.read(path) + end + + it 'returns fees' do + result = service_call + + aggregate_failures do + expect(result).to be_success + expect(result.fees.first['tax_breakdown'].first['rate']).to eq('0.10') + end + end + end + + context 'when taxes are not successfully fetched' do + let(:body) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json') + File.read(path) + end + + it 'does not return fees' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq('taxDateTooFarInFuture') + end + end + 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(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context 'when it is a server error' do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it 'returns an error' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq('action_script_runtime_error') + end + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/invoices/create_service_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/create_service_spec.rb new file mode 100644 index 00000000000..4ec655d6243 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/invoices/create_service_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Integrations::Aggregator::Taxes::Invoices::CreateService do + subject(:service_call) { described_class.call(invoice:) } + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_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/anrok/finalized_invoices' } + let(:add_on) { create(:add_on, organization:) } + let(:add_on_two) { create(:add_on, organization:) } + let(:current_time) { Time.current } + + 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_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(:invoice) do + create( + :invoice, + customer:, + organization: + ) + end + let(:fee_add_on) do + create( + :fee, + invoice:, + add_on:, + created_at: current_time - 3.seconds + ) + end + let(:fee_add_on_two) do + create( + :fee, + invoice:, + add_on: add_on_two, + created_at: current_time - 2.seconds + ) + end + + let(:headers) do + { + 'Connection-Id' => integration.connection_id, + 'Authorization' => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + 'Provider-Config-Key' => 'anrok' + } + end + + let(:params) do + [ + { + 'id' => invoice.id, + 'issuing_date' => invoice.issuing_date, + 'currency' => invoice.currency, + 'contact' => { + 'external_id' => customer.external_id, + 'name' => customer.name, + 'address_line_1' => customer.shipping_address_line1, + 'city' => customer.shipping_city, + 'zip' => customer.shipping_zipcode, + 'country' => customer.shipping_country, + 'taxable' => false, + 'tax_number' => nil + }, + 'fees' => [ + { + 'item_id' => fee_add_on.item_id, + 'item_code' => 'm1', + 'amount_cents' => 200 + }, + { + 'item_id' => fee_add_on_two.item_id, + 'item_code' => '1', + 'amount_cents' => 200 + } + ] + } + ] + end + + before do + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + + integration_customer + integration_collection_mapping1 + integration_mapping_add_on + fee_add_on + fee_add_on_two + end + + describe '#call' do + context 'when service call is successful' do + let(:response) { instance_double(Net::HTTPOK) } + + 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 + + context 'when taxes are successfully fetched for finalized invoice' do + let(:body) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/success_response.json') + File.read(path) + end + + it 'returns fees' do + result = service_call + + aggregate_failures do + expect(result).to be_success + expect(result.fees.first['tax_breakdown'].first['rate']).to eq('0.10') + end + end + end + + context 'when taxes are not successfully fetched for finalized invoice' do + let(:body) do + path = Rails.root.join('spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json') + File.read(path) + end + + it 'does not return fees' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq('taxDateTooFarInFuture') + end + end + 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(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context 'when it is a server error' do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it 'returns an error' do + result = service_call + + aggregate_failures do + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq('action_script_runtime_error') + end + end + end + end + end +end