QuickBooks
Overview
This document provides a comprehensive explanation of our QuickBooks integration flow. The integration connects our application with QuickBooks Online, enabling automatic synchronization of customers and invoices between the two systems.
Architecture
The QuickBooks integration follows a OAuth 2.0 flow for authentication and uses the QuickBooks API to interact with customer and invoice data. The integration is built using several key components:
- Authentication Service: Handles OAuth flow, token management, and API communication
- Data Resolvers: Process and transform data between our system and QuickBooks
- Background Jobs: Schedule and execute regular polling for data synchronization
- Data Models: Store QuickBooks data in our application
Integration Flow
Authentication Flow
- Consent URL Generation: The application generates a consent URL for the user to authorize our application to access their QuickBooks data.
- Authorization Code Exchange: After authorization, QuickBooks redirects back to our application with an authorization code.
- Token Exchange: The code is exchanged for access and refresh tokens.
- Token Storage: Tokens are securely stored in our database along with other integration details.
- Token Refresh: The system automatically refreshes tokens before they expire.
Data Synchronization
Customer Synchronization
- QuickbooksPollJob: Scheduled job that triggers the customer and invoice polling process.
- QuickbooksCustomersPollJob: Fetches all customers from QuickBooks for a specific company.
- QuickbooksCustomerResolverJob: Processes each customer:
- QuickbooksCustomerResolver: Handles the main customer record.
- QuickbooksCustomerByCompanyResolver: Handles company-specific customer records.
- Customer Hierarchy Management: Establishes relationships between head offices and distribution centers if 2 fetched customers have the same address.
Invoice Synchronization
- QuickbooksPollJob: Identifies customers with active EDI partners.
- QuickbooksCustomerInvoicesPollJob: Fetches invoices for EDI partners customers.
- QuickbooksInvoiceHeaderResolverJob: Processes each invoice:
- QuickbooksInvoiceHeaderResolver: Handles the invoice header record.
- QuickbooksInvoiceDetailResolver: Handles each line item in the invoice.
- Execute Stored Procedure: execute
pr_load_edi_210_invoiceto process the quickbooks invoice into the edi tables. - Status Updates: Updates the SPS Ready status in QuickBooks after processing by sending a POST request.
Technical Implementation
Authentication Implementation
1. Generating the Consent URL
When a user wants to connect their QuickBooks account, we generate a consent URL:
# QuickbooksApiService
def consent_url(state:)
URI::HTTPS.build(
host: URI(AUTH_URL).host,
path: URI(AUTH_URL).path,
query: URI.encode_www_form(
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPE,
state: state
)
).to_s
end
2. Exchange Code for Tokens
After the user authorizes our application, QuickBooks redirects back with a code that we exchange for tokens:
# QuickbooksApiService
def integrate(code:, realm_id:)
tokens = exchange_code_for_tokens(code)
integration = Integration.find_or_initialize_by(
company_id: company_id,
integration_id: Integration.quickbooks.id
)
integration.assign_attributes(
credentials: tokens.merge('realm_id' => realm_id),
active: true
)
integration.save!
integration
end
def exchange_code_for_tokens(code)
response = Faraday.post(TOKEN_URL) do |req|
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
req.headers['Accept'] = 'application/json'
req.body = URI.encode_www_form(
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI
)
end
raise "Token exchange failed: #{response.body}" unless response.success?
JSON.parse(response.body).merge('created_at' => Time.now.to_i)
end
3. Token Management
Tokens are automatically refreshed before they expire:
# QuickbooksApiService
def valid_tokens
tokens = company_integration.credentials
expires_at = tokens['created_at'].to_i + tokens['expires_in'].to_i
if Time.now.to_i + MINIMUM_TOKEN_REMAINING_TIME >= expires_at
new_tokens = refresh_tokens(tokens['refresh_token'])
updated = tokens.merge(new_tokens)
company_integration.update!(credentials: updated)
updated
else
tokens
end
end
Data Synchronization Implementation
Customer Synchronization
The customer synchronization process is triggered by the QuickbooksPollJob, which runs on a schedule:
# QuickbooksPollJob
def poll_companies_customers
Integration.quickbooks_companies.select(:company_id, :suffix_code).find_each do |integration|
QuickbooksCustomersPollJob.perform_later(
company_id: integration.company_id,
suffix_code: integration.suffix_code
)
end
end
Note suffix_code was added to be able to store a unique customer code identifier that stores the quickbooks customer id. Example: Company Centrix - suffix_code: 'CNTX'.
Fetching Customers
The QuickbooksCustomersPollJob fetches customers from QuickBooks:
# QuickbooksCustomersPollJob
def resolve_customers
quickbooks_api_service.get_each_paginated_resource(
path: 'Customer',
query: 'SELECT * FROM Customer'
) do |quickbooks_customer|
Rails.logger.info("Processing QB customer: #{quickbooks_customer['DisplayName']}")
QuickbooksCustomerResolverJob.perform_later(
company_id: company_id,
suffix_code: suffix_code,
quickbooks_customer: quickbooks_customer
)
end
end
Processing Customers
Each customer is processed by the QuickbooksCustomerResolverJob:
# QuickbooksCustomerResolverJob
def perform(company_id:, suffix_code:, quickbooks_customer:)
# 1. Resolve the main customer record
customer_id = resolve_customer(
company_id: company_id,
suffix_code: suffix_code,
quickbooks_customer: quickbooks_customer
)
# 2. Resolve the company-specific customer record
resolve_customer_by_company(
company_id: company_id,
customer_id: customer_id,
quickbooks_customer: quickbooks_customer
)
end
The resolver follows these steps:
- Find existing customer or create a new one
- Update customer attributes
- Create integration reference
# QuickbooksCustomerResolver
def resolve
ActiveRecord::Base.transaction do
if find_integrated_customer
update_customer
check_and_create_dc_customer_if_needed
{ action: Actions::UPDATE, customer: @customer }
elsif find_customer_to_integrate
integrate_customer
update_customer
check_and_create_dc_customer_if_needed
{ action: Actions::INTEGRATE, customer: @customer }
elsif create_customer
integrate_customer
check_and_create_dc_customer_if_needed
{ action: Actions::CREATE, customer: @customer }
end
end
rescue StandardError => e
Rails.logger.error "QuickBooks Customer resolve failed: #{e.message}"
raise e
end
Invoice Synchronization
Invoice synchronization is similar to customer synchronization but with additional filtering:
# QuickbooksPollJob
def poll_customers_invoices
Integration.quickbooks_companies.select(:company_id, :suffix_code).find_each do |integration|
# Get all customers for this company
CustomersByCompanyView.where(company_id: integration.company_id).find_each do |customer|
# Check if customer has an active EDI partner
edi_partner = EdiPartner.find_by(
entity_type: 'C',
head_office_id: customer.customer_id,
active: true
)
if edi_partner.present?
# Extract the QuickBooks ID from customer_code
prefix = "#{integration.suffix_code}-"
customer_code = customer.customer_code.gsub(/^#{Regexp.escape(prefix)}/, '')
QuickbooksCustomerInvoicesPollJob.perform_later(
company_id: customer.company_id,
customer_id: customer.customer_id,
customer_code: customer_code
)
end
end
end
end
Fetching and Processing Invoices
Invoices are fetched for specific customers:
# QuickbooksCustomerInvoicesPollJob
def resolve_invoices
quickbooks_api_service.get_invoices_for_customer(customer_id: customer_code) do |invoice|
Rails.logger.info("Processing QB invoice #{invoice['Id']}:")
QuickbooksInvoiceHeaderResolverJob.perform_later(
company_id: company_id,
customer_id: customer_id,
invoice: invoice
)
end
end
# QuickbooksApiService
def get_invoices_for_customer(customer_id:, &block)
get_each_paginated_resource(
path: 'Invoice',
query: "SELECT * FROM Invoice WHERE CustomerRef = '#{customer_id}' AND Balance = '0'"
) do |invoice|
custom_fields = invoice['CustomField'] || []
sps_ready = custom_fields.find { |field| field['Name'] == 'SPS Ready' }&.dig('StringValue')
yield invoice if sps_ready == 'Y'
end
end
Each invoice is then processed:
# QuickbooksInvoiceHeaderResolverJob
def perform(company_id:, customer_id:, invoice:)
# 1. Resolve the invoice header
result = QuickbooksInvoiceHeaderResolver.new(
company_id: company_id,
customer_id: customer_id,
invoice: invoice
).resolve
quickbooks_invoice_header_id = result[:invoice_header]
# 2. Resolve each invoice line
invoice['Line'].each_with_index do |line, index|
QuickbooksInvoiceDetailResolver.new(
company_id: company_id,
customer_id: customer_id,
quickbooks_invoice_header_id: quickbooks_invoice_header_id,
invoice_detail: line
).resolve
end
# 3. Update SPS Ready status
update_invoice_status_and_call_procedure(quickbooks_invoice_header_id)
end
Updating QuickBooks Invoice Status
After processing an invoice, we update its status in QuickBooks:
def update_invoice(invoice_id:, attributes:)
tokens = valid_tokens
realm_id = tokens['realm_id']
# First, we need to get the current invoice to merge with our updates
response = Faraday.get("#{API_BASE_URL}/#{realm_id}/invoice/#{invoice_id}") do |req|
req.headers['Authorization'] = "Bearer #{tokens['access_token']}"
req.headers['Accept'] = 'application/json'
req.headers['Content-Type'] = 'application/json'
end
raise "Get invoice failed: #{response.body}" unless response.success?
current_invoice = JSON.parse(response.body)['Invoice']
# Merge the current invoice with our updates
# We need to include the SyncToken to avoid concurrency issues
update_data = {
'Id' => invoice_id,
'SyncToken' => current_invoice['SyncToken'],
'sparse' => true
}.merge(attributes)
# POST the updated invoice back to QuickBooks
response = Faraday.post("#{API_BASE_URL}/#{realm_id}/invoice") do |req|
req.headers['Authorization'] = "Bearer #{tokens['access_token']}"
req.headers['Accept'] = 'application/json'
req.headers['Content-Type'] = 'application/json'
req.body = update_data.to_json
end
raise "Update invoice failed: #{response.body}" unless response.success?
JSON.parse(response.body)['Invoice']
end
def update_invoice_status_and_call_procedure(quickbooks_invoice_header_id)
# Find the invoice header to get both our internal status and the QuickBooks invoice ID
invoice_header = QuickbooksInvoiceHeader.find(quickbooks_invoice_header_id)
quickbooks_invoice_id = invoice_header.quickbooks_invoice_id
ActiveRecord::Base.transaction do
# Call the stored procedure with the invoice header ID
ActiveRecord::Base.connection.execute_procedure(
'pr_load_edi_210_invoice',
p_qb_invoice_header_id: quickbooks_invoice_header_id
)
# Update the QuickBooks invoice via API
update_quickbooks_invoice(
company_id: invoice_header.company_id,
quickbooks_invoice_id: quickbooks_invoice_id,
po_number: invoice_header.po_number,
shipment_id: invoice_header.shipment_identification_no
)
# Update our internal status to 'invoice sent'
invoice_header.update!(status: 'invoice sent')
end
rescue StandardError => e
Rails.logger.error("Failed to update: #{e.message}")
raise e
end
def update_quickbooks_invoice(company_id:, quickbooks_invoice_id:, po_number:, shipment_id:)
# Create the custom fields array with updated SPS Ready status
# Note: we need to add all the custom fields so that they are not overwritten
# by QuickBooks when we update the invoice
custom_fields = [
{ 'DefinitionId' => '1', 'Name' => 'P.O. Number', 'Type' => 'StringType', 'StringValue' => po_number },
{ 'DefinitionId' => '2', 'Name' => 'SPS Ready', 'Type' => 'StringType', 'StringValue' => 'Invoice sent' },
{ 'DefinitionId' => '3', 'Name' => 'Shipment ID', 'Type' => 'StringType', 'StringValue' => shipment_id }
]
# Update the invoice via the QuickBooks API
api_service = QuickbooksApiService.new(company_id: company_id)
api_service.update_invoice(
invoice_id: quickbooks_invoice_id,
attributes: { 'CustomField' => custom_fields }
)
rescue StandardError => e
Rails.logger.error("Failed to update QuickBooks invoice: #{e.message}")
raise e
end
API Communication
The core of our integration is the QuickbooksApiService, which handles all communication with QuickBooks API:
# QuickbooksApiService
def get_each_paginated_resource(path:, query:, &block)
tokens = valid_tokens
realm_id = tokens['realm_id']
# start_position and max_results are used for pagination they should be strings
start_position = '1'
max_results = '1000' # 1000 is the maximum allowed by QuickBooks
loop do
paginated_query = "#{query} STARTPOSITION #{start_position} MAXRESULTS #{max_results}"
response = Faraday.get("#{API_BASE_URL}/#{realm_id}/query") do |req|
req.headers['Authorization'] = "Bearer #{tokens['access_token']}"
req.headers['Accept'] = 'application/json'
req.headers['Content-Type'] = 'application/text'
req.params['query'] = paginated_query
end
raise "Get #{path} failed: #{response.body}" unless response.success?
parsed = JSON.parse(response.body)
resources = parsed.dig('QueryResponse', path.capitalize)
break if resources.blank?
resources.each(&block)
break unless resources.size == max_results
start_position = start_position.to_i + max_results.to_i
end
end
Error Handling and Logging
The integration includes comprehensive error handling and logging:
# QuickbooksCustomersPollJob
def reference_success
ReferenceEvent.create!(
level: ReferenceEvent::Levels::INFO,
company_id: company_id,
slug: ReferenceEvent::Slugs::QUICKBOOKS_CUSTOMERS_POLL,
reference: quickbooks_api_service.get_company_integration
)
end
def reference_error(error)
ReferenceEvent.create!(
level: ReferenceEvent::Levels::ERROR,
company_id: company_id,
slug: ReferenceEvent::Slugs::QUICKBOOKS_FORBADE_CUSTOMERS_POLL,
reference: quickbooks_api_service.get_company_integration,
data: error.response
)
end
Configuration Management
The integration uses configuration values from Rails credentials:
# QuickbooksApiService
CLIENT_ID = Rails.application.credentials.quickbooks_client_id
CLIENT_SECRET = Rails.application.credentials.quickbooks_client_secret
REDIRECT_URI = Rails.application.credentials.quickbooks_redirect_uri
AUTH_URL = Rails.application.credentials.quickbooks_auth_url
TOKEN_URL = Rails.application.credentials.quickbooks_token_url
API_BASE_URL = Rails.application.credentials.quickbooks_api_url
SCOPE = Rails.application.credentials.quickbooks_scope
Data Models
The integration utilizes several data models to store and manage QuickBooks data:
- QuickbooksInvoiceHeader: Stores the main invoice information.
- QuickbooksInvoiceDetail: Stores individual line items for each invoice.
- Customer: Stores the main customer information.
- CustomerByCompany: Assign the customer to a company.
- IntegrationReference: Maps our system's records to QuickBooks records.
- ReferenceEvent: Logs important events and errors during the integration process.
Developer Guide
API Reference
Customer Endpoints
GET /v3/company/{realmId}/query?query=<query>- Get all customers
Invoice Endpoints
GET /v3/company/{realmId}/query?query=<query>- Get all invoicesGET /v3/company/{realmId}/invoice/{id}- Get a specific invoicePOST /v3/company/{realmId}/invoice- Create/Update an invoice
Extending the Integration
Adding New Data Types
To add synchronization for a new data type (e.g., Bills, Payments):
- Create new resolver classes for the data type
- Create new models to store the data
- Add new polling jobs to fetch the data
- Update the main polling job to trigger the new synchronization