23 Commits

Author SHA1 Message Date
956ba2ad46 Version 1.1.0 2022-02-21 06:25:51 -05:00
3ae3107760 desc sort issues, estimates, & invoices 2022-02-21 06:22:13 -05:00
925d4b8bcf Updated comments & removed unused code 2022-02-21 06:06:24 -05:00
ca6dbfd12d Removed duplicate private declaration 2022-02-21 05:26:54 -05:00
9ea03d0c6d Removed sync on view 2022-02-21 05:23:11 -05:00
6ad4929d53 Sync Estimates & Invoices on database update 2022-02-21 05:17:35 -05:00
446f419af0 Sync invoice when viewing 2022-02-20 19:37:03 -05:00
f3c5de82e0 Bug fix 2022-02-20 18:02:37 -05:00
56e24752cf Import invoice fix 2022-02-20 17:53:01 -05:00
255af13b20 Add txn_date to invoice & estimate databse 2022-02-20 17:40:41 -05:00
02b4f1eb43 Added Invoice date 2022-02-20 17:26:24 -05:00
8c735d3921 Added Estimate date 2022-02-20 17:19:56 -05:00
70e6038215 Moved customer issue counts 2022-02-20 15:22:31 -05:00
fc7501c4fe address not a 2022-02-20 14:57:21 -05:00
45b60cfea1 PhysicalAddress to_s 2022-02-20 14:52:11 -05:00
09313ad471 exclude before filter for customer/view 2022-02-20 13:42:06 -05:00
1b15aecbff Disable autocomplete suggestions for search 2022-02-20 08:11:57 -05:00
2bea7dbc8d fixed customer link - missing view issues/history 2022-02-20 07:56:25 -05:00
3468b5f236 Open links in new window 2022-02-19 22:53:39 -05:00
1c431d14dc remove gem faraday_middleware & set oauth2 1.4.7 2022-02-19 22:48:44 -05:00
7234a70265 Added allowed params for qbo controller 2022-02-19 21:47:12 -05:00
a459d84b00 Added Estimate & Invoice List to Customer view 2022-02-19 21:19:08 -05:00
49d2ed8244 Readme update 2022-02-19 20:56:54 -05:00
16 changed files with 250 additions and 74 deletions

View File

@@ -1,13 +1,12 @@
source 'https://rubygems.org'
gem 'quickbooks-ruby'
gem 'oauth2'
gem 'oauth2', '1.4.7'
gem 'roxml'
gem 'nhtsa_vin'
gem 'will_paginate'
gem 'rails-jquery-autocomplete'
gem 'jquery-ui-rails'
gem 'faraday_middleware', '1.2.0'
group :assets do
gem 'coffee-rails'

View File

@@ -8,7 +8,7 @@ The goal of this project is to allow Redmine to connect with Quickbooks Online t
Note: Although the core functionality is complete, this project is still under heavy development. I am still working on refining everthing and adding other features. Tags should be stable
Also worth metioning I am currently using this in a live production enviroment with no issues
Use tags Version 1.0.0 & up for Redmine 4+ and Version 0.8.1 for Redine 3
#### Features
* Issues can be assigned to a `Customer` via drop down in the edit Issue form
@@ -52,11 +52,6 @@ Also worth metioning I am currently using this in a live production enviroment w
5. Assign an Employee to each of your users via the User Administration Page
## Automatic Deploy
If you want the redmine server to be automaticly restarted after a git pull event add this hook to your git hook directory
https://gist.github.com/rickbarrette/3c999c7f37e321f9c60380de99e494f5
## Usage
To enable automatic `Time Activity` entries for an Issue , you need only to assign a `Customer` to an Issue via drop downs in the issue creation/update form.
@@ -79,7 +74,7 @@ Note: After the inital synchronization, this plugin will recieve push notificati
The MIT License (MIT)
Copyright (c) 2020 rick barrette
Copyright (c) 2022 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:

View File

@@ -28,7 +28,7 @@ class CustomersController < ApplicationController
helper :timelog
before_action :add_customer, :only => :new
before_action :view_customer, :except => :new
before_action :view_customer, :except => [:new, :view]
skip_before_action :verify_authenticity_token, :check_if_login_required, :only => [:view]
default_search_scope :names
@@ -89,7 +89,10 @@ class CustomersController < ApplicationController
begin
@customer = Customer.find_by_id(params[:id])
@vehicles = @customer.vehicles.paginate(:page => params[:page])
@issues = @customer.issues
@issues = @customer.issues.order(id: :desc)
@billing_address = address_to_s(@customer.billing_address)
@shipping_address = address_to_s(@customer.shipping_address)
@closed_issues = (@issues - @issues.open)
rescue ActiveRecord::RecordNotFound
render_404
end
@@ -189,4 +192,18 @@ class CustomersController < ApplicationController
found_non_zero
end
# format a quickbooks address to a human readable string
def address_to_s (address)
return if address.nil?
string = address.line1
string << "\n" + address.line2 if address.line2
string << "\n" + address.line3 if address.line3
string << "\n" + address.line4 if address.line4
string << "\n" + address.line5 if address.line5
string << " " + address.city
string << ", " + address.country_sub_division_code
string << " " + address.postal_code
return string
end
end

View File

@@ -18,6 +18,10 @@ class QboController < ApplicationController
before_action :require_user, :except => :qbo_webhook
skip_before_action :verify_authenticity_token, :check_if_login_required, :only => [:qbo_webhook]
def allowed_params
params.permit(:code, :state, :realmId, :id)
end
#
# Called when the QBO Top Menu us shown
#

View File

@@ -53,6 +53,7 @@ class QboEstimate < ActiveRecord::Base
qbo_estimate.doc_number = estimate.doc_number
qbo_estimate.customer_id = estimate.customer_ref.value
qbo_estimate.id = estimate.id
qbo_estimate.txn_date = estimate.txn_date
qbo_estimate.save!
end
@@ -62,5 +63,35 @@ class QboEstimate < ActiveRecord::Base
estimate = base.fetch_by_id(id)
return base.pdf(estimate)
end
# Magic Method
# Maps Get/Set methods to QBO estimate object
def method_missing(sym, *arguments)
# Check to see if the method exists
if Quickbooks::Model::Estimate.method_defined?(sym)
# download details if required
pull unless @details
method_name = sym.to_s
# Setter
if method_name[-1, 1] == "="
@details.method(method_name).call(arguments[0])
# Getter
else
return @details.method(method_name).call
end
end
end
private
# pull the details
def pull
begin
raise Exception unless self.id
@details = Qbo.get_base(:estimate).fetch_by_id(self.id)
rescue Exception => e
@details = Quickbooks::Model::Estimate.new
end
end
end

View File

@@ -10,13 +10,12 @@
class QboInvoice < ActiveRecord::Base
unloadable
has_and_belongs_to_many :issues
belongs_to :customer
#attr_accessible :doc_number, :id
validates_presence_of :doc_number, :id
validates_presence_of :doc_number, :id, :customer_id, :txn_date
self.primary_key = :id
# Get the quickbooks-ruby base for invoice
def self.get_base
Qbo.get_base(:invoice)
end
@@ -25,17 +24,18 @@ class QboInvoice < ActiveRecord::Base
def self.sync
logger.debug "Syncing all invoices"
last = Qbo.first.last_sync
query = "SELECT Id, DocNumber FROM Invoice"
query << " WHERE Metadata.LastUpdatedTime >= '#{last.iso8601}' " if last
# TODO actually do something with the above query
# .all() is never called since count is never initialized
if count == 0
invoices = get_base.all
else
invoices = get_base.query()
end
# Update the invoice table
invoices.each { | invoice |
process_invoice invoice
}
@@ -44,14 +44,13 @@ class QboInvoice < ActiveRecord::Base
#sync by invoice ID
def self.sync_by_id(id)
logger.debug "Syncing invoice #{id}"
#update the information in the database
invoice = get_base.fetch_by_id(id)
process_invoice invoice
end
private
# Attach the invoice to the issue
# Attach the invoice to the issue
def self.attach_to_issue(issue, invoice)
return if issue.nil?
@@ -59,14 +58,9 @@ class QboInvoice < ActiveRecord::Base
return if issue.customer_id != invoice.customer_ref.value.to_i
logger.debug "Attaching invoice #{invoice.id} to issue #{issue.id}"
# Load the invoice into the database
qbo_invoice = QboInvoice.find_or_create_by(id: invoice.id)
qbo_invoice.doc_number = invoice.doc_number
qbo_invoice.id = invoice.id
qbo_invoice.customer_id = invoice.customer_ref
qbo_invoice.save!
qbo_invoice = QboInvoice.find_or_create_by(id: invoice.id)
unless issue.qbo_invoices.include?(qbo_invoice)
issue.qbo_invoices << qbo_invoice
issue.save!
@@ -75,10 +69,19 @@ class QboInvoice < ActiveRecord::Base
compare_custom_fields(issue, invoice)
end
# processes the invoice into the system
# processes the invoice into the database
def self.process_invoice(invoice)
logger.debug "Processing invoice"
# Check the private notes
# Load the invoice into the database
qbo_invoice = QboInvoice.find_or_create_by(id: invoice.id)
qbo_invoice.doc_number = invoice.doc_number
qbo_invoice.id = invoice.id
qbo_invoice.customer_id = invoice.customer_ref
qbo_invoice.txn_date = invoice.txn_date
qbo_invoice.save!
# Scan the private notes for hashtags and attach to the applicable issues
if not invoice.private_note.nil?
invoice.private_note.scan(/#(\w+)/).flatten.each { |issue|
attach_to_issue(Issue.find_by_id(issue.to_i), invoice)
@@ -95,15 +98,21 @@ class QboInvoice < ActiveRecord::Base
}
end
# compares the custome fields on invoices & issues and updates the invoice as needed
#
# the issue here is when two or more issues share an invoice with the same custom field, but diffrent values
# this condions causes an infinite loop as the webhook is called when an invoice is updated
# TODO maybe add a cf_sync_confict flag to invoices
def self.compare_custom_fields(issue, invoice)
# TODO break if QboInvoice.find(invoice.id).cf_sync_confict
is_changed = false
# update the invoive custom fields with infomation from the work ticket if available
# update the invoive custom fields with infomation from the issue if available
invoice.custom_fields.each { |cf|
# TODO Add some hooks here
# VIN from the attached vehicle
# TODO move this into seperate plugin
# TODO create hook for seperate plugin
begin
if cf.name.eql? "VIN"
vin = Vehicle.find(issue.vehicles_id).vin
@@ -126,43 +135,55 @@ class QboInvoice < ActiveRecord::Base
if not value.value.to_s.blank?
# Check to see if the value is diffrent
if not cf.string_value.to_s.eql? value.value.to_s
# Use the lowest Milage
if cf.name.eql? "Mileage In"
if cf.string_value.to_i > value.value.to_i or cf.string_value.blank?
cf.string_value = value.value.to_s
is_changed = true
end
# Use the max milage
elsif cf.name.eql? "Mileage Out"
if cf.string_value.to_i < value.value.to_i or cf.string_value.blank?
cf.string_value = value.value.to_s
is_changed = true
end
else
# Everything else
cf.string_value = value.value.to_s
is_changed = true
end
# update the custom field on the invoice
cf.string_value = value.value.to_s
is_changed = true
end
end
rescue
# Nothing to do here, there is no match
end
}
# TODO Add some hooks here
# Push updates
#invoice.sync_token += 1 if is_changed
begin
logger.debug "Trying to update invoice"
get_base.update(invoice) if is_changed
rescue
# Do nothing, probaly too many vehicles on the invoice. This is a problem with how it's billed
# Do nothing, probaly custome field sync confict on the invoice.
# This is a problem with how it's billed
# TODO Add notes in memo area
# TODO flag QboInvoice.cf_sync_confict here
logger.error "Failed to update invoice"
end
end
# Magic Method
# Maps Get/Set methods to QBO invoice object
def method_missing(sym, *arguments)
# Check to see if the method exists
if Quickbooks::Model::Invoice.method_defined?(sym)
# download details if required
pull unless @details
method_name = sym.to_s
# Setter
if method_name[-1, 1] == "="
@details.method(method_name).call(arguments[0])
# Getter
else
return @details.method(method_name).call
end
end
end
# pull the details from quickbooks
def pull
begin
raise Exception unless self.id
@details = Qbo.get_base(:invoice).fetch_by_id(self.id)
rescue Exception => e
@details = Quickbooks::Model::Invoice.new
end
end
end

View File

@@ -17,17 +17,12 @@
<tr>
<th><%=t(:label_billing_address)%></th>
<td><%= customer.billing_address %></td>
<td><%= @billing_address %></td>
</tr>
<tr>
<th><%=t(:label_shipping_address)%></th>
<td><%= customer.shipping_address %></td>
</tr>
<tr>
<th><%=t(:issues)%></th>
<td><%= customer.issues.count %></td>
<td><%= @shipping_address %></td>
</tr>
<tr>
@@ -35,11 +30,6 @@
<td>$<%= customer.balance %></td>
</tr>
<tr>
<th><%=t(:label_balance_with_jobs)%></th>
<td>$<%= customer.balance_with_jobs %></td>
</tr>
<tr>
<th><%=t(:field_notes)%></th>
<td><%= customer.notes %></td>

View File

@@ -1,5 +1,5 @@
<%= form_tag(customers_path, :method => "get", id: "search-form") do %>
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_customers) %>
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_customers), :autocomplete => "off" %>
<%= submit_tag t(:label_search) %>
<% end %>
<%= button_to t(:label_new_customer), new_customer_path, method: :get%>

View File

@@ -2,27 +2,42 @@
<br/>
<div class="subject">
<div><h3><%=t(:label_details)%>:</h3></div>
<div><h4><%=t(:label_details)%>:</h4></div>
</div>
<div class="attributes">
<div class="splitcontent">
<div class="splitcontentleft">
<%= render :partial => 'customers/details', locals: {customer: @customer} %>
<div class="splitcontent">
<div class="splitcontentleft">
<h4><%=t(:estimates)%>:</h4>
<%= render :partial => 'estimates/list', locals: {customer: @customer} %>
</div>
<div class="splitcontentleft">
<h4><%=t(:label_invoices)%>:</h4>
<%= render :partial => 'invoices/list', locals: {customer: @customer} %>
</div>
</div>
</div>
<div class="splitcontentleft">
<h4><%=t(:field_vehicles)%>:</h4>
<%= render :partial => 'vehicles/list' %>
<%= button_to "New Vehicle", new_customer_vehicle_path(@customer), method: :get %>
<%= button_to "New Vehicle", new_customer_vehicle_path(@customer), method: :get %>
</div>
</div>
<br/>
<h2><%=t(:label_open_issues)%>:</h2>
<h2><%=@issues.open.count%> <%=t(:label_open_issues)%>:</h2>
<%= render :partial => 'issues/list_simple', locals: {issues: @issues.open} %>
<h2><%=t(:label_closed_issues)%>:</h2>
<%= render :partial => 'issues/list_simple', locals: {issues: (@issues - @issues.open)} %>
<h2><%=@closed_issues.count%> <%=t(:label_closed_issues)%>:</h2>
<%= render :partial => 'issues/list_simple', locals: {issues: @closed_issues} %>
</div>

View File

@@ -0,0 +1,11 @@
<% if @customer.present? %>
<% @customer.qbo_estimates.order(doc_number: :desc).each do |estimate| %>
<div class="row">
<b><%= link_to "##{estimate.doc_number}", estimate_path(estimate), target: :_blank %></b> <%= estimate.txn_date %>
</div>
<% end %>
<% else %>
<p><%=t(:label_no_estimates)%>.</p>
<% end %>

View File

@@ -1,4 +1,4 @@
<%= form_tag("/qbo/estimate/doc", :method => "get", id: "est-search-form") do %>
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_estimates) %>
<%= text_field_tag :search, params[:search], placeholder: t(:label_search_estimates), :autocomplete => "off" %>
<%= submit_tag t(:label_search), :formtarget => "_blank" %>
<% end %>

View File

@@ -0,0 +1,11 @@
<% if @customer.present? %>
<% @customer.qbo_invoices.order(doc_number: :desc).each do |invoice| %>
<div class="row">
<b><%= link_to "##{invoice.doc_number}", invoice_path(invoice), target: :_blank %></b> <%= invoice.txn_date %>
</div>
<% end %>
<% else %>
<p><%=t(:label_no_invoices)%>.</p>
<% end %>

View File

@@ -0,0 +1,35 @@
<% reply_links = issue.notes_addable? -%>
<% for journal in journals %>
<div id="change-<%= journal.id %>" class="<%= journal.css_classes %>">
<div id="note-<%= journal.indice %>">
<div class="contextual">
<span class="journal-actions"><%= render_journal_actions(issue, journal, :reply_links => reply_links) %></span>
<a href="#note-<%= journal.indice %>" class="journal-link">#<%= journal.indice %></a>
</div>
<h4>
<%= avatar(journal.user, :size => "24") %>
<%= authoring journal.created_on, journal.user, :label => :label_updated_time_by %>
<%= render_private_notes_indicator(journal) %>
</h4>
<% if journal.details.any? %>
<ul class="details">
<% details_to_strings(journal.visible_details).each do |string| %>
<li><%= string %></li>
<% end %>
</ul>
<% if Setting.thumbnails_enabled? && (thumbnail_attachments = journal_thumbnail_attachments(journal)).any? %>
<div class="thumbnails">
<% thumbnail_attachments.each do |attachment| %>
<div><%= thumbnail_tag(attachment) %></div>
<% end %>
</div>
<% end %>
<% end %>
<%= render_notes(issue, journal, :reply_links => reply_links) unless journal.notes.blank? %>
</div>
</div>
<%= call_hook(:view_issues_history_journal_bottom, { :journal => journal }) %>
<% end %>
<% heads_for_wiki_formatter if User.current.allowed_to?(:edit_issue_notes, issue.project) || User.current.allowed_to?(:edit_own_issue_notes, issue.project) %>

View File

@@ -73,4 +73,7 @@ en:
label_webhook_token: "Intuit QBO Webhook Token"
label_oauth_expires: "OAuth2 Access Token Expires At"
label_oauth_note: "Note: You need to authenticate with Quickbooks after saving your key and secret above"
field_customers: "Customers"
field_customers: "Customers"
label_no_estimates: "No Estimates"
label_no_invoices: "No Invoices"
label_invoices: "Invoices"

View File

@@ -0,0 +1,44 @@
#The MIT License (MIT)
#
#Copyright (c) 2022 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 AddTxnDates < ActiveRecord::Migration[5.1]
def change
add_column :qbo_invoices, :txn_date, :date
add_column :qbo_estimates, :txn_date, :date
reversible do |direction|
direction.up {
break unless Qbo.first
QboEstimate.reset_column_information
QboInvoice.reset_column_information
say "Sync Estimates"
QboEstimate.sync
say "Sync Invoices"
invoices = QboInvoice.get_base.all
invoices.each { |invoice|
# Load the invoice into the database
qbo_invoice = QboInvoice.find_or_create_by(id: invoice.id)
qbo_invoice.doc_number = invoice.doc_number
qbo_invoice.id = invoice.id
qbo_invoice.customer_id = invoice.customer_ref
qbo_invoice.txn_date = invoice.txn_date
qbo_invoice.save!
}
}
end
end
end

View File

@@ -32,7 +32,7 @@ Redmine::Plugin.register :redmine_qbo do
name 'Redmine Quickbooks Online plugin'
author 'Rick Barrette'
description 'This is a plugin for Redmine to intergrate with Quickbooks Online to allow for seamless intergration CRM and invoicing of completed issues'
version '1.0.0'
version '1.1.0'
url 'https://github.com/rickbarrette/redmine_qbo'
author_url 'http://rickbarrette.org'
settings :default => {'empty' => true}, :partial => 'qbo/settings'