Compare commits

...

7 Commits

9 changed files with 347 additions and 292 deletions

View File

@@ -51,7 +51,7 @@ class CustomersController < ApplicationController
# display a list of all customers # display a list of all customers
def index def index
if params[:search] if params[:search]
@customers = Customer.search(params[:search]).paginate(page: params[:page]) @customers = Customer.search(params[:search]).order(:name).paginate(page: params[:page])
if only_one_non_zero?(@customers) if only_one_non_zero?(@customers)
redirect_to @customers.first redirect_to @customers.first
end end
@@ -134,60 +134,60 @@ class CustomersController < ApplicationController
# creates new customer view tokens, removes expired tokens & redirects to newly created customer view with new token. # creates new customer view tokens, removes expired tokens & redirects to newly created customer view with new token.
def share def share
issue = Issue.find(params[:id])
Thread.new do token = issue.share_token
logger.info "Removing expired customer tokens" redirect_to view_path(token.token)
CustomerToken.remove_expired_tokens
ActiveRecord::Base.connection.close
end
begin rescue ActiveRecord::RecordNotFound
issue = Issue.find_by_id(params[:id]) flash[:error] = t(:notice_issue_not_found)
redirect_to view_path issue.share_token.token render_404
rescue
flash[:error] = t :notice_issue_not_found
render_404
end
end end
# displays an issue for a customer with a provided security CustomerToken # displays an issue for a customer with a provided security CustomerToken
def view def view
User.current = User.anonymous
User.current = User.find_by lastname: 'Anonymous' # Load only active, non-expired token
@token = CustomerToken.active.find_by(token: params[:token])
return render_403 unless @token
@token = CustomerToken.find_by token: params[:token] # Load associated issue
begin @issue = @token.issue
@token.destroy if @token.expired? return render_403 unless @issue
raise "Token Expired" if @token.destroyed
session[:token] = @token.token # Optional: enforce token belongs to the issue's customer
@issue = Issue.find @token.issue_id return render_403 unless @issue.customer_id == @token.issue.customer_id
@journals = @issue.journals.
preload(:details).
preload(user: :email_address).
reorder(:created_on, :id).to_a
@journals.each_with_index {|j,i| j.indice = i+1}
@journals.reject!(&:private_notes?) unless User.current.allowed_to?(:view_private_notes, @issue.project)
Journal.preload_journals_details_custom_fields(@journals)
@journals.select! {|journal| journal.notes? || journal.visible_details.any?}
@journals.reverse! if User.current.wants_comments_in_reverse_order?
@changesets = @issue.changesets.visible.preload(:repository, :user).to_a # Store token in session for subsequent requests if needed
@changesets.reverse! if User.current.wants_comments_in_reverse_order? session[:token] = @token.token
@relations = @issue.relations.select {|r| r.other_issue(@issue) && r.other_issue(@issue).visible? } load_issue_data
@allowed_statuses = @issue.new_statuses_allowed_to(User.current) rescue ActiveRecord::RecordNotFound
@priorities = IssuePriority.active render_403
@time_entry = TimeEntry.new(issue: @issue, project: @issue.project)
@relation = IssueRelation.new
rescue
flash[:error] = t :notice_forbidden
render_403
end
end end
private private
def load_issue_data
@journals = @issue.journals.preload(:details).preload(user: :email_address).reorder(:created_on, :id).to_a
@journals.each_with_index { |j, i| j.indice = i + 1 }
@journals.reject!(&:private_notes?) unless User.current.allowed_to?(:view_private_notes, @issue.project)
Journal.preload_journals_details_custom_fields(@journals)
@journals.select! { |journal| journal.notes? || journal.visible_details.any? }
@journals.reverse! if User.current.wants_comments_in_reverse_order?
@changesets = @issue.changesets.visible.preload(:repository, :user).to_a
@changesets.reverse! if User.current.wants_comments_in_reverse_order?
@relations = @issue.relations.select { |r| r.other_issue(@issue)&.visible? }
@allowed_statuses = @issue.new_statuses_allowed_to(User.current)
@priorities = IssuePriority.active
@time_entry = TimeEntry.new(issue: @issue, project: @issue.project)
@relation = IssueRelation.new
end
# redmine permission - add customers # redmine permission - add customers
def add_customer def add_customer
global_check_permission(:add_customers) global_check_permission(:add_customers)

View File

@@ -62,80 +62,29 @@ class QboController < ApplicationController
# Manual Billing # Manual Billing
def bill def bill
i = Issue.find_by_id params[:id] issue = Issue.find_by(id: params[:id])
if i.customer return render_404 unless issue
billed = i.bill_time
if i.bill_time unless issue.customer
redirect_to i, flash: { notice: I18n.t( :label_billed_success ) + i.customer.name } redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_customer) }
else return
redirect_to i, flash: { error: I18n.t(:label_billing_error) }
end
else
redirect_to i, flash: { error: I18n.t(:label_billing_error_no_customer) }
end
end
# Quickbooks Webhook Callback
def webhook
logger.info "Quickbooks is calling webhook"
# check the payload
signature = request.headers['intuit-signature']
key = Setting.plugin_redmine_qbo['settingsWebhookToken']
data = request.body.read
hash = Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::Digest.new('sha256'), key, data)).strip()
# proceed if the request is good
if hash.eql? signature
Thread.new do
if request.headers['content-type'] == 'application/json'
data = JSON.parse(data)
else
# application/x-www-form-urlencoded
data = params.as_json
end
# Process the information
entities = data['eventNotifications'][0]['dataChangeEvent']['entities']
entities.each do |entity|
id = entity['id'].to_i
name = entity['name']
logger.info "Casting #{name.constantize} to obj"
# Magicly initialize the correct class
obj = name.constantize
# for merge events
obj.destroy(entity['deletedId']) if entity['deletedId']
#Check to see if we are deleting a record
if entity['operation'].eql? "Delete"
obj.destroy(id)
#if not then update!
else
begin
obj.sync_by_id(id)
rescue => e
logger.error "Failed to call sync_by_id on obj"
logger.error e.message
logger.error e.backtrace.join("\n")
end
end
end
# Record that last time we updated
Qbo.update_time_stamp
ActiveRecord::Base.connection.close
end
# The webhook doesn't require a response but let's make sure we don't send anything
render nothing: true, status: 200
else
render nothing: true, status: 400
end end
logger.info "Quickbooks webhook complete" unless issue.assigned_to&.employee_id.present?
redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_employee) }
return
end
unless Qbo.first
redirect_to issue, flash: { error: I18n.t(:label_billing_error_no_qbo) }
return
end
BillIssueTimeJob.perform_later(issue.id)
redirect_to issue, flash: {
notice: I18n.t(:label_billing_enqueued) + " #{issue.customer.name}"
}
end end
# #
@@ -159,4 +108,33 @@ class QboController < ApplicationController
redirect_to :home, flash: { notice: I18n.t(:label_syncing) } redirect_to :home, flash: { notice: I18n.t(:label_syncing) }
end end
# QuickBooks Webhook Callback
def webhook
logger.info "QBO: Webhook received"
signature = request.headers['intuit-signature']
key = Setting.plugin_redmine_qbo['settingsWebhookToken']
body = request.raw_post
digest = OpenSSL::Digest.new('sha256')
computed = Base64.strict_encode64(OpenSSL::HMAC.digest(digest, key, body))
unless secure_compare(computed, signature)
logger.warn "QBO: Invalid webhook signature"
head :unauthorized
return
end
WebhookProcessJob.perform_later(body)
head :ok
end
private
def secure_compare(a, b)
return false if a.blank? || b.blank?
ActiveSupport::SecurityUtils.secure_compare(a, b)
end
end end

View File

@@ -0,0 +1,108 @@
#The MIT License (MIT)
#
#Copyright (c) 2016 - 2026 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class BillIssueTimeJob < ActiveJob::Base
queue_as :default
def perform(issue_id)
issue = Issue.find(issue_id)
Rails.logger.debug "QBO: Starting billing for issue ##{issue.id}"
issue.with_lock do
unbilled_entries = issue.time_entries.where(billed: [false, nil]).lock
return if unbilled_entries.blank?
totals = aggregate_hours(unbilled_entries)
return if totals.blank?
qbo = Qbo.first
raise "No QBO configuration found" unless qbo
qbo.perform_authenticated_request do |access_token|
create_time_activities(issue, totals, access_token, qbo)
end
# Only mark billed AFTER successful QBO creation
unbilled_entries.update_all(billed: true)
end
Rails.logger.debug "QBO: Completed billing for issue ##{issue.id}"
rescue => e
Rails.logger.error "QBO: Billing failed for issue ##{issue_id} - #{e.message}"
raise e
end
private
def aggregate_hours(entries)
entries.includes(:activity)
.group_by { |e| e.activity&.name }
.transform_values { |rows| rows.sum(&:hours) }
.compact
end
def create_time_activities(issue, totals, access_token, qbo)
time_service = Quickbooks::Service::TimeActivity.new(
company_id: qbo.realm_id,
access_token: access_token
)
item_service = Quickbooks::Service::Item.new(
company_id: qbo.realm_id,
access_token: access_token
)
totals.each do |activity_name, hours_float|
next if activity_name.blank?
next if hours_float.to_f <= 0
item = find_item(item_service, activity_name)
next unless item
hours, minutes = convert_hours(hours_float)
time_entry = Quickbooks::Model::TimeActivity.new
time_entry.description = build_description(issue)
time_entry.employee_id = issue.assigned_to.employee_id
time_entry.customer_id = issue.customer_id
time_entry.billable_status = "Billable"
time_entry.hours = hours
time_entry.minutes = minutes
time_entry.name_of = "Employee"
time_entry.txn_date = Date.today
time_entry.hourly_rate = item.unit_price
time_entry.item_id = item.id
Rails.logger.debug "QBO: Creating TimeActivity for #{activity_name} (#{hours}h #{minutes}m)"
time_service.create(time_entry)
end
end
def convert_hours(hours_float)
total_minutes = (hours_float.to_f * 60).round
hours = total_minutes / 60
minutes = total_minutes % 60
[hours, minutes]
end
def build_description(issue)
base = "#{issue.tracker} ##{issue.id}: #{issue.subject}"
return base if issue.closed?
"#{base} (Partial @ #{issue.done_ratio}%)"
end
def find_item(item_service, name)
safe = name.gsub("'", "\\\\'")
item_service.query("SELECT * FROM Item WHERE Name = '#{safe}'").first
end
end

View File

@@ -0,0 +1,59 @@
#The MIT License (MIT)
#
#Copyright (c) 2016 - 2026 rick barrette
#
#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
#
#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class WebhookProcessJob < ActiveJob::Base
queue_as :default
ALLOWED_ENTITIES = %w[
Customer
Invoice
Estimate
].freeze
def perform(raw_body)
data = JSON.parse(raw_body)
data.fetch('eventNotifications', []).each do |notification|
entities = notification.dig('dataChangeEvent', 'entities') || []
entities.each do |entity|
process_entity(entity)
end
end
Qbo.update_time_stamp
end
private
def process_entity(entity)
name = entity['name']
id = entity['id']&.to_i
return unless ALLOWED_ENTITIES.include?(name)
model = name.safe_constantize
return unless model
if entity['deletedId']
model.destroy(entity['deletedId'])
return
end
if entity['operation'] == "Delete"
model.destroy(id)
else
model.sync_by_id(id)
end
rescue => e
Rails.logger.error "QBO Webhook entity processing failed"
Rails.logger.error e.message
end
end

View File

@@ -181,24 +181,10 @@ class Customer < ActiveRecord::Base
end end
end end
# Seach for customers by name or phone number
def self.search(search) def self.search(search)
return all if search.blank? search = sanitize_sql_like(search)
where("name LIKE ? OR phone_number LIKE ? OR mobile_phone_number LIKE ?", "%#{search}%", "%#{search}%", "%#{search}%")
# 1. Clean the input: Remove existing stars and special Boolean operators
# to prevent "red**" or syntax errors from hyphens/plus signs.
clean_search = search.gsub(/[*+\-><()~]/, '')
# 2. Add a single trailing wildcard for partial matching
ft_query = "#{clean_search}*"
# 3. Use the exact column list from your migration
# Using a hybrid approach to ensure "Jonh" still finds "John"
where(
"MATCH(name, phone_number, mobile_phone_number) AGAINST(? IN BOOLEAN MODE) OR
SOUNDEX(SUBSTRING_INDEX(name, ' ', 1)) = SOUNDEX(?) OR
name LIKE ?",
ft_query, clean_search, "%#{sanitize_sql_like(clean_search)}%"
).order(Arel.sql("MATCH(name, phone_number, mobile_phone_number) AGAINST(#{connection.quote(clean_search)}) DESC"))
end end
# Override the defult redmine seach method to rank results by id # Override the defult redmine seach method to rank results by id
@@ -208,14 +194,11 @@ class Customer < ActiveRecord::Base
scope = self.all scope = self.all
tokens.each do |token| tokens.each do |token|
q = "%#{sanitize_sql_like(token)}%" scope = scope.search(token)
scope = where("name LIKE ? OR phone_number LIKE ? OR mobile_phone_number LIKE ?", "%#{q}%", "%#{q}%", "%#{q}%")
end end
ids = scope.distinct.limit(options[:limit] || 100).pluck(:id) ids = scope.distinct.limit(options[:limit] || 100).pluck(:id)
ids.index_with { |id| id }
# Assign simple uniform ranking
ids.each_with_object({}) { |id, h| h[id] = id }
end end
# proforms a bruteforce sync operation # proforms a bruteforce sync operation
@@ -250,6 +233,30 @@ class Customer < ActiveRecord::Base
return "#{self[:name]} - #{phone_number.split(//).last(4).join unless phone_number.nil?}" return "#{self[:name]} - #{phone_number.split(//).last(4).join unless phone_number.nil?}"
end end
# Push the updates
def save_with_push
begin
qbo = Qbo.first
@details = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(
company_id: qbo.realm_id,
access_token: access_token
)
service.update(@details)
end
self.id = @details.id
rescue => e
errors.add(:base, e.message)
return false
end
save_without_push
end
alias_method :save_without_push, :save
alias_method :save, :save_with_push
private private
# pull the details # pull the details
@@ -266,23 +273,4 @@ class Customer < ActiveRecord::Base
end end
end end
# Push the updates
def save_with_push
begin
qbo = Qbo.first
@details = qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(company_id: qbo.realm_id, access_token: access_token)
service.update(@details)
end
#raise "QBO Fault" if @details.fault?
self.id = @details.id
rescue Exception => e
errors.add(e.message)
end
save_without_push
end
alias_method :save_without_push, :save
alias_method :save, :save_with_push
end end

View File

@@ -8,54 +8,44 @@
# #
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
class CustomerToken < ActiveRecord::Base class CustomerToken < ApplicationRecord
belongs_to :issue
has_many :issues validates :issue_id, presence: true
validates_presence_of :issue_id validates :token, presence: true, uniqueness: true
before_create :generate_token, :generate_expire_date
attr_accessor :destroyed
after_destroy :mark_as_destroyed
OAUTH_CONSUMER_SECRET = Setting.plugin_redmine_qbo['settingsOAuthConsumerSecret'] || 'CONFIGURE__' + SecureRandom.uuid before_validation :generate_token, on: :create
before_validation :generate_expire_date, on: :create
# generates a random token using the plugin setting settingsOAuthConsumerSecret for salt scope :active, -> { where("expires_at > ?", Time.current) }
def generate_token
self.token = SecureRandom.base64(15).tr('+/=lIO0', OAUTH_CONSUMER_SECRET)
end
# generates an expiring date TOKEN_EXPIRATION = 1.month
def generate_expire_date
self.expires_at = Time.now + 1.month
end
# set destroyed flag
def mark_as_destroyed
self.destroyed = true
end
# purge expired tokens
def self.remove_expired_tokens
where("expires_at < ?", Time.now).destroy_all
end
# has the token expired?
def expired? def expired?
self.expires_at < Time.now expires_at.present? && expires_at <= Time.current
end
def self.remove_expired_tokens
where("expires_at <= ?", Time.current).delete_all
end end
# Getter convenience method for tokens
def self.get_token(issue) def self.get_token(issue)
return unless issue
return unless User.current.allowed_to?(:view_issues, issue.project)
# check to see if token exists & if it is expired token = active.find_by(issue_id: issue.id)
token = find_by_issue_id issue.id return token if token
unless token.nil?
return token unless token.expired?
# remove expired tokens
token.destroy
end
# only create new token if we have an issue to attach it to create!(issue: issue)
return create(issue_id: issue.id) if User.current.logged?
end end
private
def generate_token
self.token ||= SecureRandom.urlsafe_base64(32)
end
def generate_expire_date
self.expires_at ||= Time.current + TOKEN_EXPIRATION
end
end end

View File

@@ -29,6 +29,9 @@ en:
label_billing_address: "Billing Address" label_billing_address: "Billing Address"
label_billing_error: "Customer could not be billed. Check for Customer or Assignee and try again." label_billing_error: "Customer could not be billed. Check for Customer or Assignee and try again."
label_billing_error_no_customer: "Cannot bill without an assigned customer." label_billing_error_no_customer: "Cannot bill without an assigned customer."
label_billing_error_no_employee: "Cannot bill without an assigned employee."
label_billing_error_no_qbo: "Cannot bill without a QuickBooks connection. Please connect to QuickBooks and try again."
label_billing_enqueued: "Billing has been enqueued for issue"
label_billed_success: "Successfully billed " label_billed_success: "Successfully billed "
label_client_id: "Intuit QBO OAuth2 Client ID" label_client_id: "Intuit QBO OAuth2 Client ID"
label_client_secret: "Intuit QBO OAuth2 Client Secret" label_client_secret: "Intuit QBO OAuth2 Client Secret"

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.2.14' version '2026.2.15'
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'

View File

@@ -12,27 +12,21 @@ require_dependency 'issue'
module RedmineQbo module RedmineQbo
module Patches module Patches
# Patches Redmine's Issues dynamically.
# Adds relationships for customers, estimates, invoices, customer_tokens
# Adds before and after save hooks
module IssuePatch module IssuePatch
def self.included(base) # :nodoc: def self.included(base)
base.extend(ClassMethods) base.extend(ClassMethods)
base.send(:include, InstanceMethods) base.send(:include, InstanceMethods)
# Same as typing in the class
base.class_eval do base.class_eval do
belongs_to :customer, class_name: 'Customer', foreign_key: :customer_id, optional: true belongs_to :customer, class_name: 'Customer', foreign_key: :customer_id, optional: true
belongs_to :customer_token, primary_key: :id belongs_to :customer_token, primary_key: :id
belongs_to :estimate, primary_key: :id belongs_to :estimate, primary_key: :id
has_and_belongs_to_many :invoices has_and_belongs_to_many :invoices
before_save :titlize_subject
after_save :bill_time
end
before_save :titlize_subject
after_commit :enqueue_billing, on: :update
end
end end
module ClassMethods module ClassMethods
@@ -41,101 +35,36 @@ module RedmineQbo
module InstanceMethods module InstanceMethods
# Create billable time entries def enqueue_billing
def bill_time Rails.logger.debug "QBO: Checking if issue needs to be billed for issue ##{id}"
logger.debug "QBO: Billing time for issue ##{self.id}" #return unless saved_change_to_status_id?
logger.debug "Issue is closed? #{self.closed?}" return unless closed?
return unless customer.present?
return unless assigned_to&.employee_id.present?
return unless Qbo.first
return false if self.assigned_to.nil? Rails.logger.debug "QBO: Enqueuing billing for issue ##{id}"
return false unless Qbo.first BillIssueTimeJob.perform_later(id)
return false unless self.customer end
Thread.new do def titlize_subject
spent_time = self.time_entries.where(billed: [false, nil]) Rails.logger.debug "QBO: Titlizing subject for issue ##{id}"
spent_hours ||= spent_time.sum(:hours) || 0
logger.debug "Issue has spent hours: #{spent_hours}" self.subject = subject.split(/\s+/).map do |word|
if word =~ /[A-Z]/ && word =~ /[0-9]/
if spent_hours > 0 then word
else
# Prepare to create a new Time Activity word.capitalize
qbo = Qbo.first
qbo.perform_authenticated_request do |access_token|
time_service = Quickbooks::Service::TimeActivity.new(company_id: qbo.realm_id, access_token: access_token)
item_service = Quickbooks::Service::Item.new(company_id: qbo.realm_id, access_token: access_token)
time_entry = Quickbooks::Model::TimeActivity.new
# Lets total up each activity before billing.
# This will simpify the invoicing with a single billable time entry per time activity
h = Hash.new(0)
spent_time.each do |entry|
h[entry.activity.name] += entry.hours
# update time entries billed status
entry.billed = true
entry.save
end
# Now letes upload our totals for each activity as their own billable time entry
h.each do |key, val|
logger.debug "Processing activity '#{key}' with #{val.to_i} hours for issue ##{self.id}"
# Convert float spent time to hours and minutes
hours = val.to_i
minutesDecimal = (( val - hours) * 60)
minutes = minutesDecimal.to_i
logger.debug "Converted #{val.to_i} hours to #{hours} hours and #{minutes} minutes"
# Lets match the activity to an qbo item
item = item_service.query("SELECT * FROM Item WHERE Name = '#{key}' ").first
next if item.nil?
# Create the new billable time entry and upload it
time_entry.description = "#{self.tracker} ##{self.id}: #{self.subject} #{"(Partial @ #{self.done_ratio}%)" unless self.closed?}"
time_entry.employee_id = self.assigned_to.employee_id
time_entry.customer_id = self.customer_id
time_entry.billable_status = "Billable"
time_entry.hours = hours
time_entry.minutes = minutes
time_entry.name_of = "Employee"
time_entry.txn_date = Date.today
time_entry.hourly_rate = item.unit_price
time_entry.item_id = item.id
time_entry.start_time = start_date
time_entry.end_time = Time.now
time_service.create(time_entry)
end
end
end end
end end.join(' ')
return true
end end
end end
# Create a shareable link for a customer
def share_token def share_token
CustomerToken.get_token self CustomerToken.get_token(self)
end
# Titleize the subject before save , but keep words containing numbers mixed with letters capitalized
def titlize_subject
logger.debug "QBO: Titlizing subject for issue ##{self.id}"
self.subject = self.subject.split(/\s+/).map do |word|
# If word is NOT purely alphanumeric (contains special chars),
# or is all upper/lower, we can handle it.
# excluding alphanumeric strings with mixed case and numbers (e.g., "ID555ABC") from being altered.
if word =~ /[A-Z]/ && word =~ /[0-9]/
word
else
word.downcase
word.capitalize
end
end.join(' ')
end end
end end
# Add module to Issue
Issue.send(:include, IssuePatch) Issue.send(:include, IssuePatch)
end end
end end