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) +

+ + <% 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