diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb
index 5c7b394b..12cd5e7f 100644
--- a/app/admin/contributors.rb
+++ b/app/admin/contributors.rb
@@ -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;"
@@ -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
diff --git a/app/admin/deel_invoice_adjustments.rb b/app/admin/deel_invoice_adjustments.rb
index 84b38761..8e813bcf 100644
--- a/app/admin/deel_invoice_adjustments.rb
+++ b/app/admin/deel_invoice_adjustments.rb
@@ -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."
diff --git a/app/admin/ledgers.rb b/app/admin/ledgers.rb
index 3c9f0c5a..ca03aee6 100644
--- a/app/admin/ledgers.rb
+++ b/app/admin/ledgers.rb
@@ -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
diff --git a/app/admin/money.rb b/app/admin/money.rb
index 819606c1..c1c419b3 100644
--- a/app/admin/money.rb
+++ b/app/admin/money.rb
@@ -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
diff --git a/app/models/concerns/syncs_as_qbo_bill.rb b/app/models/concerns/syncs_as_qbo_bill.rb
index e213268e..a75266fc 100644
--- a/app/models/concerns/syncs_as_qbo_bill.rb
+++ b/app/models/concerns/syncs_as_qbo_bill.rb
@@ -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
diff --git a/app/models/contributor_adjustment.rb b/app/models/contributor_adjustment.rb
index 4ad83d9e..aa8ed90e 100644
--- a/app/models/contributor_adjustment.rb
+++ b/app/models/contributor_adjustment.rb
@@ -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?
diff --git a/app/models/ledger.rb b/app/models/ledger.rb
index d715e590..61e28cf4 100644
--- a/app/models/ledger.rb
+++ b/app/models/ledger.rb
@@ -2,6 +2,18 @@ class Ledger < ApplicationRecord
belongs_to :enterprise
belongs_to :contributor
+ enum mode: { legacy: 0, qbo_bound: 1 }
+
+ PAYMENT_METHODS = %w[deel qbo].freeze
+
+ def deel_enabled?
+ payment_methods.include?("deel")
+ end
+
+ def qbo_enabled?
+ payment_methods.include?("qbo")
+ end
+
has_many :contributor_payouts
has_many :contributor_adjustments
has_many :trueups
@@ -12,6 +24,20 @@ class Ledger < ApplicationRecord
has_many :recurring_ledger_adjustments, dependent: :destroy
validates :enterprise_id, uniqueness: { scope: :contributor_id }
+ validate :payment_methods_are_known
+
+ before_validation :default_payment_methods, on: :create
+
+ # Inferred default payment methods for a contributor. Non-US Deel contractor
+ # → ["deel"]; everyone else → ["qbo"]. Shared between the schema backfill,
+ # the ensure_* bulk paths, and the per-record before_validation hook.
+ def self.payment_methods_for(contributor)
+ return %w[qbo] if contributor.nil?
+ dp = contributor.deel_person
+ country = dp&.data.is_a?(Hash) ? dp.data["country"].to_s.upcase : nil
+ return %w[deel] if dp.present? && country.present? && country != "US"
+ %w[qbo]
+ end
def self.find_or_create_for(enterprise:, contributor:)
find_or_create_by!(enterprise: enterprise, contributor: contributor)
@@ -26,14 +52,15 @@ def self.find_or_create_for(enterprise:, contributor:)
# Returns the count of rows inserted.
def self.ensure_all!
existing = pluck(:enterprise_id, :contributor_id).to_set
- contributor_ids = Contributor.pluck(:id)
+ contributors = Contributor.includes(:deel_person).index_by(&:id)
enterprise_ids = Enterprise.pluck(:id)
rows = []
now = Time.current
- contributor_ids.each do |c_id|
+ contributors.each_value do |contributor|
+ pm = payment_methods_for(contributor)
enterprise_ids.each do |e_id|
- next if existing.include?([e_id, c_id])
- rows << { enterprise_id: e_id, contributor_id: c_id, created_at: now, updated_at: now }
+ next if existing.include?([e_id, contributor.id])
+ rows << { enterprise_id: e_id, contributor_id: contributor.id, payment_methods: "{#{pm.join(",")}}", created_at: now, updated_at: now }
end
end
insert_all(rows) if rows.any?
@@ -45,11 +72,12 @@ def self.ensure_all!
# has a ledger for every enterprise — no manual setup, no waiting on cron.
def self.ensure_for_contributor!(contributor)
existing_enterprise_ids = where(contributor_id: contributor.id).pluck(:enterprise_id).to_set
+ pm = payment_methods_for(contributor)
rows = []
now = Time.current
Enterprise.pluck(:id).each do |e_id|
next if existing_enterprise_ids.include?(e_id)
- rows << { enterprise_id: e_id, contributor_id: contributor.id, created_at: now, updated_at: now }
+ rows << { enterprise_id: e_id, contributor_id: contributor.id, payment_methods: "{#{pm.join(",")}}", created_at: now, updated_at: now }
end
insert_all(rows) if rows.any?
rows.size
@@ -59,25 +87,30 @@ def self.ensure_for_contributor!(contributor)
# Invoked from Enterprise.after_create when a new enterprise is added.
def self.ensure_for_enterprise!(enterprise)
existing_contributor_ids = where(enterprise_id: enterprise.id).pluck(:contributor_id).to_set
+ contributors = Contributor.where.not(id: existing_contributor_ids).includes(:deel_person)
rows = []
now = Time.current
- Contributor.pluck(:id).each do |c_id|
- next if existing_contributor_ids.include?(c_id)
- rows << { enterprise_id: enterprise.id, contributor_id: c_id, created_at: now, updated_at: now }
+ contributors.each do |contributor|
+ pm = payment_methods_for(contributor)
+ rows << { enterprise_id: enterprise.id, contributor_id: contributor.id, payment_methods: "{#{pm.join(",")}}", created_at: now, updated_at: now }
end
insert_all(rows) if rows.any?
rows.size
end
- # Balance/unsettled at the per-ledger (per-enterprise) level. Excludes soft-deleted rows
- # via the default acts_as_paranoid scope. Each host's `payable?` decides which bucket the
- # row lands in; `signed_amount` lets withdrawals deduct.
+ # Balance/unsettled split.
+ # legacy: balance = payable items, unsettled = non-payable items.
+ # qbo_bound: balance = payable items whose QBO bill isn't paid yet;
+ # unsettled = non-payable items (waiting on Stacks-side approval).
+ # Items where the QBO bill IS paid drop from BOTH buckets — they're
+ # settled in QBO and shouldn't show up in either Stacks total. This
+ # keeps the qbo_bound ledger one-to-one with the QBO vendor record.
def balance
- visible_items.select(&:payable?).sum(&:signed_amount)
+ sum_for_bucket(payable: true)
end
def unsettled
- visible_items.reject(&:payable?).sum(&:signed_amount)
+ sum_for_bucket(payable: false)
end
# Per-ledger by-month grouping for display. Includes soft-deleted rows so the contributor
@@ -117,7 +150,15 @@ def items_grouped_by_month(override_starts_at = nil, override_ends_at = nil)
end
end
- private
+ # Rows that are bookkeeping-only under qbo_bound. Shared between
+ # qbo_bound_visible_items and Ledgers::QboBoundMigrationCheck so the
+ # two cannot drift.
+ def self.audit_only_under_qbo_bound?(item)
+ item.is_a?(DeelInvoiceAdjustment) ||
+ (item.is_a?(ContributorAdjustment) && item.amount.to_f < 0)
+ end
+
+ protected
# Non-deleted only — used by balance/unsettled sums.
def visible_items
@@ -132,6 +173,55 @@ def visible_items
].flatten
end
+ # qbo_bound mode: drop audit-only rows. The remaining items are then split
+ # by `qbo_bound_open_items` (paid bills drop too) and bucketed by `payable?`.
+ def qbo_bound_visible_items
+ visible_items.reject { |li| self.class.audit_only_under_qbo_bound?(li) }
+ end
+
+ # qbo_bound mode: items that should still appear somewhere (balance or
+ # unsettled). Audit-only items are dropped, AND any host whose QBO bill
+ # is fully Paid is dropped — paid bills are settled in QBO and shouldn't
+ # show up in Stacks at all. Partial-paid bills survive (their remaining
+ # balance is the contribution).
+ def qbo_bound_open_items
+ qbo_bound_visible_items.reject { |li| li.try(:qbo_bill)&.paid? }
+ end
+
+ # qbo_bound mode: per-item dollar amount. Uses the QBO bill's remaining
+ # balance when a bill exists so partial payments are reflected one-to-one
+ # with QBO's vendor AP; falls back to the host's signed_amount otherwise.
+ def qbo_bound_contribution(li)
+ if li.respond_to?(:qbo_bound_balance_amount)
+ li.qbo_bound_balance_amount
+ else
+ li.signed_amount
+ end
+ end
+
+ private
+
+ def default_payment_methods
+ return unless payment_methods.blank?
+ self.payment_methods = self.class.payment_methods_for(contributor)
+ end
+
+ def sum_for_bucket(payable:)
+ if legacy?
+ visible_items.select { |li| li.payable? == payable }.sum(&:signed_amount)
+ elsif qbo_bound?
+ qbo_bound_open_items.select { |li| li.payable? == payable }.sum { |li| qbo_bound_contribution(li) }
+ else
+ raise "Unknown ledger mode: #{mode.inspect}"
+ end
+ end
+
+ def payment_methods_are_known
+ return if payment_methods.blank?
+ bad = payment_methods - PAYMENT_METHODS
+ errors.add(:payment_methods, "contains unknown value(s): #{bad.join(", ")}") if bad.any?
+ end
+
# Includes soft-deleted rows — used by items_grouped_by_month for display.
def all_items_with_deleted
[
diff --git a/app/models/qbo_bill.rb b/app/models/qbo_bill.rb
index 46d00a7d..5fec9f79 100644
--- a/app/models/qbo_bill.rb
+++ b/app/models/qbo_bill.rb
@@ -15,6 +15,26 @@ def qbo_url
"https://qbo.intuit.com/app/bill?&txnId=#{qbo_id}"
end
+ # QBO Bills are settled when their balance hits zero (full or partial
+ # payments are reflected by BillPayments which deduct from balance).
+ # `data` is the JSONB blob synced from QBO via QboAccount#fetch_bill_by_id.
+ def paid?
+ balance = data&.dig("balance")
+ return false if balance.nil?
+ balance.to_f <= 0
+ end
+
+ def total_amount
+ (data&.dig("total_amt") || data&.dig("total"))&.to_f
+ end
+
+ # Remaining unpaid balance on the bill. Reflects partial payments — a bill
+ # for $1,778.40 paid down to $0.40 returns 0.4 here. Used by qbo_bound
+ # balance computation to mirror QBO's vendor AP exactly.
+ def remaining_balance
+ data&.dig("balance").to_f
+ end
+
def delete_qbo_bill!
begin
qbo_account.delete_bill(qbo_account.fetch_bill_by_id(qbo_id))
diff --git a/app/models/stacks_task.rb b/app/models/stacks_task.rb
index 85a3dfdc..1462a197 100644
--- a/app/models/stacks_task.rb
+++ b/app/models/stacks_task.rb
@@ -46,6 +46,8 @@ class StacksTask
# Ledger issues
missing_qbo_vendor_for_contributor: "Contributor needs a QBO vendor for this enterprise's ledger",
+ legacy_ledger_needs_qbo_migration: "Legacy ledger needs migration to QBO-bound",
+
}.freeze
# type — Symbol classifying the task (:project_capsule_incomplete, :survey, …)
@@ -120,7 +122,12 @@ def subject_url
when ProjectSatisfactionSurvey then helpers.admin_project_satisfaction_survey_path(subject)
when Stacks::Notion::Lead then subject.try(:notion_link) || subject.try(:external_link)
when PayCycle then helpers.admin_enterprise_pay_cycle_path(subject.enterprise, subject)
- when Ledger then helpers.edit_admin_contributor_path(subject.contributor)
+ when Ledger
+ if type == :legacy_ledger_needs_qbo_migration
+ helpers.admin_ledger_path(subject)
+ else
+ helpers.edit_admin_contributor_path(subject.contributor)
+ end
else subject.try(:external_link)
end
end
diff --git a/app/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb
new file mode 100644
index 00000000..dc0ca0bb
--- /dev/null
+++ b/app/services/ledgers/qbo_bound_migration_check.rb
@@ -0,0 +1,105 @@
+module Ledgers
+ # Decides whether a legacy Ledger can flip to qbo_bound.
+ #
+ # The ground-truth gate: does the post-migration Stacks state (balance +
+ # unsettled under qbo_bound) match the contributor's QBO vendor AP balance
+ # one-to-one? If yes, the qbo_bound ledger will mirror QBO. If no, there's
+ # a real reconciliation gap — typically an Expense-to-AP or vendor credit
+ # in QBO that Stacks can't see, or an open QBO bill that Stacks doesn't
+ # know about.
+ #
+ # The legacy-vs-qbo_bound Δ is still surfaced as diagnostic info — useful
+ # to explain WHY the two Stacks views differ — but it's not the gate.
+ class QboBoundMigrationCheck
+ TOLERANCE = 0.01
+
+ Result = Struct.new(
+ :current_balance, :current_unsettled,
+ :proposed_balance, :proposed_unsettled,
+ :balance_delta, :unsettled_delta,
+ :stacks_open_total, :qbo_vendor_balance, :qbo_diff,
+ :qbo_match?, :qbo_vendor_missing?,
+ :ready?,
+ :removed_neg_cas, :removed_dias, :dropped_paid_hosts, :open_qbo_bills,
+ keyword_init: true,
+ )
+
+ OpenBill = Struct.new(:host, :qbo_bill, :amount, keyword_init: true)
+
+ def self.call(ledger)
+ legacy_visible = ledger.send(:visible_items)
+ qbb_open = ledger.send(:qbo_bound_open_items)
+
+ legacy_b = legacy_visible.select(&:payable?).sum(&:signed_amount).to_f
+ legacy_u = legacy_visible.reject(&:payable?).sum(&:signed_amount).to_f
+ new_b = qbb_open.select(&:payable?).sum { |li| ledger.send(:qbo_bound_contribution, li).to_f }
+ new_u = qbb_open.reject(&:payable?).sum { |li| ledger.send(:qbo_bound_contribution, li).to_f }
+
+ db = (new_b - legacy_b).round(2)
+ du = (new_u - legacy_u).round(2)
+
+ stacks_open_total = (new_b + new_u).round(2)
+ qa = ledger.enterprise&.qbo_account
+ vendor = qa.present? ? ledger.contributor&.qbo_vendor_for(qa) : nil
+ qbo_vendor_balance = vendor&.data.is_a?(Hash) ? vendor.data["balance"].to_f.round(2) : nil
+ qbo_diff = qbo_vendor_balance ? (stacks_open_total - qbo_vendor_balance).round(2) : nil
+ qbo_match = qbo_diff && qbo_diff.abs < TOLERANCE
+
+ # Trivially empty ledgers: zero on both sides under both rules. Migration
+ # changes nothing visible, so no QBO comparison needed — auto-flip them.
+ # This catches the cross-product (every Contributor × every Enterprise)
+ # ledgers that have no activity and no QBO vendor mapping.
+ trivial = legacy_b.abs < TOLERANCE && legacy_u.abs < TOLERANCE &&
+ new_b.abs < TOLERANCE && new_u.abs < TOLERANCE
+
+ Result.new(
+ current_balance: legacy_b.round(2),
+ current_unsettled: legacy_u.round(2),
+ proposed_balance: new_b.round(2),
+ proposed_unsettled: new_u.round(2),
+ balance_delta: db,
+ unsettled_delta: du,
+ stacks_open_total: stacks_open_total,
+ qbo_vendor_balance: qbo_vendor_balance,
+ qbo_diff: qbo_diff,
+ qbo_match?: qbo_match,
+ qbo_vendor_missing?: vendor.nil?,
+ ready?: trivial || (!vendor.nil? && qbo_match),
+ removed_neg_cas: legacy_visible.select { |li| li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 },
+ removed_dias: legacy_visible.select { |li| li.is_a?(DeelInvoiceAdjustment) && li.payable? },
+ dropped_paid_hosts: collect_dropped_paid_hosts(legacy_visible),
+ open_qbo_bills: collect_open_qbo_bills(legacy_visible),
+ )
+ end
+
+ # Payable hosts whose QBO bill is Paid. Diagnostic: they drop from
+ # qbo_bound balance and explain part of the legacy-vs-qbo_bound Δ.
+ def self.collect_dropped_paid_hosts(items)
+ items.filter_map do |li|
+ next nil if Ledger.audit_only_under_qbo_bound?(li)
+ next nil unless li.respond_to?(:qbo_bill)
+ next nil unless li.payable?
+
+ qb = li.qbo_bill
+ next nil if qb.nil? || !qb.paid?
+
+ OpenBill.new(host: li, qbo_bill: qb, amount: li.amount.to_f)
+ end
+ end
+
+ # Unpaid QBO bills on the ledger. Marking one Paid in QBO turns it into
+ # a dropped paid host and reduces Stacks open total by its amount.
+ def self.collect_open_qbo_bills(items)
+ items.filter_map do |li|
+ next nil if Ledger.audit_only_under_qbo_bound?(li)
+ next nil unless li.respond_to?(:qbo_bill)
+ next nil unless li.payable?
+
+ qb = li.qbo_bill
+ next nil if qb.nil? || qb.paid?
+
+ OpenBill.new(host: li, qbo_bill: qb, amount: li.amount.to_f)
+ end
+ end
+ end
+end
diff --git a/app/services/money/payable_qbo_bills.rb b/app/services/money/payable_qbo_bills.rb
new file mode 100644
index 00000000..88fe8717
--- /dev/null
+++ b/app/services/money/payable_qbo_bills.rb
@@ -0,0 +1,43 @@
+module Money
+ # Selects open QBO bills payable through Stacks: every SyncsAsQboBill host
+ # row whose ledger has 'qbo' in payment_methods, where the row is payable?
+ # AND the QboBill mirror is still open. Tabbed per QBO account.
+ class PayableQboBills
+ HOST_KLASSES = [
+ ContributorPayout,
+ ContributorAdjustment,
+ ProfitShare,
+ Trueup,
+ PayStub,
+ Reimbursement,
+ ].freeze
+
+ Row = Struct.new(:host, :ledger, :contributor, :qbo_bill, :amount, keyword_init: true)
+
+ def self.call(qbo_account:)
+ rows = HOST_KLASSES.flat_map do |klass|
+ klass
+ .where.not(qbo_bill_id: nil)
+ .joins(ledger: { enterprise: :qbo_account })
+ .where(qbo_accounts: { id: qbo_account.id })
+ .where("'qbo' = ANY(ledgers.payment_methods)")
+ .includes(ledger: :contributor)
+ .find_each.filter_map do |row|
+ next nil unless row.payable?
+ qb = row.try(:qbo_bill)
+ next nil if qb.nil? || qb.paid?
+
+ Row.new(
+ host: row,
+ ledger: row.ledger,
+ contributor: row.ledger.contributor,
+ qbo_bill: qb,
+ amount: row.amount.to_f,
+ )
+ end
+ end
+
+ rows.sort_by { |r| [r.contributor.id, r.host.class.name, r.host.id] }
+ end
+ end
+end
diff --git a/app/services/money/refresh_payable_qbo_bills.rb b/app/services/money/refresh_payable_qbo_bills.rb
new file mode 100644
index 00000000..35ea4e70
--- /dev/null
+++ b/app/services/money/refresh_payable_qbo_bills.rb
@@ -0,0 +1,12 @@
+module Money
+ # Bulk-refresh: walks the rows PayableQboBills would return and calls
+ # SyncsAsQboBill#sync_qbo_bill! on each so bills marked Paid in QBO drop
+ # off the page on the next render.
+ class RefreshPayableQboBills
+ def self.call(qbo_account:)
+ Money::PayableQboBills.call(qbo_account: qbo_account).each do |row|
+ row.host.sync_qbo_bill!
+ end
+ end
+ end
+end
diff --git a/app/views/admin/contributors/_show.html.erb b/app/views/admin/contributors/_show.html.erb
index 21ffeaab..5f7e0bd2 100644
--- a/app/views/admin/contributors/_show.html.erb
+++ b/app/views/admin/contributors/_show.html.erb
@@ -271,7 +271,7 @@
<% end %>
- <% unless li.deleted_at.present? %>
+ <% unless li.try(:deleted_at).present? %>
<% if li.is_a?(ContributorPayout) %>
<%= link_to "#{li.display_name} ↗", admin_invoice_tracker_contributor_payout_path(li.invoice_tracker, li) %>
<% elsif li.is_a?(ProfitShare) %>
diff --git a/app/views/admin/money/payable_qbo_bills.html.erb b/app/views/admin/money/payable_qbo_bills.html.erb
new file mode 100644
index 00000000..966ebd08
--- /dev/null
+++ b/app/views/admin/money/payable_qbo_bills.html.erb
@@ -0,0 +1,46 @@
+Payable QBO Bills
+
+
+ <% @qbo_accounts.each do |qa| %>
+ <%= link_to qa.enterprise.name, admin_money_payable_qbo_bills_path(qbo_account_id: qa.id),
+ style: "margin-right: 1em; #{'font-weight: bold' if @active_qa&.id == qa.id}" %>
+ <% end %>
+
+
+<% if @active_qa.nil? %>
+ No QBO accounts connected.
+<% else %>
+
+ <%= button_to "Refresh all on this tab",
+ admin_money_refresh_tab_path(qbo_account_id: @active_qa.id),
+ method: :post %>
+
+
+ <% if @rows.empty? %>
+ No payable bills on <%= @active_qa.enterprise.name %>.
+ <% else %>
+ <% @rows.group_by(&:contributor).each do |contributor, contributor_rows| %>
+
+ <%= contributor.forecast_person&.email || "Contributor ##{contributor.id}" %>
+ — <%= number_to_currency(contributor_rows.sum(&:amount)) %>
+ (<%= contributor_rows.size %> bills)
+
+
+ <% contributor_rows.each do |row| %>
+ -
+ <%= row.host.class.name %> #<%= row.host.id %>
+ — <%= number_to_currency(row.amount) %>
+ — <%= link_to "Pay in QBO ↗", row.qbo_bill.qbo_url, target: "_blank", rel: "noopener" %>
+ — <%= button_to "Refresh",
+ admin_money_refresh_bill_path(
+ qbo_account_id: @active_qa.id,
+ host_class: row.host.class.name,
+ host_id: row.host.id,
+ ),
+ method: :post, form: { style: "display: inline" } %>
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/db/migrate/20260612214545_add_mode_and_payment_methods_to_ledgers.rb b/db/migrate/20260612214545_add_mode_and_payment_methods_to_ledgers.rb
new file mode 100644
index 00000000..93a7499b
--- /dev/null
+++ b/db/migrate/20260612214545_add_mode_and_payment_methods_to_ledgers.rb
@@ -0,0 +1,27 @@
+class AddModeAndPaymentMethodsToLedgers < ActiveRecord::Migration[6.1]
+ # Per the QBO-bound cutover design:
+ # - `mode` controls balance computation. Default :legacy preserves today's behavior.
+ # - `payment_methods` is a Postgres text[] with values from %w[deel qbo].
+ # Backfilled from the contributor's DeelPerson country: non-US Deel → ["deel"],
+ # everyone else → ["qbo"].
+ def up
+ add_column :ledgers, :mode, :integer, null: false, default: 0
+ add_column :ledgers, :payment_methods, :string, array: true, null: false, default: []
+ add_index :ledgers, :mode
+ add_index :ledgers, :payment_methods, using: :gin
+
+ Ledger.reset_column_information
+
+ Ledger.includes(contributor: :deel_person).find_each do |ledger|
+ next if ledger.contributor.nil?
+ ledger.update_column(:payment_methods, Ledger.payment_methods_for(ledger.contributor))
+ end
+ end
+
+ def down
+ remove_index :ledgers, :payment_methods
+ remove_index :ledgers, :mode
+ remove_column :ledgers, :payment_methods
+ remove_column :ledgers, :mode
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0da079f3..5859f0fc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -406,9 +406,13 @@
t.bigint "contributor_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
+ t.integer "mode", default: 0, null: false
+ t.string "payment_methods", default: [], null: false, array: true
t.index ["contributor_id"], name: "index_ledgers_on_contributor_id"
t.index ["enterprise_id", "contributor_id"], name: "index_ledgers_on_enterprise_id_and_contributor_id", unique: true
t.index ["enterprise_id"], name: "index_ledgers_on_enterprise_id"
+ t.index ["mode"], name: "index_ledgers_on_mode"
+ t.index ["payment_methods"], name: "index_ledgers_on_payment_methods", using: :gin
end
create_table "mailing_list_subscribers", force: :cascade do |t|
diff --git a/lib/stacks/task_builder.rb b/lib/stacks/task_builder.rb
index 386fa77e..9f720159 100644
--- a/lib/stacks/task_builder.rb
+++ b/lib/stacks/task_builder.rb
@@ -49,6 +49,7 @@ class TaskBuilder
Discoveries::Surveys,
Discoveries::PayCycles,
Discoveries::MissingQboVendors,
+ Discoveries::LegacyLedgersPendingQboMigration,
].freeze
# Returns Array — every open task system-wide.
diff --git a/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb b/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb
new file mode 100644
index 00000000..f6730717
--- /dev/null
+++ b/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb
@@ -0,0 +1,45 @@
+module Stacks
+ class TaskBuilder
+ module Discoveries
+ # Surfaces every legacy ledger that has at least one payable host row
+ # AND whose enterprise has a connected QBO account. Each emits a
+ # :legacy_ledger_needs_qbo_migration task routed to the admin fallback.
+ class LegacyLedgersPendingQboMigration < Base
+ PAYABLE_TABLES = %w[
+ contributor_payouts
+ contributor_adjustments
+ profit_shares
+ pay_stubs
+ trueups
+ reimbursements
+ ].freeze
+
+ def tasks
+ ledgers = Ledger
+ .where(mode: :legacy)
+ .joins(:enterprise)
+ .where(enterprises: { id: Enterprise.joins(:qbo_account).select(:id) })
+ .where("EXISTS (#{any_payable_subquery})")
+ .includes(:contributor, enterprise: :qbo_account)
+ .to_a
+
+ ledgers.map do |ledger|
+ task(
+ subject: ledger,
+ type: :legacy_ledger_needs_qbo_migration,
+ owners: @admin_fallback,
+ )
+ end
+ end
+
+ private
+
+ def any_payable_subquery
+ PAYABLE_TABLES.map do |t|
+ "SELECT 1 FROM #{t} WHERE #{t}.ledger_id = ledgers.id"
+ end.join(" UNION ALL ")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/ledgers.rake b/lib/tasks/ledgers.rake
new file mode 100644
index 00000000..26eecd0e
--- /dev/null
+++ b/lib/tasks/ledgers.rake
@@ -0,0 +1,23 @@
+namespace :ledgers do
+ desc "Flip every legacy ledger whose balance/unsettled would not change to qbo_bound"
+ task migrate_qbo_bound_zero_drift: :environment do
+ flipped = 0
+ blocked = 0
+ errors = 0
+
+ Ledger.where(mode: :legacy).find_each do |ledger|
+ result = Ledgers::QboBoundMigrationCheck.call(ledger)
+ if result.ready?
+ ledger.update!(mode: :qbo_bound)
+ flipped += 1
+ else
+ blocked += 1
+ end
+ rescue => e
+ errors += 1
+ warn "Ledger ##{ledger.id}: #{e.class}: #{e.message}"
+ end
+
+ puts "Flipped #{flipped} ledgers; #{blocked} still blocked; #{errors} errors."
+ end
+end
diff --git a/test/integration/ledger_migration_test.rb b/test/integration/ledger_migration_test.rb
new file mode 100644
index 00000000..edde48dc
--- /dev/null
+++ b/test/integration/ledger_migration_test.rb
@@ -0,0 +1,61 @@
+require "test_helper"
+
+class LedgerMigrationTest < ActionDispatch::IntegrationTest
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.create!(name: "MigPanel-#{SecureRandom.hex(2)}")
+ @qa = QboAccount.create!(enterprise: @enterprise, client_id: "mp#{SecureRandom.hex(2)}", client_secret: "s", realm_id: "r#{SecureRandom.hex(2)}")
+ @qbo_vendor = QboVendor.create!(qbo_account: @qa, qbo_id: "v#{SecureRandom.hex(2)}", data: { "balance" => "0.0" })
+ fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "mp#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ ContributorQboVendor.create!(contributor: @contributor, qbo_account: @qa, qbo_vendor: @qbo_vendor)
+
+ @admin = AdminUser.create!(
+ email: "lmig#{SecureRandom.hex(2)}@example.com",
+ password: "password123",
+ password_confirmation: "password123",
+ roles: ["admin"]
+ )
+ sign_in @admin
+ end
+
+ test "Migrate posts and flips ready ledger to qbo_bound" do
+ assert @ledger.legacy?
+ post migrate_to_qbo_bound_admin_ledger_path(@ledger)
+ assert_response :redirect
+ @ledger.reload
+ assert @ledger.qbo_bound?
+ end
+
+ test "Migrate refuses to flip a ledger that does not match QBO vendor balance" do
+ not_ready = Ledgers::QboBoundMigrationCheck::Result.new(
+ current_balance: 0, current_unsettled: 0,
+ proposed_balance: 100, proposed_unsettled: 0,
+ balance_delta: 100, unsettled_delta: 0,
+ stacks_open_total: 100, qbo_vendor_balance: 0, qbo_diff: 100,
+ qbo_match?: false, qbo_vendor_missing?: false,
+ ready?: false, removed_neg_cas: [], removed_dias: [], dropped_paid_hosts: [], open_qbo_bills: [],
+ )
+ Ledgers::QboBoundMigrationCheck.expects(:call).with(@ledger).returns(not_ready)
+
+ post migrate_to_qbo_bound_admin_ledger_path(@ledger)
+ assert_response :redirect
+ @ledger.reload
+ assert @ledger.legacy?
+ end
+
+ test "Refresh QBO vendor data calls sync_all_vendors! and redirects" do
+ QboAccount.any_instance.expects(:sync_all_vendors!).once
+ post refresh_qbo_vendor_admin_ledger_path(@ledger)
+ assert_response :redirect
+ follow_redirect!
+ assert_response :success
+ end
+
+ private
+
+ def sign_in(admin)
+ post admin_user_session_path, params: { admin_user: { email: admin.email, password: "password123" } }
+ end
+end
diff --git a/test/integration/payable_qbo_bills_test.rb b/test/integration/payable_qbo_bills_test.rb
new file mode 100644
index 00000000..059f11a7
--- /dev/null
+++ b/test/integration/payable_qbo_bills_test.rb
@@ -0,0 +1,35 @@
+require "test_helper"
+
+class PayableQboBillsTest < ActionDispatch::IntegrationTest
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.create!(name: "IntEnt-#{SecureRandom.hex(2)}")
+ @qa = QboAccount.create!(enterprise: @enterprise, client_id: "pgi#{SecureRandom.hex(2)}", client_secret: "s", realm_id: "r#{SecureRandom.hex(2)}")
+ fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "ip#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ @ledger.update!(payment_methods: %w[qbo])
+
+ @admin = AdminUser.create!(email: "pq#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", roles: ["admin"])
+ sign_in @admin
+ end
+
+ test "GET payable_qbo_bills renders" do
+ get admin_money_payable_qbo_bills_path(qbo_account_id: @qa.id)
+ assert_response :success
+ # Page shows enterprise name (since QboAccount has no name column).
+ assert_match @enterprise.name, response.body
+ end
+
+ test "POST refresh_tab kicks off bulk refresh" do
+ Money::RefreshPayableQboBills.expects(:call).with(qbo_account: instance_of(QboAccount))
+ post admin_money_refresh_tab_path(qbo_account_id: @qa.id)
+ assert_response :redirect
+ end
+
+ private
+
+ def sign_in(admin)
+ post admin_user_session_path, params: { admin_user: { email: admin.email, password: "password123" } }
+ end
+end
diff --git a/test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb b/test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb
new file mode 100644
index 00000000..25cbbf4a
--- /dev/null
+++ b/test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb
@@ -0,0 +1,39 @@
+require "test_helper"
+
+class Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigrationTest < ActiveSupport::TestCase
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.create!(name: "DiscEnt-#{SecureRandom.hex(2)}")
+ @qa = QboAccount.create!(
+ enterprise: @enterprise,
+ client_id: "test_client",
+ client_secret: "test_secret",
+ realm_id: "rake#{SecureRandom.hex(4)}",
+ )
+ fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "disc#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ @admin = AdminUser.create!(email: "ldisc#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", roles: ["admin"])
+ end
+
+ test "legacy ledger with payable activity yields a migration task" do
+ ContributorAdjustment.create!(ledger: @ledger, qbo_account: @qa, amount: 100, effective_on: Date.today)
+ discovery = Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration.new(admin_fallback: [@admin])
+ tasks = discovery.tasks
+ assert tasks.any? { |t| t[:subject] == @ledger && t[:type] == :legacy_ledger_needs_qbo_migration }
+ end
+
+ test "qbo_bound ledger yields no task" do
+ ContributorAdjustment.create!(ledger: @ledger, qbo_account: @qa, amount: 100, effective_on: Date.today)
+ @ledger.update!(mode: :qbo_bound)
+ discovery = Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration.new(admin_fallback: [@admin])
+ tasks = discovery.tasks
+ refute tasks.any? { |t| t[:subject] == @ledger }
+ end
+
+ test "legacy ledger without activity yields no task" do
+ discovery = Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration.new(admin_fallback: [@admin])
+ tasks = discovery.tasks
+ refute tasks.any? { |t| t[:subject] == @ledger }
+ end
+end
diff --git a/test/lib/tasks/ledgers_rake_test.rb b/test/lib/tasks/ledgers_rake_test.rb
new file mode 100644
index 00000000..c45360c3
--- /dev/null
+++ b/test/lib/tasks/ledgers_rake_test.rb
@@ -0,0 +1,39 @@
+require "test_helper"
+require "rake"
+
+class LedgersRakeTest < ActiveSupport::TestCase
+ setup do
+ Rails.application.load_tasks unless Rake::Task.task_defined?("ledgers:migrate_qbo_bound_zero_drift")
+ Rake::Task["ledgers:migrate_qbo_bound_zero_drift"].reenable
+
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.create!(name: "RakeMig-#{SecureRandom.hex(2)}")
+ @qa = QboAccount.create!(enterprise: @enterprise, client_id: "rm#{SecureRandom.hex(2)}", client_secret: "s", realm_id: "r#{SecureRandom.hex(2)}")
+ @qbo_vendor = QboVendor.create!(qbo_account: @qa, qbo_id: "v#{SecureRandom.hex(2)}", data: { "balance" => "0.0" })
+ fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "rm#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ ContributorQboVendor.create!(contributor: @contributor, qbo_account: @qa, qbo_vendor: @qbo_vendor)
+ end
+
+ test "ready legacy ledger is auto-flipped to qbo_bound" do
+ @ledger.update!(mode: :legacy)
+ Rake::Task["ledgers:migrate_qbo_bound_zero_drift"].invoke
+ assert @ledger.reload.qbo_bound?
+ end
+
+ test "blocked ledger stays legacy" do
+ @ledger.update!(mode: :legacy)
+ blocked = Ledgers::QboBoundMigrationCheck::Result.new(
+ current_balance: 0, current_unsettled: 0, proposed_balance: 100, proposed_unsettled: 0,
+ balance_delta: 100, unsettled_delta: 0,
+ stacks_open_total: 100, qbo_vendor_balance: 0, qbo_diff: 100,
+ qbo_match?: false, qbo_vendor_missing?: false,
+ ready?: false, removed_neg_cas: [], removed_dias: [], dropped_paid_hosts: [], open_qbo_bills: [],
+ )
+ Ledgers::QboBoundMigrationCheck.stubs(:call).returns(blocked)
+
+ Rake::Task["ledgers:migrate_qbo_bound_zero_drift"].invoke
+ assert @ledger.reload.legacy?
+ end
+end
diff --git a/test/models/contributor_adjustment_test.rb b/test/models/contributor_adjustment_test.rb
index de4f913e..f96b5b73 100644
--- a/test/models/contributor_adjustment_test.rb
+++ b/test/models/contributor_adjustment_test.rb
@@ -65,3 +65,34 @@ def new_adj(attrs = {})
refute adj.payable?, "should be false when qbo_invoice does not exist in the qbo_account"
end
end
+
+class ContributorAdjustmentNegativeOnQboBoundTest < ActiveSupport::TestCase
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.find_or_create_by!(name: "NegCAGuard-#{SecureRandom.hex(2)}")
+ fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "ncag#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ end
+
+ test "negative CA on legacy ledger is allowed" do
+ @ledger.update!(mode: :legacy)
+ ca = ContributorAdjustment.new(ledger: @ledger, amount: -100, description: "off-platform payment")
+ ca.valid?
+ refute ca.errors[:amount].any? { |m| m.include?("QBO-bound") }, "should not trigger qbo_bound guard on legacy"
+ end
+
+ test "negative CA on qbo_bound ledger is rejected with the right error" do
+ @ledger.update!(mode: :qbo_bound)
+ ca = ContributorAdjustment.new(ledger: @ledger, amount: -100, description: "off-platform payment")
+ refute ca.valid?
+ assert ca.errors[:amount].any? { |m| m.include?("QBO-bound") }, "expected an error mentioning QBO-bound"
+ end
+
+ test "positive CA on qbo_bound ledger is allowed" do
+ @ledger.update!(mode: :qbo_bound)
+ ca = ContributorAdjustment.new(ledger: @ledger, amount: 100, description: "bonus")
+ ca.valid?
+ refute ca.errors[:amount].any? { |m| m.include?("QBO-bound") }
+ end
+end
diff --git a/test/models/ledger_test.rb b/test/models/ledger_test.rb
index f268bcf7..0f1f7e43 100644
--- a/test/models/ledger_test.rb
+++ b/test/models/ledger_test.rb
@@ -151,3 +151,176 @@ class LedgerAfterCreateCallbacksTest < ActiveSupport::TestCase
assert_equal 0, Ledger.ensure_for_contributor!(c), "expected zero new rows on second call"
end
end
+
+class LedgerModeAndPaymentMethodsTest < ActiveSupport::TestCase
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.find_or_create_by!(name: "ModeTest-#{SecureRandom.hex(2)}")
+ fp = ForecastPerson.create!(forecast_id: 992_001, email: "mode#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ end
+
+ test "mode defaults to legacy" do
+ assert_equal "legacy", @ledger.mode
+ assert @ledger.legacy?
+ refute @ledger.qbo_bound?
+ end
+
+ test "mode flips to qbo_bound" do
+ @ledger.update!(mode: :qbo_bound)
+ assert @ledger.qbo_bound?
+ refute @ledger.legacy?
+ end
+
+ test "deel_enabled? and qbo_enabled? reflect payment_methods" do
+ @ledger.update!(payment_methods: %w[deel])
+ assert @ledger.deel_enabled?
+ refute @ledger.qbo_enabled?
+
+ @ledger.update!(payment_methods: %w[qbo])
+ refute @ledger.deel_enabled?
+ assert @ledger.qbo_enabled?
+ end
+
+ test "PAYMENT_METHODS is the canonical list" do
+ assert_equal %w[deel qbo], Ledger::PAYMENT_METHODS
+ end
+
+ test "validation rejects unknown payment_methods values" do
+ @ledger.payment_methods = %w[deel justworks]
+ refute @ledger.valid?
+ assert_match(/justworks/, @ledger.errors[:payment_methods].join)
+ end
+
+ test "payment_methods_for: non-US Deel contributor → [deel]" do
+ dp = DeelPerson.create!(deel_id: "dp#{SecureRandom.hex(2)}", data: { "country" => "CA" })
+ c = Contributor.create!(forecast_person: ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "fr#{SecureRandom.hex(2)}@example.com", data: {}), deel_person_id: dp.deel_id)
+ assert_equal %w[deel], Ledger.payment_methods_for(c)
+ end
+
+ test "payment_methods_for: US Deel contributor → [qbo]" do
+ dp = DeelPerson.create!(deel_id: "dp#{SecureRandom.hex(2)}", data: { "country" => "US" })
+ c = Contributor.create!(forecast_person: ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "us#{SecureRandom.hex(2)}@example.com", data: {}), deel_person_id: dp.deel_id)
+ assert_equal %w[qbo], Ledger.payment_methods_for(c)
+ end
+
+ test "payment_methods_for: no Deel attachment → [qbo]" do
+ c = Contributor.create!(forecast_person: ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "nd#{SecureRandom.hex(2)}@example.com", data: {}))
+ assert_equal %w[qbo], Ledger.payment_methods_for(c)
+ end
+
+ test "ensure_for_contributor! sets payment_methods from contributor's deel country" do
+ Enterprise.find_or_create_by!(name: "DefaultPMBulk-#{SecureRandom.hex(2)}")
+ dp = DeelPerson.create!(deel_id: "dp#{SecureRandom.hex(2)}", data: { "country" => "DE" })
+ c = Contributor.create!(forecast_person: ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "dpm#{SecureRandom.hex(2)}@example.com", data: {}), deel_person_id: dp.deel_id)
+ # Contributor.after_create runs Ledger.ensure_for_contributor!; every ledger
+ # for c should have payment_methods set from payment_methods_for(c).
+ Ledger.where(contributor: c).each do |l|
+ assert_equal %w[deel], l.payment_methods, "ensure_for_contributor! should populate payment_methods"
+ end
+ end
+
+ test "default_payment_methods callback fires when a Ledger is built directly" do
+ # Build (not create) so the auto-create from Contributor.after_create doesn't preempt us.
+ dp = DeelPerson.create!(deel_id: "dp#{SecureRandom.hex(2)}", data: { "country" => "DE" })
+ c = Contributor.create!(forecast_person: ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "cb#{SecureRandom.hex(2)}@example.com", data: {}), deel_person_id: dp.deel_id)
+ l = Ledger.new(enterprise: Enterprise.find_or_create_by!(name: "CB-#{SecureRandom.hex(2)}"), contributor: c)
+ assert_equal [], l.payment_methods, "blank before validation"
+ l.valid?
+ assert_equal %w[deel], l.payment_methods, "callback should fill the default"
+ end
+end
+
+
+class LedgerBalanceUnderQboBoundTest < ActiveSupport::TestCase
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.find_or_create_by!(name: "QBoundBal-#{SecureRandom.hex(2)}")
+ fp = ForecastPerson.create!(forecast_id: 994_001, email: "qbb#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ end
+
+ test "legacy mode uses legacy rule (Reimbursement counts when accepted?)" do
+ @ledger.update!(mode: :legacy)
+ admin = AdminUser.create!(email: "qbblg#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123")
+ r = Reimbursement.create!(ledger: @ledger, amount: 100, description: "test reimbursement", receipts: "", accepted_at: Time.current, accepted_by: admin)
+ assert_equal 100, @ledger.balance.to_f
+ end
+
+ test "qbo_bound mode drops a paid host from BOTH balance and unsettled" do
+ @ledger.update!(mode: :qbo_bound)
+ paid = mock("qbo_bill"); paid.stubs(:paid?).returns(true)
+ payout = mock("payout")
+ payout.stubs(:payable?).returns(true)
+ payout.stubs(:qbo_bill).returns(paid)
+ payout.stubs(:signed_amount).returns(100)
+ payout.stubs(:is_a?).returns(false)
+ payout.stubs(:is_a?).with(DeelInvoiceAdjustment).returns(false)
+ payout.stubs(:is_a?).with(ContributorAdjustment).returns(false)
+
+ @ledger.stubs(:visible_items).returns([payout])
+ assert_equal 0, @ledger.balance.to_f, "paid host must not be in balance"
+ assert_equal 0, @ledger.unsettled.to_f, "paid host must not be in unsettled either — it's done"
+ end
+
+ test "qbo_bound mode keeps a non-payable host in unsettled" do
+ @ledger.update!(mode: :qbo_bound)
+ pending = mock("pending_payout")
+ pending.stubs(:payable?).returns(false)
+ pending.stubs(:signed_amount).returns(100)
+ pending.stubs(:qbo_bill).returns(nil)
+ pending.stubs(:is_a?).returns(false)
+ pending.stubs(:is_a?).with(DeelInvoiceAdjustment).returns(false)
+ pending.stubs(:is_a?).with(ContributorAdjustment).returns(false)
+
+ @ledger.stubs(:visible_items).returns([pending])
+ assert_equal 0, @ledger.balance.to_f
+ assert_equal 100, @ledger.unsettled.to_f
+ end
+
+ test "qbo_bound mode ignores DIAs entirely" do
+ @ledger.update!(mode: :qbo_bound)
+ dia = mock("dia")
+ dia.stubs(:is_a?).returns(false)
+ dia.stubs(:is_a?).with(DeelInvoiceAdjustment).returns(true)
+ dia.stubs(:signed_amount).returns(-50)
+
+ @ledger.stubs(:visible_items).returns([dia])
+ assert_equal 0, @ledger.balance.to_f
+ assert_equal 0, @ledger.unsettled.to_f
+ end
+
+ test "qbo_bound mode ignores negative CAs" do
+ @ledger.update!(mode: :qbo_bound)
+ neg = mock("neg_ca")
+ neg.stubs(:is_a?).returns(false)
+ neg.stubs(:is_a?).with(DeelInvoiceAdjustment).returns(false)
+ neg.stubs(:is_a?).with(ContributorAdjustment).returns(true)
+ neg.stubs(:amount).returns(-100)
+ neg.stubs(:signed_amount).returns(-100)
+
+ @ledger.stubs(:visible_items).returns([neg])
+ assert_equal 0, @ledger.balance.to_f
+ assert_equal 0, @ledger.unsettled.to_f
+ end
+
+ test "qbo_bound mode contributes the QBO bill's remaining balance for partial payments" do
+ @ledger.update!(mode: :qbo_bound)
+ partial = mock("qbo_bill"); partial.stubs(:paid?).returns(false); partial.stubs(:remaining_balance).returns(0.4)
+ host = mock("partial_payout")
+ host.stubs(:payable?).returns(true)
+ host.stubs(:qbo_bill).returns(partial)
+ host.stubs(:qbo_bound_balance_amount).returns(0.4)
+ host.stubs(:amount).returns(1778.4)
+ host.stubs(:signed_amount).returns(1778.4)
+ host.stubs(:is_a?).returns(false)
+ host.stubs(:is_a?).with(DeelInvoiceAdjustment).returns(false)
+ host.stubs(:is_a?).with(ContributorAdjustment).returns(false)
+
+ @ledger.stubs(:visible_items).returns([host])
+ assert_in_delta 0.4, @ledger.balance.to_f, 0.001, "contribution should be qbo_bill.remaining_balance, not amount"
+ assert_equal 0, @ledger.unsettled.to_f
+ end
+end
diff --git a/test/services/ledgers/qbo_bound_migration_check_test.rb b/test/services/ledgers/qbo_bound_migration_check_test.rb
new file mode 100644
index 00000000..3620909a
--- /dev/null
+++ b/test/services/ledgers/qbo_bound_migration_check_test.rb
@@ -0,0 +1,101 @@
+require "test_helper"
+
+class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @qa = QboAccount.create!(client_id: "mc#{SecureRandom.hex(2)}", client_secret: "s", realm_id: "r#{SecureRandom.hex(2)}", enterprise: nil) rescue nil
+ @enterprise = Enterprise.create!(name: "MigCheck-#{SecureRandom.hex(2)}")
+ @qa = QboAccount.create!(enterprise: @enterprise, client_id: "mc#{SecureRandom.hex(2)}", client_secret: "s", realm_id: "r#{SecureRandom.hex(2)}")
+ @qbo_vendor = QboVendor.create!(qbo_account: @qa, qbo_id: "v#{SecureRandom.hex(2)}", data: { "balance" => "0.0", "display_name" => "Test" })
+ fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "mc#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ ContributorQboVendor.create!(contributor: @contributor, qbo_account: @qa, qbo_vendor: @qbo_vendor)
+ end
+
+ test "empty legacy ledger with QBO vendor at $0 is ready" do
+ result = Ledgers::QboBoundMigrationCheck.call(@ledger)
+ assert result.ready?
+ assert result.qbo_match?
+ refute result.qbo_vendor_missing?
+ assert_in_delta 0, result.qbo_diff, 0.001
+ end
+
+ test "result struct exposes the required fields" do
+ r = Ledgers::QboBoundMigrationCheck.call(@ledger)
+ assert_respond_to r, :current_balance
+ assert_respond_to r, :proposed_balance
+ assert_respond_to r, :balance_delta
+ assert_respond_to r, :ready?
+ assert_respond_to r, :removed_neg_cas
+ assert_respond_to r, :removed_dias
+ assert_respond_to r, :dropped_paid_hosts
+ assert_respond_to r, :open_qbo_bills
+ assert_respond_to r, :stacks_open_total
+ assert_respond_to r, :qbo_vendor_balance
+ assert_respond_to r, :qbo_diff
+ assert_respond_to r, :qbo_match?
+ assert_respond_to r, :qbo_vendor_missing?
+ end
+
+ test "blocked when Stacks open total does not match QBO vendor balance" do
+ # Need a non-trivial ledger so the empty-ledger bypass doesn't kick in.
+ payable_payout = mock("payout")
+ payable_payout.stubs(:payable?).returns(true)
+ payable_payout.stubs(:signed_amount).returns(100)
+ payable_payout.stubs(:qbo_bill).returns(nil)
+ payable_payout.stubs(:is_a?).returns(false)
+ payable_payout.stubs(:is_a?).with(DeelInvoiceAdjustment).returns(false)
+ payable_payout.stubs(:is_a?).with(ContributorAdjustment).returns(false)
+ @ledger.stubs(:visible_items).returns([payable_payout])
+ @ledger.stubs(:qbo_bound_visible_items).returns([payable_payout])
+ @ledger.stubs(:qbo_bound_open_items).returns([payable_payout])
+
+ @qbo_vendor.update!(data: { "balance" => "999.0", "display_name" => "Test" })
+ result = Ledgers::QboBoundMigrationCheck.call(@ledger)
+ refute result.ready?
+ refute result.qbo_match?
+ assert_in_delta(-899, result.qbo_diff, 0.01)
+ end
+
+ test "blocked when contributor has no QBO vendor mapping and ledger has activity" do
+ payable_payout = mock("payout")
+ payable_payout.stubs(:payable?).returns(true)
+ payable_payout.stubs(:signed_amount).returns(100)
+ payable_payout.stubs(:qbo_bill).returns(nil)
+ payable_payout.stubs(:is_a?).returns(false)
+ payable_payout.stubs(:is_a?).with(DeelInvoiceAdjustment).returns(false)
+ payable_payout.stubs(:is_a?).with(ContributorAdjustment).returns(false)
+ @ledger.stubs(:visible_items).returns([payable_payout])
+ @ledger.stubs(:qbo_bound_visible_items).returns([payable_payout])
+ @ledger.stubs(:qbo_bound_open_items).returns([payable_payout])
+
+ ContributorQboVendor.where(contributor: @contributor).destroy_all
+ result = Ledgers::QboBoundMigrationCheck.call(@ledger)
+ refute result.ready?
+ assert result.qbo_vendor_missing?
+ end
+
+ test "trivially-empty ledger is ready even without QBO vendor mapping" do
+ ContributorQboVendor.where(contributor: @contributor).destroy_all
+ result = Ledgers::QboBoundMigrationCheck.call(@ledger)
+ assert result.ready?, "empty ledger should auto-flip regardless of QBO vendor state"
+ end
+
+ test "Δ between legacy and qbo_bound is surfaced as diagnostic info" do
+ paid_qb = mock("qbo_bill"); paid_qb.stubs(:paid?).returns(true)
+ cp = ContributorPayout.new(amount: 100)
+ cp.stubs(:payable?).returns(true)
+ cp.stubs(:qbo_bill).returns(paid_qb)
+ cp.stubs(:signed_amount).returns(100)
+
+ neg_ca = ContributorAdjustment.new(amount: -50)
+ neg_ca.stubs(:signed_amount).returns(-50)
+
+ @ledger.stubs(:visible_items).returns([cp, neg_ca])
+ @ledger.stubs(:qbo_bound_visible_items).returns([cp])
+
+ result = Ledgers::QboBoundMigrationCheck.call(@ledger)
+ assert_in_delta(-50, result.balance_delta, 0.01)
+ end
+end
diff --git a/test/services/money/payable_qbo_bills_test.rb b/test/services/money/payable_qbo_bills_test.rb
new file mode 100644
index 00000000..6098be0f
--- /dev/null
+++ b/test/services/money/payable_qbo_bills_test.rb
@@ -0,0 +1,51 @@
+require "test_helper"
+
+class Money::PayableQboBillsTest < ActiveSupport::TestCase
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.create!(name: "PayableEnt-#{SecureRandom.hex(2)}")
+ @qa = QboAccount.create!(enterprise: @enterprise, client_id: "c", client_secret: "s", realm_id: "pq#{SecureRandom.hex(2)}")
+ @vendor = QboVendor.create!(qbo_id: "VND-pq#{SecureRandom.hex(3)}", qbo_account: @qa, data: {})
+ fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "pq#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ @ledger.update!(payment_methods: %w[qbo])
+ end
+
+ test "returns rows only for hosts on qbo-enabled ledgers" do
+ @ledger.update!(payment_methods: %w[deel]) # NOT qbo
+ open_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b1", qbo_vendor_id: @vendor.qbo_id, data: { "balance" => "100" })
+ ca = ContributorAdjustment.create!(ledger: @ledger, qbo_account: @qa, amount: 100, effective_on: Date.current, qbo_bill_id: open_bill.qbo_id, description: "x")
+ ContributorAdjustment.any_instance.stubs(:payable?).returns(true)
+
+ rows = Money::PayableQboBills.call(qbo_account: @qa)
+ refute rows.any? { |r| r.host == ca }
+ end
+
+ test "returns rows for payable hosts whose qbo_bill is open" do
+ open_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b2", qbo_vendor_id: @vendor.qbo_id, data: { "balance" => "100" })
+ ca = ContributorAdjustment.create!(ledger: @ledger, qbo_account: @qa, amount: 100, effective_on: Date.current, qbo_bill_id: open_bill.qbo_id, description: "y")
+ ContributorAdjustment.any_instance.stubs(:payable?).returns(true)
+
+ rows = Money::PayableQboBills.call(qbo_account: @qa)
+ assert rows.any? { |r| r.host.id == ca.id && r.qbo_bill.qbo_id == "b2" }
+ end
+
+ test "excludes paid bills" do
+ paid_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b3", qbo_vendor_id: @vendor.qbo_id, data: { "balance" => "0" })
+ ca = ContributorAdjustment.create!(ledger: @ledger, qbo_account: @qa, amount: 100, effective_on: Date.current, qbo_bill_id: paid_bill.qbo_id, description: "z")
+ ContributorAdjustment.any_instance.stubs(:payable?).returns(true)
+
+ rows = Money::PayableQboBills.call(qbo_account: @qa)
+ refute rows.any? { |r| r.host.id == ca.id }
+ end
+
+ test "excludes non-payable hosts" do
+ open_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b4", qbo_vendor_id: @vendor.qbo_id, data: { "balance" => "100" })
+ ca = ContributorAdjustment.create!(ledger: @ledger, qbo_account: @qa, amount: 100, effective_on: Date.current, qbo_bill_id: open_bill.qbo_id, description: "w")
+ ContributorAdjustment.any_instance.stubs(:payable?).returns(false)
+
+ rows = Money::PayableQboBills.call(qbo_account: @qa)
+ refute rows.any? { |r| r.host.id == ca.id }
+ end
+end
diff --git a/test/services/money/refresh_payable_qbo_bills_test.rb b/test/services/money/refresh_payable_qbo_bills_test.rb
new file mode 100644
index 00000000..d942f271
--- /dev/null
+++ b/test/services/money/refresh_payable_qbo_bills_test.rb
@@ -0,0 +1,24 @@
+require "test_helper"
+
+class Money::RefreshPayableQboBillsTest < ActiveSupport::TestCase
+ setup do
+ Thread.current[:sanctuary_enterprise] = nil
+ @enterprise = Enterprise.create!(name: "RefreshEnt-#{SecureRandom.hex(2)}")
+ @qa = QboAccount.create!(enterprise: @enterprise, client_id: "c", client_secret: "s", realm_id: "rfp#{SecureRandom.hex(2)}")
+ @vendor = QboVendor.create!(qbo_id: "VND-rfp#{SecureRandom.hex(3)}", qbo_account: @qa, data: {})
+ fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "rfp#{SecureRandom.hex(2)}@example.com", data: {})
+ @contributor = Contributor.create!(forecast_person: fp)
+ @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor)
+ @ledger.update!(payment_methods: %w[qbo])
+
+ @bill = QboBill.create!(qbo_account: @qa, qbo_id: "rfb1", qbo_vendor_id: @vendor.qbo_id, data: { "balance" => "100" })
+ @ca = ContributorAdjustment.create!(ledger: @ledger, qbo_account: @qa, amount: 100, effective_on: Date.current, qbo_bill_id: @bill.qbo_id, description: "test")
+ end
+
+ test "calls sync_qbo_bill! on every row returned by PayableQboBills" do
+ ContributorAdjustment.any_instance.stubs(:payable?).returns(true)
+ ContributorAdjustment.any_instance.expects(:sync_qbo_bill!).at_least_once
+
+ Money::RefreshPayableQboBills.call(qbo_account: @qa)
+ end
+end
|