Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
678aa80
Add LedgerWithdrawalRequest model + join table
hhff Jun 6, 2026
10fe358
Add contributor-facing selection UI for withdrawal requests
hhff Jun 6, 2026
75fc10c
Add controller process actions, auto-process sync, and admin nag task
hhff Jun 6, 2026
ea9ed2f
Render the selection screen inside ActiveAdmin's form DSL
hhff Jun 6, 2026
940cf53
Drop "Mark Processed" escape hatch, prompt for cancellation reason
hhff Jun 6, 2026
5d4f162
Fail fast with a friendly screen when QBO vendor isn't mapped
hhff Jun 6, 2026
43d0f92
Render missing-vendor screen under ActiveAdmin's layout
hhff Jun 6, 2026
516d4f1
Skip form chrome on the empty-state withdrawal request page
hhff Jun 6, 2026
161c167
Share one panel between the empty-state and missing-vendor screens
hhff Jun 6, 2026
6b55542
Style the shared back-to-ledger button as a blue pill
hhff Jun 6, 2026
2d8ce59
Match the AA submit-button pill on the shared back-to-ledger link
hhff Jun 6, 2026
b80505c
Wrap the back-to-ledger link in a bare <form> instead of inlining styles
hhff Jun 6, 2026
fe8b02f
Restyle AA .panel chrome on LedgerWithdrawalRequest pages
hhff Jun 6, 2026
d0da301
Override AA section chrome harder on LedgerWithdrawalRequest pages
hhff Jun 6, 2026
a6b4a7a
Render withdrawal form panels with the ledger's dashboard-modules markup
hhff Jun 6, 2026
4371c56
Pre-check rows, add bulk-toggle, live total on submit button
hhff Jun 6, 2026
05eba1d
Defer bills-panel JS init until DOMContentLoaded
hhff Jun 6, 2026
2e1e65d
Make Deel withdrawals admin-only via the LedgerWithdrawalRequest flow
hhff Jun 6, 2026
44946b7
Add admin overpayment toggle + richer Deel description
hhff Jun 6, 2026
1abb463
Add Pending / Paid / Cancelled index scope tabs
hhff Jun 6, 2026
6df938d
Collapse the withdrawal-request task into a single queue indicator
hhff Jun 6, 2026
c43f7b1
Splice withdrawal requests into the ledger view as informational mile…
hhff Jun 6, 2026
06c5d0f
Drop the 'No balance impact' pill from the withdrawal-request row
hhff Jun 6, 2026
758b6b8
Render withdrawal-request amount with a leading minus
hhff Jun 6, 2026
1e21361
Dry-run audit script for the QBO-cutover balance invariant
hhff Jun 6, 2026
6165d6b
Audit: also model deletion of off-platform payment offsets in CAs
hhff Jun 6, 2026
09a2a74
Accountant-facing worklist for the proposed QBO cutover
hhff Jun 6, 2026
e8286c6
Worklist: filter out items on ledgers with no QBO vendor mapping
hhff Jun 6, 2026
232f23f
Spec: QBO-bound ledger cutover
hhff Jun 12, 2026
3ddc4f8
Plan: QBO-bound ledger cutover
hhff Jun 12, 2026
2bc28d2
QBO cutover: add ledger.mode + ledger.payment_methods with backfill
hhff Jun 12, 2026
007635e
Ledger: mode enum, payment_methods helpers, qbo_bound_visible_items
hhff Jun 12, 2026
b2730e8
Hosts: in_balance_under_qbo_bound? predicates for QBO-bound balance rule
hhff Jun 12, 2026
a9a9167
Ledger: balance/unsettled branch on mode (legacy vs qbo_bound)
hhff Jun 12, 2026
0c045d4
Ledger: use enum predicates + raise on unknown mode (review fix)
hhff Jun 12, 2026
f0c9307
ContributorAdjustment: reject negative amounts on qbo_bound ledgers
hhff Jun 12, 2026
2ba7703
ContributorAdjustment: amount&.negative?, restore payable? docstring …
hhff Jun 12, 2026
371c638
Ledgers::QboBoundMigrationCheck: per-ledger gate with blocking-bill d…
hhff Jun 12, 2026
527c0fc
QboBoundMigrationCheck: extract shared audit-only predicate, drop dea…
hhff Jun 12, 2026
4493026
Ledger admin: Migrate-to-QBO-bound panel + member_action
hhff Jun 12, 2026
2d7ef27
Migrate panel: number_to_currency, legacy guard, Reimbursement crash fix
hhff Jun 12, 2026
419efca
Migrate panel: use helpers.number_to_currency in member_action context
hhff Jun 12, 2026
ce3243f
ledgers:migrate_qbo_bound_zero_drift rake task
hhff Jun 12, 2026
1f08182
TaskBuilder: surface legacy ledgers pending QBO migration
hhff Jun 12, 2026
d35ec37
Money::PayableQboBills: cross-enterprise open-bill selection
hhff Jun 12, 2026
450a050
Money::RefreshPayableQboBills: bulk re-sync open bills for one QBO ac…
hhff Jun 12, 2026
88d8510
Money admin: Payable QBO Bills page, tabbed per QBO account
hhff Jun 12, 2026
f4479dc
DeelInvoiceAdjustments::CreateForLedger: direct Deel API call (no wit…
hhff Jun 12, 2026
f009991
Contributors admin: withdraw_via_deel member_action + gated form
hhff Jun 12, 2026
2a200db
Delete LedgerWithdrawalRequest model, admin, services, discovery, views
hhff Jun 12, 2026
e470fc2
Remove all LedgerWithdrawalRequest cross-references from runtime
hhff Jun 12, 2026
6b61139
Remove one-shot QBO-cutover audit scripts
hhff Jun 12, 2026
a5a76ef
Final sweep: remove stale LedgerWithdrawalRequest rake block, dead te…
hhff Jun 12, 2026
9ffc209
db/schema.rb: restore composite-FK comments for qbo_invoices
hhff Jun 12, 2026
cbf5271
qbo_bound: paid items drop from unsettled too, not just from balance
hhff Jun 13, 2026
868f5d2
Migrate panel: explain Δ by driver category instead of mislabeling op…
hhff Jun 13, 2026
eb41f44
Reimbursement: sync as QBO Bill like every other payable host
hhff Jun 13, 2026
3dcb367
Migration gate: enforce one-to-one match with QBO vendor record
hhff Jun 13, 2026
d801db5
Reimbursement Deny: leave QBO Bill in place (don't destroy)
hhff Jun 13, 2026
502e275
Reimbursement acceptance: sync QBO bill on either toggle
hhff Jun 13, 2026
16780ae
Gate: auto-flip trivially-empty ledgers without QBO check
hhff Jun 13, 2026
6fb37a1
Migrate panel: 'Refresh QBO vendor data' action for stale-cache cases
hhff Jun 13, 2026
1be755a
Restore schema.rb composite-FK comments + update tests for trivial-em…
hhff Jun 13, 2026
9002f08
qbo_bound: use QBO bill's remaining balance for partial payments
hhff Jun 13, 2026
b507de7
qbo_bound: drop paid bills from unsettled too, use remaining_balance …
hhff Jun 14, 2026
2c31226
Merge main into cutover branch (resolves Reimbursement / contributors…
hhff Jun 14, 2026
8a97338
Revert custom withdraw_via_deel UI; restore 'New Deel Withdrawal' act…
hhff Jun 14, 2026
b427f33
PR review cleanup: delete dead predicate, default payment_methods on …
hhff Jun 14, 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
16 changes: 9 additions & 7 deletions app/admin/contributors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ def manual_deel_invoice_visible?(contributor)
# write against, so the buttons short-circuit to a JS alert instead.
LEDGER_REQUIRED_ALERT = "Select the appropriate ledger before you can perform this action.".freeze

action_item :deel_invoice, only: :show, if: proc {
manual_deel_invoice_visible?(resource)
} do
action_item :new_deel_withdrawal, only: :show do
selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger])
if selected_ledger
link_to "New Deel Withdrawal",
new_admin_contributor_deel_invoice_adjustment_path(resource, ledger: selected_ledger.id)
if selected_ledger&.deel_enabled?
link_to "New Deel Withdrawal", new_admin_contributor_deel_invoice_adjustment_path(resource, ledger: selected_ledger.id)
elsif selected_ledger
# Ledger selected but Deel not in payment_methods — keep the button visible but inert,
# mirroring the other action_items' UX.
link_to "New Deel Withdrawal", "#",
onclick: "alert(#{"Deel is not enabled for this ledger's payment methods.".to_json}); return false;"
else
link_to "New Deel Withdrawal", "#",
onclick: "alert(#{LEDGER_REQUIRED_ALERT.to_json}); return false;"
Expand Down Expand Up @@ -119,7 +121,7 @@ def manual_deel_invoice_visible?(contributor)

# Keep QBO in sync after either toggle. sync_qbo_bill! is idempotent —
# creates if missing, updates the existing bill otherwise. Best-effort:
# log + continue on failure; admin can retry manually.
# log + continue on failure; admin can retry via the Payable QBO Bills page.
begin
r.sync_qbo_bill!
rescue => e
Expand Down
10 changes: 10 additions & 0 deletions app/admin/deel_invoice_adjustments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ def manual_deel_invoice_submission_allowed?(contributor)
end

def verify_deel_invoice_access!
# :new / :create are admin-only (for legacy backfills / corrections).
# :index and :show stay available to the owning contributor so they
# can audit their existing Deel withdrawals.
if [:new, :create].include?(action_name.to_sym)
return if current_admin_user.is_admin?
redirect_to admin_contributor_path(parent),
alert: "Direct Deel withdrawals are admin-only."
return
end

return if manual_deel_invoice_submission_allowed?(parent)

redirect_to admin_contributor_path(parent), alert: "That action is not available."
Expand Down
153 changes: 153 additions & 0 deletions app/admin/ledgers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,157 @@
# the parent's index. Without it, the breadcrumb link is missing.
actions :index, :show
permit_params

member_action :refresh_qbo_vendor, method: :post do
qa = resource.enterprise&.qbo_account
if qa.nil?
redirect_to admin_ledger_path(resource), alert: "No QBO account connected for #{resource.enterprise&.name}."
return
end
qa.sync_all_vendors!
redirect_to admin_ledger_path(resource), notice: "Refreshed QBO vendor data for #{resource.enterprise.name}."
rescue => e
Rails.logger.error("[refresh_qbo_vendor] ledger=#{resource.id}: #{e.class}: #{e.message}")
redirect_to admin_ledger_path(resource), alert: "Refresh failed: #{e.message}"
end

member_action :migrate_to_qbo_bound, method: :post do
unless resource.legacy?
redirect_to admin_ledger_path(resource), alert: "Already QBO-bound."
return
end
result = Ledgers::QboBoundMigrationCheck.call(resource)
if result.ready?
resource.update!(mode: :qbo_bound)
redirect_to admin_ledger_path(resource), notice: "Migrated to QBO-bound."
elsif result.qbo_vendor_missing?
redirect_to admin_ledger_path(resource),
alert: "Cannot migrate: no QBO vendor mapping for this contributor on #{resource.enterprise.name}."
else
redirect_to admin_ledger_path(resource),
alert: "Cannot migrate: Stacks total #{helpers.number_to_currency(result.stacks_open_total)} does not match QBO vendor balance #{helpers.number_to_currency(result.qbo_vendor_balance)} (diff #{helpers.number_to_currency(result.qbo_diff)})."
end
end

show do
attributes_table do
row :id
row :enterprise
row :contributor
row :mode
row :payment_methods
end

if resource.legacy?
panel "Migrate to QBO-bound" do
result = Ledgers::QboBoundMigrationCheck.call(resource)

div do
h4 "QBO match check"
if result.qbo_vendor_missing?
para strong("No QBO vendor mapping for this contributor on #{resource.enterprise.name}.")
para "Set up the vendor mapping before migrating — we can't compare against QBO without it."
else
para "Stacks (proposed qbo_bound) open total: #{number_to_currency(result.stacks_open_total)} = balance #{number_to_currency(result.proposed_balance)} + unsettled #{number_to_currency(result.proposed_unsettled)}"
para "QBO vendor AP balance: #{number_to_currency(result.qbo_vendor_balance)}"
if result.qbo_match?
para strong("Match. Safe to migrate — qbo_bound will mirror QBO one-to-one.")
else
para strong("Does NOT match. Diff: #{number_to_currency(result.qbo_diff)} (Stacks − QBO).")
if result.qbo_diff > 0
para "Stacks shows MORE owed than QBO. Likely cause: an Expense-to-AP or vendor credit in QBO that reduces AP, which Stacks can't see. Reconcile in QBO first (add the missing offset in Stacks, or verify the QBO entry is correct)."
else
para "QBO shows MORE owed than Stacks. Likely cause: an open Bill in QBO that Stacks doesn't know about (host without qbo_bill_id, or a Bill created outside Stacks). Sync the missing Bill or verify the QBO entry."
end
div style: "margin-top: 0.5em;" do
button_to "Refresh QBO vendor data",
refresh_qbo_vendor_admin_ledger_path(resource),
method: :post,
data: { confirm: "Fetch all vendors for #{resource.enterprise.name} from QBO? Takes a few seconds." }
para style: "font-size: 0.85em; opacity: 0.7;" do
text_node "Refreshes the cached vendor balance. Use if you just synced a new bill to QBO and the diff matches a known Stacks-side amount."
end
end
end
end
end

if result.ready?
div do
button_to "Migrate to QBO-bound", migrate_to_qbo_bound_admin_ledger_path(resource), method: :post, data: { confirm: "Flip this ledger to qbo_bound? Stacks total and QBO balance match — this is safe." }
end
else
div do
h4 "Diagnostic — legacy vs qbo_bound (independent of QBO check)"
para "Current (legacy): balance #{number_to_currency(result.current_balance)} unsettled #{number_to_currency(result.current_unsettled)}"
para "Proposed (qbo_bound): balance #{number_to_currency(result.proposed_balance)} unsettled #{number_to_currency(result.proposed_unsettled)}"
para "Δ balance #{number_to_currency(result.balance_delta)}, Δ unsettled #{number_to_currency(result.unsettled_delta)}"
end

neg_ca_sum = result.removed_neg_cas.sum { |ca| ca.amount.to_f }.round(2)
dia_sum = result.removed_dias.sum { |d| d.amount.to_f }.round(2)
paid_sum = result.dropped_paid_hosts.sum { |b| b.amount.to_f }.round(2)

if result.removed_neg_cas.any? || result.removed_dias.any? || result.dropped_paid_hosts.any?
div do
h4 "Items behaving differently under qbo_bound"

if result.removed_neg_cas.any?
para strong("Negative CAs ignored as audit-only: #{number_to_currency(neg_ca_sum)} (+#{number_to_currency(neg_ca_sum.abs)} to Δ)")
ul do
result.removed_neg_cas.first(15).each do |ca|
li "CA ##{ca.id} — #{number_to_currency(ca.amount.to_f)} #{ca.description.to_s.truncate(70)}"
end
li "… and #{result.removed_neg_cas.size - 15} more" if result.removed_neg_cas.size > 15
end
end

if result.removed_dias.any?
para strong("DIAs ignored as audit-only: #{number_to_currency(dia_sum)} (+#{number_to_currency(dia_sum.abs)} to Δ)")
ul do
result.removed_dias.first(15).each do |d|
li "DIA ##{d.id} — #{number_to_currency(d.amount.to_f)} #{d.description.to_s.truncate(70)}"
end
li "… and #{result.removed_dias.size - 15} more" if result.removed_dias.size > 15
end
end

if result.dropped_paid_hosts.any?
para strong("Paid QBO bills dropping out: #{number_to_currency(paid_sum)} (−#{number_to_currency(paid_sum.abs)} to Δ)")
ul do
result.dropped_paid_hosts.first(15).each do |b|
li do
text_node "#{b.host.class.name} ##{b.host.id} — #{number_to_currency(b.amount.to_f)} — "
link_to "View in QBO ↗", b.qbo_bill.qbo_url, target: "_blank", rel: "noopener"
end
end
li "… and #{result.dropped_paid_hosts.size - 15} more" if result.dropped_paid_hosts.size > 15
end
end
end
end

if result.open_qbo_bills.any?
div do
h4 "Open QBO bills on this ledger"
para "Marking one Paid in QBO turns it into a dropped paid host and reduces Stacks open total by its amount."
ul do
result.open_qbo_bills.first(20).each do |b|
li do
text_node "#{b.host.class.name} ##{b.host.id} — #{number_to_currency(b.amount.to_f)} — "
link_to "Pay in QBO ↗", b.qbo_bill.qbo_url, target: "_blank", rel: "noopener"
end
end
li "… and #{result.open_qbo_bills.size - 20} more" if result.open_qbo_bills.size > 20
end
end
end

div do
button_to "Re-check", admin_ledger_path(resource), method: :get
end
end
end
end
end
end
29 changes: 27 additions & 2 deletions app/admin/money.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
ActiveAdmin.register_page "Money" do
menu priority: 50

controller do
before_action do |_|
redirect_to admin_invoice_passes_path
before_action :authenticate_admin_user!
end

page_action :payable_qbo_bills, method: :get do
@qbo_accounts = QboAccount.includes(:enterprise).order(:id).to_a
@active_qa = if params[:qbo_account_id].present?
QboAccount.find(params[:qbo_account_id])
else
@qbo_accounts.first
end
@rows = @active_qa ? Money::PayableQboBills.call(qbo_account: @active_qa) : []
render "admin/money/payable_qbo_bills"
end

page_action :refresh_bill, method: :post do
klass = params.require(:host_class).to_s.constantize
raise ActionController::BadRequest, "unsupported host class" unless Money::PayableQboBills::HOST_KLASSES.include?(klass)
host = klass.find(params.require(:host_id))
host.sync_qbo_bill!
redirect_back(fallback_location: admin_money_payable_qbo_bills_path(qbo_account_id: params[:qbo_account_id]))
end

page_action :refresh_tab, method: :post do
qa = QboAccount.find(params.require(:qbo_account_id))
Money::RefreshPayableQboBills.call(qbo_account: qa)
redirect_back(fallback_location: admin_money_payable_qbo_bills_path(qbo_account_id: qa.id))
end
end
10 changes: 10 additions & 0 deletions app/models/concerns/syncs_as_qbo_bill.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ def payable?
false
end

# Contribution to qbo_bound balance. Uses the QBO bill's remaining unpaid
# balance when a bill exists so partial payments are reflected one-to-one
# with QBO's vendor AP. Falls back to the host amount when there's no
# synced bill.
def qbo_bound_balance_amount
qb = qbo_bill
return amount.to_f if qb.nil?
qb.remaining_balance
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
Expand Down
9 changes: 9 additions & 0 deletions app/models/contributor_adjustment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ class ContributorAdjustment < ApplicationRecord

validates :amount, presence: true
validates :effective_on, presence: true
validate :no_negative_on_qbo_bound_ledger

def no_negative_on_qbo_bound_ledger
return unless ledger&.qbo_bound? && amount&.negative?
errors.add(
:amount,
"negative adjustments are not allowed on QBO-bound ledgers — mark the corresponding QBO bill Paid instead",
)
end

# No linked invoice: counts toward balance like other payable rows. Linked invoice: only when fully paid in QBO.
def payable?
Expand Down
Loading