Compare commits

...

8 Commits

8 changed files with 54 additions and 36 deletions

View File

@@ -46,9 +46,13 @@ class QboController < ApplicationController
redirect_to issue || root_path, flash: { error: e.message } redirect_to issue || root_path, flash: { error: e.message }
end end
# Manual sync endpoint to trigger a full synchronization of QuickBooks entities with the local database. Enqueues all relevant sync jobs and redirects to the home page with a notice that syncing has started. # Manual sync endpoint to trigger synchronization of QuickBooks entities
# with the local database. Supports full or partial sync depending on
# the `full_sync` boolean parameter.
def sync def sync
QboSyncDispatcher.full_sync! full_sync = ActiveModel::Type::Boolean.new.cast(params[:full_sync])
QboSyncDispatcher.sync!(full_sync: full_sync)
redirect_to :home, flash: { notice: I18n.t(:label_syncing) } redirect_to :home, flash: { notice: I18n.t(:label_syncing) }
end end

View File

@@ -17,9 +17,17 @@ class QboSyncDispatcher
Employee Employee
].freeze ].freeze
# Dispatches all synchronization jobs to perform a full sync of QuickBooks entities with the local database. Each job is enqueued with the `full_sync` flag set to true. # Dispatches all synchronization jobs to perform a full sync of QuickBooks entities with the local database.
def self.full_sync! # Each job is enqueued with the `full_sync` flag set to true.
def self.sync!(full_sync: false)
log "Manual Sync initated for #{full_sync ? "full sync" : "incremental sync"}"
enque_jobs full_sync: full_sync
end
private
# Dynamically enques all sync jobs
def self.enque_jobs(full_sync: full_sync)
jobs = SYNC_JOBS.dup jobs = SYNC_JOBS.dup
# Allow other plugins to add addtional sync jobs via Hooks # Allow other plugins to add addtional sync jobs via Hooks
@@ -29,11 +37,9 @@ class QboSyncDispatcher
log "Added additionals QBO Sync Job for #{context.to_s}" log "Added additionals QBO Sync Job for #{context.to_s}"
end end
jobs.each { |job| QboSyncJob.perform_later(entity: job, full_sync: true) } jobs.each { |job| QboSyncJob.perform_later(entity: job, full_sync: full_sync) }
end end
private
def self.log(msg) def self.log(msg)
Rails.logger.info "[QboSyncDispatcher] #{msg}" Rails.logger.info "[QboSyncDispatcher] #{msg}"
end end

View File

@@ -33,14 +33,12 @@ class Customer < QboBaseModel
# Returns the customer's email address # Returns the customer's email address
def email def email
details return details&.email_address&.address
return @details&.email_address&.address
end end
# Updates the customer's email address # Updates the customer's email address
def email=(s) def email=(s)
details details.email_address = s
@details.email_address = s
end end
# Customers are not bound by a project # Customers are not bound by a project
@@ -51,22 +49,19 @@ class Customer < QboBaseModel
# returns the customer's mobile phone # returns the customer's mobile phone
def mobile_phone def mobile_phone
details return details&.mobile_phone&.free_form_number
return @details&.mobile_phone&.free_form_number
end end
# Updates the custome's mobile phone number # Updates the custome's mobile phone number
def mobile_phone=(n) def mobile_phone=(n)
details
pn = Quickbooks::Model::TelephoneNumber.new pn = Quickbooks::Model::TelephoneNumber.new
pn.free_form_number = n pn.free_form_number = n
@details.mobile_phone = pn details.mobile_phone = pn
end end
# Updates Both local DB name & QBO display_name # Updates Both local DB name & QBO display_name
def name=(s) def name=(s)
details details.display_name = s
@details.display_name = s
super super
end end
@@ -78,22 +73,19 @@ class Customer < QboBaseModel
# Sets the notes for the customer # Sets the notes for the customer
def notes=(s) def notes=(s)
details details.notes = s
@details.notes = s
end end
# returns the customer's primary phone # returns the customer's primary phone
def primary_phone def primary_phone
details return details&.primary_phone&.free_form_number
return @details&.primary_phone&.free_form_number
end end
# Updates the customer's primary phone number # Updates the customer's primary phone number
def primary_phone=(n) def primary_phone=(n)
details
pn = Quickbooks::Model::TelephoneNumber.new pn = Quickbooks::Model::TelephoneNumber.new
pn.free_form_number = n pn.free_form_number = n
@details.primary_phone = pn details.primary_phone = pn
end end
# Seach for customers by name or phone number # Seach for customers by name or phone number

View File

@@ -14,6 +14,26 @@ class Qbo < ActiveRecord::Base
include Redmine::I18n include Redmine::I18n
validate :single_record_only, on: :create validate :single_record_only, on: :create
# Returns the last sync time formatted for display. If no sync has occurred, returns a default message.
def self.last_sync
qbo = QboConnectionService.current!
format_time(qbo.last_sync)
rescue
return I18n.t(:label_qbo_never_synced)
end
def self.oauth2_access_token_expires_at
QboConnectionService.current!.oauth2_access_token_expires_at
rescue
return I18n.t(:label_qbo_never_synced)
end
def self.oauth2_refresh_token_expires_at
QboConnectionService.current!.oauth2_refresh_token_expires_at
rescue
return I18n.t(:label_qbo_never_synced)
end
# Updates last sync time stamp # Updates last sync time stamp
def self.update_time_stamp def self.update_time_stamp
@@ -24,13 +44,6 @@ class Qbo < ActiveRecord::Base
qbo.save qbo.save
end end
# Returns the last sync time formatted for display. If no sync has occurred, returns a default message.
def self.last_sync
qbo = QboConnectionService.current!
return I18n.t(:label_qbo_never_synced) unless qbo&.last_sync
format_time(qbo.last_sync)
end
private private
# Logs a message with a QBO-specific prefix for easier identification in the logs. # Logs a message with a QBO-specific prefix for easier identification in the logs.

View File

@@ -10,7 +10,8 @@
class QboOauthService class QboOauthService
# Generates the QuickBooks OAuth authorization URL with the specified callback URL. The URL includes necessary parameters such as response type, state, and scope. # Generates the QuickBooks OAuth authorization URL with the specified callback URL.
# The URL includes necessary parameters such as response type, state, and scope.
def self.authorization_url(callback_url:) def self.authorization_url(callback_url:)
client.auth_code.authorize_url( client.auth_code.authorize_url(
redirect_uri: callback_url, redirect_uri: callback_url,
@@ -20,7 +21,8 @@ class QboOauthService
) )
end end
# Exchanges the authorization code for access and refresh tokens. Creates or replaces the QBO connection record with the new credentials and refreshes the token immediately after creation. # Exchanges the authorization code for access and refresh tokens.
# Creates or replaces the QBO connection record with the new credentials and refreshes the token immediately after creation.
def self.exchange!(code:, callback_url:, realm_id:) def self.exchange!(code:, callback_url:, realm_id:)
resp = client.auth_code.get_token(code, redirect_uri: callback_url) resp = client.auth_code.get_token(code, redirect_uri: callback_url)
QboConnectionService.replace!( token: resp.token, refresh_token: resp.refresh_token, realm_id: realm_id ) QboConnectionService.replace!( token: resp.token, refresh_token: resp.refresh_token, realm_id: realm_id )

View File

@@ -3,3 +3,4 @@
<%= submit_tag t(:label_search) %> <%= submit_tag t(:label_search) %>
<% end %> <% end %>
<%= button_to t(:label_new_customer), new_customer_path, method: :get%> <%= button_to t(:label_new_customer), new_customer_path, method: :get%>
<%= button_to(t(:label_sync), qbo_sync_path, method: :get) if User.current.admin?%>

View File

@@ -66,12 +66,12 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
<tr> <tr>
<th><%=t(:label_oauth_expires)%></th> <th><%=t(:label_oauth_expires)%></th>
<td><%= QboConnectionService.current!&.oauth2_access_token_expires_at %> <td><%= Qbo.oauth2_access_token_expires_at %>
</tr> </tr>
<tr> <tr>
<th><%=t(:label_oauth2_refresh_token_expires_at)%></th> <th><%=t(:label_oauth2_refresh_token_expires_at)%></th>
<td><%= QboConnectionService.current!&.oauth2_refresh_token_expires_at %> <td><%= Qbo.oauth2_refresh_token_expires_at %>
</tr> </tr>
</tbody> </tbody>
@@ -107,5 +107,5 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
<br/> <br/>
<div> <div>
<b><%=t(:label_last_sync)%> </b> <%= Qbo.last_sync if Qbo.exists? %> <%= link_to t(:label_sync_now), qbo_sync_path %> <b><%=t(:label_last_sync)%> </b> <%= Qbo.last_sync if Qbo.exists? %> <%= link_to t(:label_sync_now), qbo_sync_path(full_sync: true) %>
</div> </div>

View File

@@ -14,7 +14,7 @@ Redmine::Plugin.register :redmine_qbo do
name 'Redmine QBO plugin' name 'Redmine QBO plugin'
author 'Rick Barrette' author 'Rick Barrette'
description 'A pluging for Redmine to connect with QuickBooks Online to create Time Activity Entries for billable hours logged when an Issue is closed' description 'A pluging for Redmine to connect with QuickBooks Online to create Time Activity Entries for billable hours logged when an Issue is closed'
version '2026.3.7' version '2026.3.9'
url 'https://github.com/rickbarrette/redmine_qbo' url 'https://github.com/rickbarrette/redmine_qbo'
author_url 'https://barrettefabrication.com' author_url 'https://barrettefabrication.com'
settings default: {empty: true}, partial: 'qbo/settings' settings default: {empty: true}, partial: 'qbo/settings'