Skip to content

Commit

Permalink
Feat hubspot customers services (#2674)
Browse files Browse the repository at this point in the history
## Roadmap Task

👉  https://getlago.canny.io/feature-requests/p/integration-with-hubspot

## Context

When creating or updating customers in Lago, we must sync them to
Hubspot.
Depending on if Lago customer is either company or individual, we need
to call different services (Nango endpoints).

## Description

This PR implements services that handle syncing the customers either as
companies or contacts to Hubspot via Nango.
  • Loading branch information
ivannovosad authored Oct 21, 2024
1 parent c393c01 commit faa7518
Show file tree
Hide file tree
Showing 85 changed files with 3,237 additions and 270 deletions.
3 changes: 2 additions & 1 deletion app/controllers/api/v1/customers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ def create_params
:integration_type,
:integration_code,
:subsidiary_id,
:sync_with_provider
:sync_with_provider,
:targeted_object
],
billing_configuration: [
:invoice_grace_period,
Expand Down
1 change: 0 additions & 1 deletion app/graphql/types/integrations/hubspot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ class Hubspot < Types::BaseObject
field :default_targeted_object, Types::Integrations::Hubspot::TargetedObjectsEnum, null: false
field :id, ID, null: false
field :name, String, null: false
field :private_app_token, String, null: false
field :sync_invoices, Boolean
field :sync_subscriptions, Boolean
end
Expand Down
1 change: 0 additions & 1 deletion app/graphql/types/integrations/hubspot/create_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ class CreateInput < Types::BaseInputObject

argument :connection_id, String, required: true
argument :default_targeted_object, Types::Integrations::Hubspot::TargetedObjectsEnum, required: true
argument :private_app_token, String, required: true
argument :sync_invoices, Boolean, required: false
argument :sync_subscriptions, Boolean, required: false
end
Expand Down
1 change: 0 additions & 1 deletion app/graphql/types/integrations/hubspot/update_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ class UpdateInput < Types::BaseInputObject

argument :connection_id, String, required: false
argument :default_targeted_object, Types::Integrations::Hubspot::TargetedObjectsEnum, required: false
argument :private_app_token, String, required: false
argument :sync_invoices, Boolean, required: false
argument :sync_subscriptions, Boolean, required: false
end
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# frozen_string_literal: true

module Integrations
module Aggregator
class SendPrivateAppTokenJob < ApplicationJob
module Hubspot
class SavePortalIdJob < ApplicationJob
queue_as 'integrations'

retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3

def perform(integration:)
result = Integrations::Aggregator::SendPrivateAppTokenService.call(integration:)
result = Integrations::Hubspot::SavePortalIdService.call(integration:)
result.raise_if_error!
end
end
Expand Down
6 changes: 4 additions & 2 deletions app/jobs/send_webhook_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ class SendWebhookJob < ApplicationJob
'events.errors' => Webhooks::Events::ValidationErrorsService,
'fee.created' => Webhooks::Fees::PayInAdvanceCreatedService,
'fee.tax_provider_error' => Webhooks::Integrations::Taxes::FeeErrorService,
'customer.accounting_provider_created' => Webhooks::Integrations::CustomerCreatedService,
'customer.accounting_provider_error' => Webhooks::Integrations::CustomerErrorService,
'customer.accounting_provider_created' => Webhooks::Integrations::AccountingCustomerCreatedService,
'customer.accounting_provider_error' => Webhooks::Integrations::AccountingCustomerErrorService,
'customer.crm_provider_created' => Webhooks::Integrations::CrmCustomerCreatedService,
'customer.crm_provider_error' => Webhooks::Integrations::CrmCustomerErrorService,
'customer.payment_provider_created' => Webhooks::PaymentProviders::CustomerCreatedService,
'customer.payment_provider_error' => Webhooks::PaymentProviders::CustomerErrorService,
'customer.checkout_url_generated' => Webhooks::PaymentProviders::CustomerCheckoutService,
Expand Down
6 changes: 3 additions & 3 deletions app/models/integrations/hubspot_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

module Integrations
class HubspotIntegration < BaseIntegration
validates :connection_id, :private_app_token, :default_targeted_object, presence: true
validates :connection_id, :default_targeted_object, presence: true

settings_accessors :default_targeted_object, :sync_subscriptions, :sync_invoices, :subscriptions_object_type_id,
:invoices_object_type_id, :companies_properties_version, :contacts_properties_version,
:subscriptions_properties_version, :invoices_properties_version
secrets_accessors :connection_id, :private_app_token
:subscriptions_properties_version, :invoices_properties_version, :portal_id
secrets_accessors :connection_id

TARGETED_OBJECTS = %w[companies contacts].freeze

Expand Down
5 changes: 3 additions & 2 deletions app/services/integration_customers/anrok_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

module IntegrationCustomers
class AnrokService < ::BaseService
def initialize(integration:, customer:, subsidiary_id:)
def initialize(integration:, customer:, subsidiary_id:, **params)
@customer = customer
@subsidiary_id = subsidiary_id
@integration = integration
@params = params&.with_indifferent_access

super(nil)
end
Expand All @@ -26,6 +27,6 @@ def create

private

attr_reader :integration, :customer, :subsidiary_id
attr_reader :integration, :customer, :subsidiary_id, :params
end
end
4 changes: 4 additions & 0 deletions app/services/integration_customers/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ def subsidiary_id
@subsidiary_id ||= params[:subsidiary_id]
end

def targeted_object
@targeted_object ||= params[:targeted_object]
end

def external_customer_id
@external_customer_id ||= params[:external_customer_id]
end
Expand Down
10 changes: 9 additions & 1 deletion app/services/integration_customers/create_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ def call
attr_reader :customer

def sync_customer!
integration_customer_service = IntegrationCustomers::Factory.new_instance(integration:, customer:, subsidiary_id:)
integration_customer_service = IntegrationCustomers::Factory.new_instance(
integration:, customer:, subsidiary_id:, **params
)

return result unless integration_customer_service

Expand All @@ -49,6 +51,12 @@ def link_customer!

if integration&.type&.to_s == 'Integrations::NetsuiteIntegration'
new_integration_customer.subsidiary_id = subsidiary_id
new_integration_customer.save!
end

if integration&.type&.to_s == 'Integrations::HubspotIntegration'
new_integration_customer.targeted_object = targeted_object
new_integration_customer.save!
end

result.integration_customer = new_integration_customer
Expand Down
6 changes: 4 additions & 2 deletions app/services/integration_customers/factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

module IntegrationCustomers
class Factory
def self.new_instance(integration:, customer:, subsidiary_id:)
service_class(integration).new(integration:, customer:, subsidiary_id:)
def self.new_instance(integration:, customer:, subsidiary_id:, **params)
service_class(integration).new(integration:, customer:, subsidiary_id:, **params)
end

def self.service_class(integration)
Expand All @@ -14,6 +14,8 @@ def self.service_class(integration)
IntegrationCustomers::AnrokService
when 'Integrations::XeroIntegration'
IntegrationCustomers::XeroService
when 'Integrations::HubspotIntegration'
IntegrationCustomers::HubspotService
else
raise(NotImplementedError)
end
Expand Down
53 changes: 53 additions & 0 deletions app/services/integration_customers/hubspot_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

module IntegrationCustomers
class HubspotService < ::BaseService
def initialize(integration:, customer:, subsidiary_id:, **params)
@customer = customer
@subsidiary_id = subsidiary_id
@integration = integration
@params = params&.with_indifferent_access

super(nil)
end

def create
create_result = create_service_class.call(
integration:,
customer:,
subsidiary_id: nil
)

return create_result if create_result.error

new_integration_customer = IntegrationCustomers::BaseCustomer.create!(
integration:,
customer:,
external_customer_id: create_result.contact_id,
email: create_result.email,
type: 'IntegrationCustomers::HubspotCustomer',
sync_with_provider: true,
targeted_object:
)

result.integration_customer = new_integration_customer
result
end

private

attr_reader :integration, :customer, :subsidiary_id, :params

def create_service_class
@create_service_class ||= if targeted_object == 'contacts'
Integrations::Aggregator::Contacts::CreateService
else
Integrations::Aggregator::Companies::CreateService
end
end

def targeted_object
@targeted_object ||= params[:targeted_object]
end
end
end
5 changes: 3 additions & 2 deletions app/services/integration_customers/netsuite_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

module IntegrationCustomers
class NetsuiteService < ::BaseService
def initialize(integration:, customer:, subsidiary_id:)
def initialize(integration:, customer:, subsidiary_id:, **params)
@customer = customer
@subsidiary_id = subsidiary_id
@integration = integration
@params = params&.with_indifferent_access

super(nil)
end
Expand All @@ -29,6 +30,6 @@ def create

private

attr_reader :integration, :customer, :subsidiary_id
attr_reader :integration, :customer, :subsidiary_id, :params
end
end
22 changes: 15 additions & 7 deletions app/services/integration_customers/update_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,13 @@ def call
return result if integration_customer.type == 'IntegrationCustomers::AnrokCustomer'
return result.not_found_failure!(resource: 'integration_customer') unless integration_customer

integration_customer.update!(external_customer_id:) if external_customer_id.present?
integration_customer.external_customer_id = external_customer_id if external_customer_id.present?
integration_customer.targeted_object = targeted_object if targeted_object.present?
integration_customer.save!

if sync_with_provider
integration_customer.subsidiary_id = subsidiary_id if subsidiary_id.present?

update_result = Integrations::Aggregator::Contacts::UpdateService.call(integration:, integration_customer:)
if integration_customer.external_customer_id.present?
update_result = update_service_class.call(integration:, integration_customer:)
return update_result unless update_result.success?

integration_customer.save!
end

result.integration_customer = integration_customer
Expand All @@ -34,5 +32,15 @@ def call
attr_reader :integration_customer

delegate :customer, to: :integration_customer

def update_service_class
@update_service_class ||= if integration_customer.type != 'IntegrationCustomers::HubspotCustomer'
Integrations::Aggregator::Contacts::UpdateService
elsif integration_customer.targeted_object == 'contacts'
Integrations::Aggregator::Contacts::UpdateService
else
Integrations::Aggregator::Companies::UpdateService
end
end
end
end
5 changes: 3 additions & 2 deletions app/services/integration_customers/xero_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

module IntegrationCustomers
class XeroService < ::BaseService
def initialize(integration:, customer:, subsidiary_id:)
def initialize(integration:, customer:, subsidiary_id:, **params)
@customer = customer
@subsidiary_id = subsidiary_id
@integration = integration
@params = params&.with_indifferent_access

super(nil)
end
Expand Down Expand Up @@ -33,6 +34,6 @@ def create

private

attr_reader :integration, :customer, :subsidiary_id
attr_reader :integration, :customer, :subsidiary_id, :params
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Integrations
module Aggregator
class AccountInformationService < BaseService
def action_path
'v1/account-information'
end

def call
response = http_client.get(headers:)

result.account_information = OpenStruct.new(response)
result
end

private

def headers
{
'Connection-Id' => integration.connection_id,
'Authorization' => "Bearer #{secret_key}",
'Provider-Config-Key' => provider_key
}
end
end
end
end
11 changes: 10 additions & 1 deletion app/services/integrations/aggregator/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def headers

def deliver_error_webhook(customer:, code:, message:)
SendWebhookJob.perform_later(
'customer.accounting_provider_error',
error_webhook_code,
customer,
provider:,
provider_code: integration.code,
Expand Down Expand Up @@ -108,6 +108,15 @@ def secret_key
ENV['NANGO_SECRET_KEY']
end

def error_webhook_code
case provider
when 'hubspot'
'customer.crm_provider_error'
else
'customer.accounting_provider_error'
end
end

def code(error)
json = error.json_message
json['type'].presence || json.dig('error', 'payload', 'name').presence || json.dig('error', 'code')
Expand Down
36 changes: 36 additions & 0 deletions app/services/integrations/aggregator/companies/base_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module Integrations
module Aggregator
module Companies
class BaseService < Integrations::Aggregator::Contacts::BaseService
def action_path
"v1/#{provider}/companies"
end

private

def process_hash_result(body)
contact = body['succeededCompanies']&.first
contact_id = contact&.dig('id')
email = contact&.dig('email')

if contact_id
result.contact_id = contact_id
result.email = email if email.present?
else
message = if body.key?('failedCompanies')
body['failedCompanies'].first['validation_errors'].map { |error| error['Message'] }.join(". ")
else
body.dig('error', 'payload', 'message')
end

code = 'Validation error'

deliver_error_webhook(customer:, code:, message:)
end
end
end
end
end
end
Loading

0 comments on commit faa7518

Please sign in to comment.