Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1d8e048
Add QBO bill account mapping engine design spec
hhff Jun 10, 2026
7eae249
Drop studio level from mapping engine; snapshot studio routing to con…
hhff Jun 10, 2026
3129b1f
Add QBO bill account mapping engine implementation plan
hhff Jun 10, 2026
04c18de
Add QboChartAccount mirror of the QBO chart of accounts
hhff Jun 10, 2026
b784e8f
Restore hand-maintained composite-FK comments in schema.rb
hhff Jun 10, 2026
6f7b303
Align QboChartAccount with sibling mirror models; test active scope
hhff Jun 10, 2026
582a346
Add QboAccount#sync_all_chart_accounts! mirror sync
hhff Jun 10, 2026
2db083a
Wire chart-of-accounts mirror sync into daily task and enterprise admin
hhff Jun 10, 2026
302a59c
Guard refresh_chart_accounts endpoint; re-prime mirror when no active…
hhff Jun 10, 2026
405fd51
Add QboBillAccountMapping rules table with strict chart-account valid…
hhff Jun 10, 2026
014fae5
Make enterprise.qbo_account deterministic; cover mapping levels and s…
hhff Jun 10, 2026
4b2eec6
Add Qbo::BillAccountResolver with strict project->contributor->entity…
hhff Jun 10, 2026
16d3166
Document qbo_id contract; test cross-subject isolation and missing-mi…
hhff Jun 10, 2026
01bd013
Route default bill lines through Qbo::BillAccountResolver; delete fin…
hhff Jun 10, 2026
af7ccd1
Surface UnmappedLineItemError as admin flash on manual bill syncs
hhff Jun 10, 2026
c9cb0e9
Split contributor payout bill lines per (bucket x project tracker) vi…
hhff Jun 10, 2026
a08dc6f
Collapse negative per-tracker payout lines; flash unmapped errors on …
hhff Jun 10, 2026
a614a60
Resolve PayStub bill lines per project tracker via the mapping engine
hhff Jun 10, 2026
d66c800
Use bill_line_item_key in PayStub line resolution; pin nil-project gr…
hhff Jun 10, 2026
05860cd
Delete Studio#qbo_subcontractors_categories (folded into mapping seeds)
hhff Jun 10, 2026
04c38ad
Add idempotent seeding of bill account mappings from legacy routing
hhff Jun 10, 2026
23e6537
Isolate per-enterprise seed failures; restore 5710/6120 fallback parity
hhff Jun 10, 2026
8dbb0f8
Add admin UI for QBO bill account mappings
hhff Jun 10, 2026
01a2f13
Drop dead rescue around lazy includes; preload forecast_person in map…
hhff Jun 10, 2026
4cd24db
Amend spec: explicit subject FKs, seed-via-rake, routing nuances
hhff Jun 10, 2026
0f343c1
Spec touch-ups: sync method name, enterprise panel description
hhff Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/admin/contributor_adjustments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
admin_ledger_contributor_adjustment_path(adj.ledger, adj),
notice: "Success"
)
rescue Qbo::UnmappedLineItemError => e
# Unmapped is an expected operational state (new enterprise, pre-seed
# window) — surface the actionable message instead of a 500.
redirect_to admin_ledger_contributor_adjustment_path(adj.ledger, adj), alert: e.message
end

controller do
Expand Down
4 changes: 4 additions & 0 deletions app/admin/contributor_payouts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
admin_invoice_tracker_contributor_payout_path(cp.invoice_tracker, cp),
notice: "Success",
)
rescue Qbo::UnmappedLineItemError => e
# Unmapped is an expected operational state (new enterprise, pre-seed
# window) — surface the actionable message instead of a 500.
redirect_to admin_invoice_tracker_contributor_payout_path(cp.invoice_tracker, cp), alert: e.message
end

member_action :remap_blueprint_entry, method: :post do
Expand Down
19 changes: 19 additions & 0 deletions app/admin/contributors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,25 @@ def manual_deel_invoice_visible?(contributor)
)
end

sidebar "QBO Bill Account Mappings", only: :show do
mappings = QboBillAccountMapping.where(contributor_id: resource.id).includes(:enterprise)
if mappings.any?
table_for mappings do
column("Enterprise") { |m| m.enterprise.name }
column("Line item", :line_item_key)
column("Account") { |m| m.chart_account&.display_label || m.qbo_chart_account_qbo_id }
column("") { |m| link_to "Edit", edit_admin_qbo_bill_account_mapping_path(m) }
end
else
para "No contributor-specific account overrides."
end
div do
link_to "Add override", new_admin_qbo_bill_account_mapping_path(
qbo_bill_account_mapping: { contributor_id: resource.id },
)
end
end

form do |f|
f.inputs do
f.input :forecast_person, input_html: { disabled: true }
Expand Down
74 changes: 73 additions & 1 deletion app/admin/enterprises.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,28 @@
link_to "Regenerate Data", trigger_generate_snapshot_admin_enterprise_path(resource), method: :post
end

action_item :refresh_chart_accounts, only: :show, if: proc { resource.qbo_account.present? } do
link_to "Refresh Chart of Accounts", refresh_chart_accounts_admin_enterprise_path(resource), method: :post
end

member_action :trigger_generate_snapshot, method: :post do
resource.qbo_account.sync_all!
resource.qbo_account.sync_all_chart_accounts!
resource.generate_snapshot!
redirect_to admin_enterprise_path(resource), notice: "Regenerated!"
end

member_action :refresh_chart_accounts, method: :post do
# The action_item is hidden without a qbo_account, but the endpoint is
# still reachable by direct POST — guard rather than 500.
if resource.qbo_account.blank?
redirect_to admin_enterprise_path(resource), alert: "No QBO account configured."
next
end
resource.qbo_account.sync_all_chart_accounts!
redirect_to admin_enterprise_path(resource), notice: "Chart of accounts refreshed from QuickBooks."
end

index download_links: false do
column :name
column :last_generated do |resource|
Expand Down Expand Up @@ -166,6 +182,56 @@ def update
next
end

panel "QBO Bill Account Mappings" do
defaults = QboBillAccountMapping
.where(enterprise: resource, contributor_id: nil, project_tracker_id: nil)
.index_by(&:line_item_key)
chart_by_qbo_id = QboChartAccount
.where(qbo_account_id: resource.qbo_account.id)
.index_by(&:qbo_id)

table_for QboBillAccountMapping::LINE_ITEM_KEYS do
column("Line item") { |key| key }
column("Entity default account") do |key|
m = defaults[key]
if m.nil?
status_tag("unmapped", class: "error")
else
chart_by_qbo_id[m.qbo_chart_account_qbo_id]&.display_label || m.qbo_chart_account_qbo_id
end
end
column("") do |key|
m = defaults[key]
if m
link_to "Edit", edit_admin_qbo_bill_account_mapping_path(m)
else
link_to "Set", new_admin_qbo_bill_account_mapping_path(
qbo_bill_account_mapping: { enterprise_id: resource.id, line_item_key: key },
)
end
end
end

overrides = QboBillAccountMapping
.where(enterprise: resource)
.where("contributor_id IS NOT NULL OR project_tracker_id IS NOT NULL")
.includes(:contributor, :project_tracker)
if overrides.any?
h4 "Overrides (#{overrides.size})"
table_for overrides.first(25) do
column("Subject") { |m| m.subject_label }
column("Line item", :line_item_key)
column("Account") { |m| chart_by_qbo_id[m.qbo_chart_account_qbo_id]&.display_label || m.qbo_chart_account_qbo_id }
column("") { |m| link_to "Edit", edit_admin_qbo_bill_account_mapping_path(m) }
end
end

div do
link_to "All mappings for this enterprise →",
admin_qbo_bill_account_mappings_path(q: { enterprise_id_eq: resource.id })
end
end

COLORS = Stacks::Utils::COLORS
accounting_method = session[:accounting_method] || "cash"

Expand All @@ -180,7 +246,13 @@ def update
current_gradation =
default_gradation unless all_gradations.include?(current_gradation)

qbo_accounts = resource.qbo_account.fetch_all_accounts
# Read bank/CC balances from the local chart-of-accounts mirror (synced
# daily + via the Refresh / Regenerate actions) instead of a live QBO
# fetch on every page load. Prime the mirror lazily on first view.
if QboChartAccount.active.where(qbo_account_id: resource.qbo_account.id).none?
resource.qbo_account.sync_all_chart_accounts!
end
qbo_accounts = QboChartAccount.active.where(qbo_account_id: resource.qbo_account.id)
cc_or_bank_accounts = qbo_accounts.select do |a|
["Bank", "Credit Card"].include?(a.account_type)
end
Expand Down
19 changes: 19 additions & 0 deletions app/admin/project_trackers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,25 @@ def collection
})
end

sidebar "QBO Bill Account Mappings", only: :show do
mappings = QboBillAccountMapping.where(project_tracker_id: resource.id).includes(:enterprise)
if mappings.any?
table_for mappings do
column("Enterprise") { |m| m.enterprise.name }
column("Line item", :line_item_key)
column("Account") { |m| m.chart_account&.display_label || m.qbo_chart_account_qbo_id }
column("") { |m| link_to "Edit", edit_admin_qbo_bill_account_mapping_path(m) }
end
else
para "No project-specific account overrides."
end
div do
link_to "Add override", new_admin_qbo_bill_account_mapping_path(
qbo_bill_account_mapping: { project_tracker_id: resource.id },
)
end
end

form do |f|
f.inputs(class: "admin_inputs") do

Expand Down
88 changes: 88 additions & 0 deletions app/admin/qbo_bill_account_mappings.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
ActiveAdmin.register QboBillAccountMapping do
menu label: "QBO Account Mappings", parent: "Money"
actions :index, :show, :new, :create, :edit, :update, :destroy
permit_params :enterprise_id, :line_item_key, :project_tracker_id, :contributor_id, :qbo_chart_account_qbo_id

controller do
# Supports prefilled "Add override" links from the Enterprise /
# ProjectTracker / Contributor pages.
def build_new_resource
super.tap do |r|
if params[:qbo_bill_account_mapping].present?
r.assign_attributes(
params.require(:qbo_bill_account_mapping)
.permit(:enterprise_id, :line_item_key, :project_tracker_id, :contributor_id),
)
end
end
end
end

index download_links: false do
column :enterprise
column("Line item", :line_item_key)
column("Subject") { |m| m.subject_label }
column("QBO account") { |m| m.chart_account&.display_label || m.qbo_chart_account_qbo_id }
actions
end

filter :enterprise
filter :line_item_key, as: :select, collection: QboBillAccountMapping::LINE_ITEM_KEYS
filter :project_tracker
filter :contributor

show do
attributes_table do
row :enterprise
row :line_item_key
row("Subject") { |m| m.subject_label }
row("QBO account") { |m| m.chart_account&.display_label || m.qbo_chart_account_qbo_id }
row :created_at
row :updated_at
end
end

form do |f|
# When the enterprise is already known (edit, or prefilled new), scope
# the chart-account options to its realm. qbo_ids are NOT unique across
# realms, so the unscoped fallback prefixes each option with its
# enterprise name — pick one matching the enterprise selected above
# (validation rejects ids absent from the chosen enterprise's realm,
# but cannot catch an id that exists in both realms).
known_enterprise = f.object.enterprise
chart_options =
if known_enterprise&.qbo_account
QboChartAccount.active
.where(qbo_account_id: known_enterprise.qbo_account.id)
.order(:name)
.map { |a| [a.display_label, a.qbo_id] }
else
QboChartAccount.active
.includes(qbo_account: :enterprise)
.sort_by { |a| [a.qbo_account.enterprise&.name.to_s, a.name] }
.map { |a| ["#{a.qbo_account.enterprise&.name} — #{a.display_label}", a.qbo_id] }
end

f.inputs(class: "admin_inputs") do
f.semantic_errors
f.input :enterprise, as: :select,
collection: Enterprise.order(:name).pluck(:name, :id),
include_blank: false
f.input :line_item_key, as: :select,
collection: QboBillAccountMapping::LINE_ITEM_KEYS,
include_blank: false
f.input :project_tracker_id, as: :select,
collection: ProjectTracker.order(:name).pluck(:name, :id),
include_blank: "(none — leave blank unless this is a project-tracker override)"
f.input :contributor_id, as: :select,
collection: Contributor.includes(:forecast_person).map { |c| [c.display_name, c.id] },
include_blank: "(none — leave blank unless this is a contributor override)",
hint: "Set a project tracker OR a contributor, not both. Both blank = entity-level default."
f.input :qbo_chart_account_qbo_id, as: :select,
collection: chart_options,
include_blank: "Choose a QBO account…",
label: "QBO chart account"
end
f.actions
end
end
4 changes: 4 additions & 0 deletions app/admin/trueups.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
admin_ledger_trueup_path(tu.ledger, tu),
notice: "Success",
)
rescue Qbo::UnmappedLineItemError => e
# Unmapped is an expected operational state (new enterprise, pre-seed
# window) — surface the actionable message instead of a 500.
redirect_to admin_ledger_trueup_path(tu.ledger, tu), alert: e.message
end

show do
Expand Down
44 changes: 17 additions & 27 deletions app/models/concerns/syncs_as_qbo_bill.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,6 @@ def qbo_url
qbo_bill.try(:qbo_url)
end

# Returns the QBO Account+Studio tuple to bill against. Default impl picks
# "Contractors - Client Services" with a studio-specific subcategory if the
# contributor has a studio. Hosts (e.g., ContributorPayout) may override for
# special-case routing (internal-client → marketing account).
def find_qbo_account!(qbo_accounts = nil)
qa = qbo_account_for_bill
raise "Enterprise #{enterprise&.name.inspect} has no connected QboAccount" if qa.nil?

qbo_accounts ||= qa.fetch_all_accounts
account = qbo_accounts.find { |a| a.name == "Contractors - Client Services" }
studio = contributor.forecast_person.studio
if studio.present?
specific_account = qbo_accounts.find { |a| a.name == studio.qbo_subcontractors_categories.first }
account = specific_account if specific_account.present?
end
raise "No account found in QuickBooks" unless account.present?
[account, studio]
end

def load_qbo_bill!
return nil if qbo_bill_id.blank?
qa = qbo_account_for_bill
Expand Down Expand Up @@ -82,21 +63,27 @@ def load_qbo_bill!
# (must be unique across all host models). Current mappings:
# CP = ContributorPayout, TU = Trueup, CA = ContributorAdjustment,
# PS = ProfitShare, SB = PayStub.
# - bill_line_item_key → QboBillAccountMapping::LINE_ITEM_KEYS entry
# used by the default single-line bill_line_items below. Hosts that
# override bill_line_items (ContributorPayout, PayStub) resolve their
# own per-line keys instead.

def payable?
false
end

# Returns the array of Quickbooks::Model::BillLineItem objects that will
# be pushed for this host's bill. Default implementation produces a single
# line at the host's `find_qbo_account!` result, matching the historic
# behavior for Trueup / ContributorAdjustment / ProfitShare / PayStub.
# ContributorPayout overrides this to break the bill into per-bucket lines.
def bill_line_items(qbo_accounts)
account, _studio = find_qbo_account!(qbo_accounts)
# line at the account resolved by the bill account mapping engine
# (project tracker → contributor → entity default; raises
# Qbo::UnmappedLineItemError when unmapped). ContributorPayout and
# PayStub override this to emit multiple lines.
def bill_line_items
account = Qbo::BillAccountResolver.new(enterprise)
.account_for(bill_line_item_key, contributor: contributor)
line = Quickbooks::Model::BillLineItem.new(description: bill_description, amount: amount)
line.account_based_expense_item! do |detail|
detail.account_ref = Quickbooks::Model::BaseReference.new(account.id)
detail.account_ref = Quickbooks::Model::BaseReference.new(account.qbo_id)
end
[line]
end
Expand All @@ -122,8 +109,11 @@ def sync_qbo_bill!
bill.doc_number = "Stacks_#{id}_#{bill_doc_number_code}" # QBO has a 21-char limit
bill.vendor_ref = Quickbooks::Model::BaseReference.new(vendor.qbo_id)

qbo_accounts = qa.fetch_all_accounts
bill.line_items = bill_line_items(qbo_accounts)
# Lazily prime the chart-of-accounts mirror on first use so a bill sync
# works even before the daily task has run for this realm. (Previously
# every sync did a live fetch_all_accounts here anyway.)
qa.sync_all_chart_accounts! if QboChartAccount.where(qbo_account_id: qa.id).none?
bill.line_items = bill_line_items
bill_service = Quickbooks::Service::Bill.new
bill_service.company_id = qa.realm_id
bill_service.access_token = qa.make_and_refresh_qbo_access_token
Expand Down
4 changes: 4 additions & 0 deletions app/models/contributor_adjustment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ def bill_description
def bill_doc_number_code
"CA"
end

def bill_line_item_key
"contributor_adjustment"
end
end
Loading