Compare commits

...

17 Commits

Author SHA1 Message Date
ricky a97d867610 Fixed the UI, now working 2026-03-05 07:53:23 -05:00
ricky 021a9ec0c6 Added Issue safe attributes for line items 2026-03-05 07:28:30 -05:00
ricky f056c27bc5 Merge branch 'master' into lineitems 2026-03-05 07:08:30 -05:00
ricky 2520892e2c 2026.3.1 2026-03-04 20:14:16 -05:00
ricky b96678a2e9 fixed accident deleteion details_cache_key 2026-03-04 20:09:13 -05:00
ricky bccfcd9dbc cache qbo details to reduce api calls 2026-03-04 20:06:22 -05:00
ricky 8ba99b7db2 Fixed eager loading issues 2026-03-04 19:18:06 -05:00
ricky aff7d0c48e removed uneeded logging of issue and project contents. 2026-03-04 18:37:30 -05:00
ricky e9b3b1c838 Merge branch 'master' into dev 2026-03-04 17:42:42 -05:00
ricky 2fc2f94cd1 Fixed combining of estimate pdf 2026-03-04 13:23:59 -05:00
ricky 681747e08b Got the UI working 2026-03-04 07:55:35 -05:00
ricky 9f9810686f removed logging 2026-03-03 20:36:23 -05:00
ricky f041e1bce4 Added logging for completed pull 2026-03-03 20:05:19 -05:00
ricky d44d5e2fb7 Fixed log prefix 2026-03-03 19:54:50 -05:00
ricky 4403267abb Moved QBO fetch from customer model into service 2026-03-03 19:49:36 -05:00
ricky be400c2b2a Added logging for errors when editing 2026-03-03 19:22:15 -05:00
ricky eb6beea5fa Initial line item idea 2026-03-01 15:04:25 -05:00
18 changed files with 251 additions and 62 deletions
+27 -11
View File
@@ -80,16 +80,31 @@ class CustomersController < ApplicationController
def show
@customer = Customer.find_by_id(params[:id])
return render_404 unless @customer
@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)
@hours = 0
@closed_hours = 0
@issues.open.each { |i| @hours+= i.total_spent_hours }
@closed_issues.each { |i| @closed_hours+= i.total_spent_hours }
@open_issues = @customer.issues
.joins(:status)
.includes(:status, :project, :tracker, :priority)
.where(issue_statuses: { is_closed: false })
.order(id: :desc)
@closed_issues = @customer.issues
.joins(:status)
.includes(:status, :project, :tracker, :priority)
.where(issue_statuses: { is_closed: true })
.order(id: :desc)
@hours = TimeEntry
.joins(:issue)
.where(issues: { id: @open_issues.select(:id) })
.sum(:hours)
@closed_hours = TimeEntry
.joins(:issue)
.where(issues: { id: @closed_issues.select(:id) })
.sum(:hours)
rescue => e
log "Failed to load customer ##{params[:id]}: #{e.message}"
Rails.logger.error "Failed to load customer ##{params[:id]}: #{e.message}\n#{e.backtrace.join("\n")}"
flash[:error] = e.message
render_404
end
@@ -98,8 +113,9 @@ class CustomersController < ApplicationController
def edit
@customer = Customer.find_by_id(params[:id])
return render_404 unless @customer
rescue
flash[:error] = t :notice_customer_not_found
rescue => e
log "Failed to edit customer"
flash[:error] = e.message
render_404
end
+19 -13
View File
@@ -35,7 +35,20 @@ class Customer < ActiveRecord::Base
# Returns the details of the customer. If the details have already been fetched, it returns the cached version. Otherwise, it fetches the details from QuickBooks Online and caches them for future use. This method is used to access the customer's information in a way that minimizes unnecessary API calls to QBO, improving performance and reducing latency.
def details
@details ||= fetch_details
return Quickbooks::Model::Customer.new unless id.present?
@details ||= begin
xml = Rails.cache.fetch(details_cache_key, expires_in: 10.minutes) do
fetch_details.to_xml_ns
end
Quickbooks::Model::Customer.from_xml(xml)
end
end
# Generates a unique cache key for storing this customer's QBO details.
def details_cache_key
"customer:#{id}:qbo_details:#{updated_at.to_i}"
end
# Returns the customer's email address
@@ -50,6 +63,7 @@ class Customer < ActiveRecord::Base
@details.email_address = s
end
# Returns the last sync time formatted for display. If no sync has occurred, returns a default message.
def self.last_sync
return I18n.t(:label_qbo_never_synced) unless maximum(:updated_at)
@@ -164,9 +178,10 @@ class Customer < ActiveRecord::Base
# Push the updates
def save_with_push
qbo = QboConnectionService.current!
log "Starting push for customer ##{self.id}..."
CustomerPushService.new(qbo: qbo, customer: self).push()
qbo = QboConnectionService.current!
CustomerService.new(qbo: qbo, customer: self).push()
Rails.cache.delete(details_cache_key)
save_without_push
end
@@ -180,16 +195,7 @@ class Customer < ActiveRecord::Base
return Quickbooks::Model::Customer.new unless id.present?
log "Fetching details for customer ##{id} from QBO..."
qbo = QboConnectionService.current!
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(
company_id: qbo.realm_id,
access_token: access_token
)
service.fetch_by_id(id)
end
rescue => e
log "Fetch failed for #{id}: #{e.message}"
Quickbooks::Model::Customer.new
CustomerService.new(qbo: qbo, customer: self).pull()
end
# Log messages with the entity type for better traceability
+7
View File
@@ -0,0 +1,7 @@
class LineItem < ApplicationRecord
belongs_to :issue
validates :description, presence: true
validates :quantity, numericality: { greater_than: 0 }
validates :unit_price, numericality: { greater_than_or_equal_to: 0 }
end
@@ -8,7 +8,7 @@
#
#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 CustomerPushService
class CustomerService
# Initializes the service with a QBO client and an optional customer record. The QBO client is used to communicate with QuickBooks Online, while the customer record contains the data that needs to be pushed to QBO. If no customer is provided, the service will not perform any operations.
def initialize(qbo:, customer: nil)
@@ -18,6 +18,23 @@ class CustomerPushService
@customer = customer
end
# Pulls the customer data from QuickBooks Online.
def pull
return Quickbooks::Model::Customer.new unless @customer.present?
log "Fetching details for customer ##{@customer.id} from QBO..."
qbo = QboConnectionService.current!
qbo.perform_authenticated_request do |access_token|
service = Quickbooks::Service::Customer.new(
company_id: qbo.realm_id,
access_token: access_token
)
service.fetch_by_id(@customer.id)
end
rescue => e
log "Fetch failed for #{@customer.id}: #{e.message}"
Quickbooks::Model::Customer.new
end
# Pushes the customer data to QuickBooks Online. This method handles the communication with QBO, including authentication and error handling. It uses the QBO client to send the customer data and logs the process for monitoring and debugging purposes. If the push is successful, it returns the customer record; otherwise, it logs the error and returns false.
def push
log "Pushing customer ##{@customer.id} to QBO..."
@@ -39,7 +56,7 @@ class CustomerPushService
# Log messages with the entity type for better traceability
def log(msg)
Rails.logger.info "[CustomerPushService] #{msg}"
Rails.logger.info "[CustomerService] #{msg}"
end
end
+2 -2
View File
@@ -46,8 +46,8 @@
</div>
<br/>
<h3><%=@issues.open.count%> <%=t(:label_open_issues)%> - <%=@hours.round(1)%> <%=t(:label_hours)%></h3>
<%= render partial: 'issues/list_simple', locals: {issues: @issues.open} %>
<h3><%=@open_issues.count%> <%=t(:label_open_issues)%> - <%=@hours.round(1)%> <%=t(:label_hours)%></h3>
<%= render partial: 'issues/list_simple', locals: {issues: @open_issues.open} %>
<h3><%=@closed_issues.count%> <%=t(:label_closed_issues)%> - <%= @closed_hours.round(1)%> <%=t(:label_hours)%></h3>
<%= render partial: 'issues/list_simple', locals: {issues: @closed_issues} %>
+3
View File
@@ -7,3 +7,6 @@
<p>
<%= select_estimate %>
</p>
<%= render "line_items/issue_form", f: f %>
+34
View File
@@ -0,0 +1,34 @@
<% @issue.line_items.build if @issue.line_items.empty? %>
<div class="box tabular" data-nested-form data-wrapper-selector=".line-item">
<p><strong>Line Items</strong></p>
<table class="list line-items-table">
<thead>
<tr>
<th>Description</th>
<th style="width:120px;">Quantity</th>
<th style="width:150px;">Unit Price</th>
<th style="width:80px;"></th>
</tr>
</thead>
<tbody data-nested-form-container>
<%= f.fields_for :line_items do |item_form| %>
<%= render "line_items/line_item_fields", f: item_form %>
<% end %>
</tbody>
</table>
<template data-nested-form-template>
<%= f.fields_for :line_items, LineItem.new, child_index: "NEW_RECORD" do |item_form| %>
<%= render "line_items/line_item_fields", f: item_form %>
<% end %>
</template>
<p>
<button type="button" class="icon icon-add" data-nested-form-add>
Add Line Item
</button>
</p>
</div>
@@ -0,0 +1,34 @@
<tr class="line-item">
<%= f.hidden_field :id %>
<%= f.hidden_field :_destroy %>
<td>
<%= f.text_field :description,
size: 50,
placeholder: "Description",
:no_label => true %>
</td>
<td>
<%= f.number_field :quantity,
step: 1,
min: 1,
style: "width:90px;",
:no_label => true %>
</td>
<td>
<%= f.number_field :unit_price,
step: 0.01,
style: "width:120px;",
:no_label => true %>
</td>
<td style="text-align:center;">
<button type="button"
class="icon-only icon-del"
title="Remove"
data-nested-form-remove>
</button>
</td>
</tr>
@@ -0,0 +1,53 @@
(function () {
function initNestedForms() {
document.querySelectorAll("[data-nested-form]").forEach(function (wrapper) {
if (wrapper.dataset.initialized === "true") return;
wrapper.dataset.initialized = "true";
const container = wrapper.querySelector("[data-nested-form-container]");
const template = wrapper.querySelector("[data-nested-form-template]");
if (!container || !template) return;
wrapper.addEventListener("click", function (event) {
const addButton = event.target.closest("[data-nested-form-add]");
const removeButton = event.target.closest("[data-nested-form-remove]");
// ADD
if (addButton) {
event.preventDefault();
const content = template.innerHTML.replace(
/NEW_RECORD/g,
Date.now().toString()
);
container.insertAdjacentHTML("beforeend", content);
}
// REMOVE
if (removeButton) {
event.preventDefault();
const lineItem = removeButton.closest(wrapper.dataset.wrapperSelector);
if (!lineItem) return;
const destroyField = lineItem.querySelector("input[name*='_destroy']");
if (destroyField) {
destroyField.value = "1";
lineItem.style.display = "none";
} else {
lineItem.remove();
}
}
});
});
}
// Works for full load
document.addEventListener("DOMContentLoaded", initNestedForms);
// Works for Turbo navigation
document.addEventListener("turbo:load", initNestedForms);
})();
-26
View File
@@ -1,26 +0,0 @@
#The License
#
#Copyright (c) 2016 - 2026 Rick Barrette - All Rights Reserved
#
#Unauthorized copying of this software and associated documentation files (the "Software"), via any medium is strictly prohibited.
#
#Proprietary and confidential
#
#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 CreateLineItems < ActiveRecord::Migration[5.1]
def change
create_table :line_items do |t|
t.integer :item_id
t.float :amount
t.string :description
t.float :unit_price
t.float :quantity
t.boolean :billed
end
add_reference :line_items, :issues, index: true
end
end
-1
View File
@@ -12,6 +12,5 @@ class RemoveQboItems < ActiveRecord::Migration[5.1]
def change
drop_table :qbo_items
drop_table :qbo_purchases
drop_table :line_items
end
end
+42
View File
@@ -0,0 +1,42 @@
#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 CreateLineItems < ActiveRecord::Migration[7.0]
def change
create_table :line_items do |t|
t.integer :issue_id, null: false
t.text :description, null: false
t.decimal :quantity,
precision: 15,
scale: 4,
null: false,
default: 0
t.decimal :unit_price,
precision: 15,
scale: 4,
null: false,
default: 0
t.decimal :line_total,
precision: 15,
scale: 4,
null: false,
default: 0
t.timestamps
end
add_index :line_items, :issue_id
add_foreign_key :line_items, :issues
end
end
+2 -1
View File
@@ -14,7 +14,7 @@ Redmine::Plugin.register :redmine_qbo do
name 'Redmine QBO plugin'
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'
version '2026.3.0'
version '2026.3.1'
url 'https://github.com/rickbarrette/redmine_qbo'
author_url 'https://barrettefabrication.com'
settings default: {empty: true}, partial: 'qbo/settings'
@@ -25,6 +25,7 @@ Redmine::Plugin.register :redmine_qbo do
Issue.safe_attributes :estimate_id
Issue.safe_attributes :invoice_id
User.safe_attributes :employee_id
Issue.safe_attributes :line_items_attributes
TimeEntry.safe_attributes :billed
# set per_page globally
@@ -21,8 +21,6 @@ module RedmineQbo
f = context[:form]
issue = context[:issue]
project = context[:project]
log issue.inspect
log project.inspect
# Customer Name Text Box with database backed autocomplete
# onchange event will update the hidden customer_id field
@@ -64,7 +62,8 @@ module RedmineQbo
locals: {
search_customer: search_customer,
customer_id: customer_id,
select_estimate: select_estimate
select_estimate: select_estimate,
f: context[:form]
}
}
)
+2 -1
View File
@@ -18,7 +18,8 @@ module RedmineQbo
safe_join([
javascript_include_tag( 'application.js', plugin: :redmine_qbo),
javascript_include_tag( 'autocomplete-rails.js', plugin: :redmine_qbo),
javascript_include_tag( 'checkbox_controller.js', plugin: :redmine_qbo)
javascript_include_tag( 'checkbox_controller.js', plugin: :redmine_qbo),
javascript_include_tag( 'nested_form_controller.js', plugin: :redmine_qbo)
])
end
+2
View File
@@ -23,6 +23,8 @@ module RedmineQbo
belongs_to :customer_token, primary_key: :id
belongs_to :estimate, primary_key: :id
has_and_belongs_to_many :invoices
has_many :line_items, dependent: :destroy
accepts_nested_attributes_for :line_items, allow_destroy: true
before_save :titlize_subject
after_commit :enqueue_billing, on: :update
+2 -1
View File
@@ -260,8 +260,9 @@ module RedmineQbo
# Check to see if there is an estimate attached, then combine them
if issue.estimate
e_pdf, ref = EstimatePdfService.new(qbo: QboConnectionService.current!).fetch_pdf(doc_ids: [issue.estimate.id])
pdf = CombinePDF.parse(pdf.output, allow_optional_content: true)
pdf << CombinePDF.parse(issue.estimate.pdf)
pdf << CombinePDF.parse(e_pdf)
return pdf.to_pdf
end