From 678aa809a7338f3aed6b5e7fa57901b2dee79b57 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 14:01:50 -0400 Subject: [PATCH 01/67] Add LedgerWithdrawalRequest model + join table Contributor self-service flow that will let them queue a "pay me for these bills" request without admins having to initiate it. Two tables: - ledger_withdrawal_requests: belongs_to :ledger, with processed_at / cancelled_at / paid_via columns. paid_via records how the controller resolved it (deel / qbo_bill_pay / manual) once processed; nullable while pending. - ledger_withdrawal_request_bills (join): one row per Bill the contributor selected. Bills are keyed by (qbo_account_id, qbo_id) matching the rest of the QBO-side records, with an amount_snapshot so per-row totals stay stable against QBO-side amount edits. The model also exposes maybe_auto_process! which the daily QBO sync will call: once every Bill in a request is Paid in QBO, processed_at flips with paid_via = qbo_bill_pay without requiring a controller click. QboBill#paid? now reads off the balance field of the synced JSONB. Co-Authored-By: Claude Opus 4.7 --- app/models/contributor.rb | 1 + app/models/ledger.rb | 1 + app/models/ledger_withdrawal_request.rb | 81 +++++++++++++++++++ app/models/ledger_withdrawal_request_bill.rb | 24 ++++++ app/models/qbo_bill.rb | 13 +++ ...35814_create_ledger_withdrawal_requests.rb | 39 +++++++++ db/schema.rb | 41 +++++++++- 7 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 app/models/ledger_withdrawal_request.rb create mode 100644 app/models/ledger_withdrawal_request_bill.rb create mode 100644 db/migrate/20260606135814_create_ledger_withdrawal_requests.rb diff --git a/app/models/contributor.rb b/app/models/contributor.rb index ac13a9c0..9fb279b7 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -64,6 +64,7 @@ def qbo_vendor_for(qbo_account) has_many :deel_invoice_adjustments, through: :ledgers has_many :pay_stubs, through: :ledgers has_many :recurring_ledger_adjustments, through: :ledgers + has_many :ledger_withdrawal_requests, through: :ledgers # Each *_with_deleted method below is memoized per-instance. The first call # fires a query; subsequent calls return the cached array. diff --git a/app/models/ledger.rb b/app/models/ledger.rb index d715e590..5de56abe 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -10,6 +10,7 @@ class Ledger < ApplicationRecord has_many :deel_invoice_adjustments has_many :pay_stubs has_many :recurring_ledger_adjustments, dependent: :destroy + has_many :ledger_withdrawal_requests, dependent: :destroy validates :enterprise_id, uniqueness: { scope: :contributor_id } diff --git a/app/models/ledger_withdrawal_request.rb b/app/models/ledger_withdrawal_request.rb new file mode 100644 index 00000000..697995cd --- /dev/null +++ b/app/models/ledger_withdrawal_request.rb @@ -0,0 +1,81 @@ +class LedgerWithdrawalRequest < ApplicationRecord + PAID_VIA_DEEL = "deel".freeze + PAID_VIA_QBO_BILL_PAY = "qbo_bill_pay".freeze + PAID_VIA_MANUAL = "manual".freeze + PAID_VIA_VALUES = [PAID_VIA_DEEL, PAID_VIA_QBO_BILL_PAY, PAID_VIA_MANUAL].freeze + + belongs_to :ledger + belongs_to :cancelled_by, class_name: "AdminUser", optional: true + belongs_to :deel_invoice_adjustment, optional: true + has_many :bills, + class_name: "LedgerWithdrawalRequestBill", + dependent: :destroy, + inverse_of: :ledger_withdrawal_request + has_one :contributor, through: :ledger + has_one :enterprise, through: :ledger + + accepts_nested_attributes_for :bills + + validates :requested_at, presence: true + validates :paid_via, inclusion: { in: PAID_VIA_VALUES }, allow_nil: true + validate :paid_via_set_iff_processed + validate :cannot_be_processed_and_cancelled + + scope :pending, -> { where(processed_at: nil, cancelled_at: nil) } + scope :processed, -> { where.not(processed_at: nil) } + scope :cancelled, -> { where.not(cancelled_at: nil) } + + def pending? + processed_at.nil? && cancelled_at.nil? + end + + def processed? + processed_at.present? + end + + def cancelled? + cancelled_at.present? + end + + # Sum of the amount_snapshot column across every included bill — what the + # contributor saw on the selection screen when they submitted. Stable + # against later QBO-side amount edits. + def total_amount + bills.sum(:amount_snapshot) + end + + # Subset of bills whose linked QboBill mirror is marked Paid. Drives the + # "N / M paid" progress display and the auto-process trigger. + def paid_bills + bills.select(&:paid?) + end + + def all_bills_paid? + bills.any? && bills.all?(&:paid?) + end + + # Auto-process: when every Bill in this request is Paid in QBO, flip + # processed_at without requiring a controller click. Called by the daily + # QBO sync after the QboBill mirror updates. Idempotent. + def maybe_auto_process!(paid_via: PAID_VIA_QBO_BILL_PAY) + return if processed? || cancelled? + return unless all_bills_paid? + update!(processed_at: Time.current, paid_via: paid_via) + end + + private + + def paid_via_set_iff_processed + if processed_at.present? && paid_via.blank? + errors.add(:paid_via, "must be set when processed_at is set") + elsif processed_at.blank? && paid_via.present? + errors.add(:paid_via, "must be blank when processed_at is blank") + end + end + + def cannot_be_processed_and_cancelled + if processed_at.present? && cancelled_at.present? + errors.add(:base, "cannot be both processed and cancelled") + end + end +end diff --git a/app/models/ledger_withdrawal_request_bill.rb b/app/models/ledger_withdrawal_request_bill.rb new file mode 100644 index 00000000..115d4486 --- /dev/null +++ b/app/models/ledger_withdrawal_request_bill.rb @@ -0,0 +1,24 @@ +class LedgerWithdrawalRequestBill < ApplicationRecord + belongs_to :ledger_withdrawal_request, inverse_of: :bills + belongs_to :qbo_account + + validates :qbo_bill_id, presence: true + validates :amount_snapshot, presence: true + validates :qbo_bill_id, uniqueness: { + scope: [:ledger_withdrawal_request_id, :qbo_account_id], + message: "already attached to this request", + } + + # Resolve the local QboBill mirror via the composite (qbo_account_id, qbo_id) + # key — same pattern SyncsAsQboBill hosts use. Memoized per instance. + def qbo_bill + return @_qbo_bill if defined?(@_qbo_bill) + @_qbo_bill = QboBill.find_by(qbo_account_id: qbo_account_id, qbo_id: qbo_bill_id) + end + + # "Paid in QBO" is the source of truth. Defer to the QboBill mirror's + # status field, which the daily sync updates from QBO. + def paid? + qbo_bill&.paid? || false + end +end diff --git a/app/models/qbo_bill.rb b/app/models/qbo_bill.rb index 46d00a7d..bae7fa74 100644 --- a/app/models/qbo_bill.rb +++ b/app/models/qbo_bill.rb @@ -15,6 +15,19 @@ 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 + def delete_qbo_bill! begin qbo_account.delete_bill(qbo_account.fetch_bill_by_id(qbo_id)) diff --git a/db/migrate/20260606135814_create_ledger_withdrawal_requests.rb b/db/migrate/20260606135814_create_ledger_withdrawal_requests.rb new file mode 100644 index 00000000..e0b8d8a7 --- /dev/null +++ b/db/migrate/20260606135814_create_ledger_withdrawal_requests.rb @@ -0,0 +1,39 @@ +class CreateLedgerWithdrawalRequests < ActiveRecord::Migration[6.1] + def change + create_table :ledger_withdrawal_requests do |t| + t.references :ledger, null: false, foreign_key: true + t.datetime :requested_at, null: false + t.datetime :processed_at + t.datetime :cancelled_at + t.references :cancelled_by, foreign_key: { to_table: :admin_users } + t.text :cancelled_reason + t.text :notes + # How the controller resolved the request, set when processed_at flips. + # One of: "deel", "qbo_bill_pay", "manual". + t.string :paid_via + t.bigint :deel_invoice_adjustment_id + t.timestamps + end + add_index :ledger_withdrawal_requests, :processed_at + add_index :ledger_withdrawal_requests, :cancelled_at + + create_table :ledger_withdrawal_request_bills do |t| + t.references :ledger_withdrawal_request, null: false, foreign_key: true, index: { name: "idx_lwrb_on_request_id" } + # Bills are identified by (qbo_account_id, qbo_id) — same composite key + # the rest of the QBO-side records use. Storing both lets us go straight + # to QboBill.find_by(qbo_account_id:, qbo_id:) without an extra join. + t.bigint :qbo_account_id, null: false + t.string :qbo_bill_id, null: false + # Snapshot of the Bill's amount at request time so the show page and + # totals stay stable even if a QBO-side edit changes the live amount. + t.decimal :amount_snapshot, precision: 12, scale: 2, null: false + t.timestamps + end + add_index :ledger_withdrawal_request_bills, + [:ledger_withdrawal_request_id, :qbo_account_id, :qbo_bill_id], + unique: true, name: "idx_lwrb_unique_per_bill" + add_index :ledger_withdrawal_request_bills, + [:qbo_account_id, :qbo_bill_id], + name: "idx_lwrb_on_bill" + end +end diff --git a/db/schema.rb b/db/schema.rb index 358a54fc..f4bf082b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2026_06_02_155349) do +ActiveRecord::Schema.define(version: 2026_06_06_135814) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gist" @@ -401,6 +401,36 @@ t.check_constraint "(company_treasury_split >= (0)::numeric) AND (company_treasury_split <= (1)::numeric)", name: "check_company_treasury_split_range" end + create_table "ledger_withdrawal_request_bills", force: :cascade do |t| + t.bigint "ledger_withdrawal_request_id", null: false + t.bigint "qbo_account_id", null: false + t.string "qbo_bill_id", null: false + t.decimal "amount_snapshot", precision: 12, scale: 2, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["ledger_withdrawal_request_id", "qbo_account_id", "qbo_bill_id"], name: "idx_lwrb_unique_per_bill", unique: true + t.index ["ledger_withdrawal_request_id"], name: "idx_lwrb_on_request_id" + t.index ["qbo_account_id", "qbo_bill_id"], name: "idx_lwrb_on_bill" + end + + create_table "ledger_withdrawal_requests", force: :cascade do |t| + t.bigint "ledger_id", null: false + t.datetime "requested_at", null: false + t.datetime "processed_at" + t.datetime "cancelled_at" + t.bigint "cancelled_by_id" + t.text "cancelled_reason" + t.text "notes" + t.string "paid_via" + t.bigint "deel_invoice_adjustment_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["cancelled_at"], name: "index_ledger_withdrawal_requests_on_cancelled_at" + t.index ["cancelled_by_id"], name: "index_ledger_withdrawal_requests_on_cancelled_by_id" + t.index ["ledger_id"], name: "index_ledger_withdrawal_requests_on_ledger_id" + t.index ["processed_at"], name: "index_ledger_withdrawal_requests_on_processed_at" + end + create_table "ledgers", force: :cascade do |t| t.bigint "enterprise_id", null: false t.bigint "contributor_id", null: false @@ -1191,14 +1221,14 @@ add_foreign_key "account_lead_periods", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "qbo_accounts" - # Composite FK fk_adhoc_invoice_trackers_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "adhoc_invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_adhoc_invoice_trackers_qbo_invoice" add_foreign_key "admin_user_salary_windows", "admin_users" add_foreign_key "associates_award_agreements", "admin_users" add_foreign_key "commissions", "contributors" add_foreign_key "commissions", "project_trackers" add_foreign_key "contributor_adjustments", "ledgers" add_foreign_key "contributor_adjustments", "qbo_accounts" - # Composite FK fk_contributor_adjustments_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "contributor_adjustments", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_contributor_adjustments_qbo_invoice" add_foreign_key "contributor_payouts", "admin_users", column: "created_by_id" add_foreign_key "contributor_payouts", "invoice_trackers" add_foreign_key "contributor_payouts", "ledgers" @@ -1217,7 +1247,10 @@ add_foreign_key "invoice_trackers", "admin_users" add_foreign_key "invoice_trackers", "invoice_passes" add_foreign_key "invoice_trackers", "qbo_accounts" - # Composite FK fk_invoice_trackers_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_invoice_trackers_qbo_invoice" + add_foreign_key "ledger_withdrawal_request_bills", "ledger_withdrawal_requests" + add_foreign_key "ledger_withdrawal_requests", "admin_users", column: "cancelled_by_id" + add_foreign_key "ledger_withdrawal_requests", "ledgers" add_foreign_key "ledgers", "contributors" add_foreign_key "ledgers", "enterprises" add_foreign_key "mailing_list_subscribers", "mailing_lists" From 10fe358d077539caeba102c760d02c6a6418a194 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 14:05:06 -0400 Subject: [PATCH 02/67] Add contributor-facing selection UI for withdrawal requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnumerateCandidateBills walks every SyncsAsQboBill host on the ledger (CP / CA / ProfitShare / Trueup / PayStub) and decides each row's candidacy. Selectable iff: the host is payable?, the QBO Bill mirror exists locally, the Bill isn't already Paid in QBO, and the Bill isn't already claimed by an open request on this ledger. Gray rows surface the specific reason — "Not yet payable" / "Already paid in QBO" / "Already in an open withdrawal request" etc. LedgerWithdrawalRequests admin registers under Money. The new action re-resolves every selected (qbo_account_id, qbo_bill_id) against the candidate enumeration on create (never trusts form-side amounts), discards selections that turned non-selectable between page load and submit, and snapshots amounts at request time. Contributor show page picks up a "Request Payment" action item next to the existing Deel / Reimbursement buttons, scoped to the currently viewed ledger tab. Adapter rule lets any contributor reach :index / :new / :create / :read; the controller's verify_ledger_access! gates which ledger they can request against. Co-Authored-By: Claude Opus 4.7 --- app/admin/contributors.rb | 11 ++ app/admin/ledger_withdrawal_requests.rb | 117 ++++++++++++++++++ app/models/admin_authorization.rb | 12 ++ .../enumerate_candidate_bills.rb | 106 ++++++++++++++++ .../ledger_withdrawal_requests/_show.html.erb | 66 ++++++++++ .../ledger_withdrawal_requests/new.html.erb | 64 ++++++++++ 6 files changed, 376 insertions(+) create mode 100644 app/admin/ledger_withdrawal_requests.rb create mode 100644 app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb create mode 100644 app/views/admin/ledger_withdrawal_requests/_show.html.erb create mode 100644 app/views/admin/ledger_withdrawal_requests/new.html.erb diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index 4ee1adf8..ea88caab 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -71,6 +71,17 @@ def manual_deel_invoice_visible?(contributor) end end + action_item :request_payment, only: :show do + selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger]) + if selected_ledger + link_to "Request Payment", + new_admin_ledger_withdrawal_request_path(ledger_id: selected_ledger.id) + else + link_to "Request Payment", "#", + onclick: "alert(#{LEDGER_REQUIRED_ALERT.to_json}); return false;" + end + end + action_item :submit_reimbursement, only: :show do selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger]) if selected_ledger diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb new file mode 100644 index 00000000..a8b658dd --- /dev/null +++ b/app/admin/ledger_withdrawal_requests.rb @@ -0,0 +1,117 @@ +ActiveAdmin.register LedgerWithdrawalRequest do + menu label: "Withdrawal Requests", parent: "Money" + config.filters = false + actions :index, :new, :create, :show + + permit_params :ledger_id, :notes, bills_attributes: [:qbo_account_id, :qbo_bill_id, :amount_snapshot] + + controller do + helper_method :enumerate_candidates_for, :contributor_owns_ledger? + + before_action :require_ledger_param, only: [:new] + before_action :verify_ledger_access!, only: [:new, :create] + + def require_ledger_param + return if params[:ledger_id].present? + redirect_to admin_root_path, alert: "A ledger must be selected before submitting a withdrawal request." + end + + def verify_ledger_access! + ledger_id = params[:ledger_id] || params.dig(:ledger_withdrawal_request, :ledger_id) + return redirect_to(admin_root_path, alert: "Ledger not specified.") if ledger_id.blank? + + ledger = Ledger.find_by(id: ledger_id) + return redirect_to(admin_root_path, alert: "Ledger not found.") if ledger.nil? + + return if current_admin_user.is_admin? + return if contributor_owns_ledger?(ledger) + + redirect_to admin_root_path, alert: "You cannot submit withdrawals for that ledger." + end + + def contributor_owns_ledger?(ledger) + fp = current_admin_user.forecast_person + return false if fp.nil? + ledger.contributor_id == fp.contributor&.id + end + + def enumerate_candidates_for(ledger) + LedgerWithdrawalRequests::EnumerateCandidateBills.call(ledger) + end + + def new + ledger = Ledger.find(params[:ledger_id]) + @ledger = ledger + @candidates = enumerate_candidates_for(ledger) + @ledger_withdrawal_request = LedgerWithdrawalRequest.new(ledger: ledger) + end + + def create + ledger = Ledger.find(params.dig(:ledger_withdrawal_request, :ledger_id)) + selected_keys = Array(params.dig(:ledger_withdrawal_request, :selected_bill_keys)).reject(&:blank?) + + if selected_keys.empty? + @ledger = ledger + @candidates = enumerate_candidates_for(ledger) + @ledger_withdrawal_request = LedgerWithdrawalRequest.new(ledger: ledger) + flash.now[:alert] = "Select at least one bill to request payment for." + render :new, status: :unprocessable_entity + return + end + + # Re-resolve every selected (qbo_account_id, qbo_bill_id) against the + # candidate list — never trust the form's amount field for a value we + # snapshot. This also drops anything the contributor selected but is no + # longer selectable (paid in QBO between page load and submit, etc). + candidates_by_key = enumerate_candidates_for(ledger).index_by { |r| "#{r.qbo_account_id}:#{r.qbo_bill_id}" } + valid_rows = selected_keys.filter_map { |key| candidates_by_key[key] }.select(&:selectable) + + if valid_rows.empty? + redirect_to(new_admin_ledger_withdrawal_request_path(ledger_id: ledger.id), + alert: "None of the selected bills are still eligible. Try again.") + return + end + + req = LedgerWithdrawalRequest.create!( + ledger: ledger, + requested_at: Time.current, + notes: params.dig(:ledger_withdrawal_request, :notes), + bills_attributes: valid_rows.map { |r| { qbo_account_id: r.qbo_account_id, qbo_bill_id: r.qbo_bill_id, amount_snapshot: r.amount } }, + ) + + redirect_to admin_ledger_withdrawal_request_path(req), notice: "Withdrawal request submitted." + end + + def scoped_collection + super.includes(:ledger, :enterprise, :contributor, bills: :qbo_account) + end + end + + index download_links: false do + column :contributor do |r| + r.contributor.forecast_person&.email || "Contributor ##{r.contributor.id}" + end + column :enterprise do |r| + r.enterprise.name + end + column "Bills", &:bills + column "Total" do |r| + number_to_currency(r.total_amount) + end + column :requested_at + column :status do |r| + if r.cancelled? + status_tag("Cancelled") + elsif r.processed? + status_tag("Processed (#{r.paid_via})") + else + status_tag("Pending") + end + end + actions + end + + show do + render partial: "show", locals: { resource: resource } + end +end diff --git a/app/models/admin_authorization.rb b/app/models/admin_authorization.rb index 7c76daaf..bd66216d 100644 --- a/app/models/admin_authorization.rb +++ b/app/models/admin_authorization.rb @@ -73,6 +73,18 @@ def authorized?(action, subject = nil) end end + # Ledger withdrawal requests: any contributor can browse / submit / view + # them at the adapter level; the controller's verify_ledger_access! + # filters whose ledger they can request against. + if subject.is_a?(LedgerWithdrawalRequest) || subject == LedgerWithdrawalRequest + if user.forecast_person&.contributor.present? + return true if [:index, :new, :create, :read].include?(action) + if subject.is_a?(LedgerWithdrawalRequest) && subject.ledger.contributor_id == user.forecast_person.contributor.id + return true unless OWN_LEDGER_ITEM_DENY.include?(action) + end + end + end + if subject.is_a?(Reimbursement) || subject == Reimbursement if user.forecast_person&.contributor.present? return true if action == :create diff --git a/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb b/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb new file mode 100644 index 00000000..69bf361f --- /dev/null +++ b/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb @@ -0,0 +1,106 @@ +module LedgerWithdrawalRequests + # Walks every SyncsAsQboBill host attached to a ledger and returns one row + # per linked Bill (with its candidacy state) — the selection screen renders + # each, with grayed-out reasons for non-selectable rows. + class EnumerateCandidateBills + # Host classes to walk. Each row has #qbo_bill_id and a #payable? per + # SyncsAsQboBill, and #amount. + HOST_CLASSES = [ + ContributorPayout, + ContributorAdjustment, + ProfitShare, + Trueup, + PayStub, + ].freeze + + Row = Struct.new(:host, :qbo_bill, :qbo_bill_id, :qbo_account_id, :amount, :selectable, :reason, :description, keyword_init: true) + + def self.call(ledger) + new(ledger).call + end + + def initialize(ledger) + @ledger = ledger + end + + def call + claimed_bill_keys = open_request_bill_keys + rows = collect_rows + rows.map { |row| annotate_candidacy(row, claimed_bill_keys) } + end + + private + + attr_reader :ledger + + def collect_rows + HOST_CLASSES.flat_map do |klass| + klass.where(ledger_id: ledger.id).map do |host| + qbo_bill_id = host.qbo_bill_id + qbo_account_id = host.qbo_account_for_bill&.id + qbo_bill = (qbo_bill_id.present? && qbo_account_id.present?) ? host.qbo_bill : nil + Row.new( + host: host, + qbo_bill: qbo_bill, + qbo_bill_id: qbo_bill_id, + qbo_account_id: qbo_account_id, + amount: host.respond_to?(:amount) ? host.amount.to_f : 0, + description: row_description(host), + selectable: false, + reason: nil, + ) + end + end + end + + def annotate_candidacy(row, claimed_bill_keys) + host = row.host + + if !host.payable? + row.reason = "Not yet payable" + return row + end + + if row.qbo_bill_id.blank? || row.qbo_account_id.blank? + row.reason = "Bill not yet pushed to QBO" + return row + end + + if row.qbo_bill.nil? + row.reason = "QBO Bill mirror missing — wait for next sync" + return row + end + + if row.qbo_bill.paid? + row.reason = "Already paid in QBO" + return row + end + + if claimed_bill_keys.include?([row.qbo_account_id, row.qbo_bill_id]) + row.reason = "Already in an open withdrawal request" + return row + end + + row.selectable = true + row + end + + # Bills already locked into a pending request on this ledger. Used to + # gray those out on the selection screen so two requests can't claim + # the same Bill. + def open_request_bill_keys + LedgerWithdrawalRequestBill + .joins(:ledger_withdrawal_request) + .where(ledger_withdrawal_requests: { ledger_id: ledger.id, processed_at: nil, cancelled_at: nil }) + .pluck(:qbo_account_id, :qbo_bill_id) + .to_set + end + + def row_description(host) + type = host.class.name.titleize + effective = host.respond_to?(:effective_on_for_display) ? host.effective_on_for_display : nil + base = effective.present? ? "#{type} — #{effective}" : type + host.respond_to?(:description) && host.description.present? ? "#{base}: #{host.description.to_s.truncate(60)}" : base + end + end +end diff --git a/app/views/admin/ledger_withdrawal_requests/_show.html.erb b/app/views/admin/ledger_withdrawal_requests/_show.html.erb new file mode 100644 index 00000000..95b2511e --- /dev/null +++ b/app/views/admin/ledger_withdrawal_requests/_show.html.erb @@ -0,0 +1,66 @@ +

+ Withdrawal Request #<%= resource.id %> + + <%= resource.contributor.forecast_person&.email %> · <%= resource.enterprise.name %> + +

+ +
+
+
+

+ Status: + <% if resource.cancelled? %> + Cancelled <%= resource.cancelled_at.to_date %> + <% if resource.cancelled_reason.present? %> + — <%= resource.cancelled_reason %> + <% end %> + <% elsif resource.processed? %> + Processed via <%= resource.paid_via %> on <%= resource.processed_at.to_date %> + <% else %> + Pending + <% end %> +  ·  + Requested: <%= resource.requested_at.to_date %> +  ·  + Total: <%= number_to_currency(resource.total_amount) %> +  ·  + Bills: <%= resource.paid_bills.size %> / <%= resource.bills.size %> paid in QBO +

+ <% if resource.notes.present? %> +

Notes: <%= resource.notes %>

+ <% end %> +
+
+
+ + + + + + + + + + + + <% resource.bills.each_with_index do |bill, idx| %> + "> + + + + + + <% end %> + +
BillAmount (at request time)Paid in QBO?QBO
#<%= bill.qbo_bill_id %><%= number_to_currency(bill.amount_snapshot) %> + <% if bill.paid? %> + Paid + <% else %> + Open + <% end %> + + <% if bill.qbo_bill.present? %> + <%= link_to "Open in QBO ↗", bill.qbo_bill.qbo_url, target: "_blank", rel: "noopener" %> + <% end %> +
diff --git a/app/views/admin/ledger_withdrawal_requests/new.html.erb b/app/views/admin/ledger_withdrawal_requests/new.html.erb new file mode 100644 index 00000000..50e01ac1 --- /dev/null +++ b/app/views/admin/ledger_withdrawal_requests/new.html.erb @@ -0,0 +1,64 @@ +<%# Selection form: contributor picks the QBO Bills they want paid. + Selectable rows have a checkbox; non-selectable rows show why. %> +

Request Payment — <%= @ledger.enterprise.name %>

+

+ Pick the bills you want to be paid for. Once submitted, the request lands on + the financial controller's desk to process via Deel or QBO Bill Pay. +

+ +<%= form_with url: admin_ledger_withdrawal_requests_path, scope: :ledger_withdrawal_request, local: true do |f| %> + <%= f.hidden_field :ledger_id, value: @ledger.id %> + + + + + + + + + + + + + <% @candidates.each_with_index do |row, idx| %> + <% key = "#{row.qbo_account_id}:#{row.qbo_bill_id}" %> + " style="<%= 'opacity: 0.5;' unless row.selectable %>"> + + + + + + + <% end %> + <% if @candidates.empty? %> + + <% end %> + +
BillAmountStatusQBO
+ <% if row.selectable %> + <%= check_box_tag "ledger_withdrawal_request[selected_bill_keys][]", key, false %> + <% else %> + + <% end %> + <%= row.description %><%= number_to_currency(row.amount) %> + <% if row.selectable %> + Ready to request + <% else %> + <%= row.reason %> + <% end %> + + <% if row.qbo_bill.present? %> + <%= link_to "View ↗", row.qbo_bill.qbo_url, target: "_blank", rel: "noopener" %> + <% end %> +
No bills found for this ledger.
+ +
+ <%= f.label :notes, "Notes (optional)" %>
+ <%= f.text_area :notes, rows: 3, style: "width: 100%;" %> +
+ +
+ <%= f.submit "Submit Withdrawal Request", class: "button" %> + <%= link_to "Cancel", admin_contributor_path(@ledger.contributor), class: "button" %> +
+<% end %> From 75fc10c0fb4588f4a84af1aa2e1304b1eb45c7cf Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 14:10:45 -0400 Subject: [PATCH 03/67] Add controller process actions, auto-process sync, and admin nag task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces: 1. ProcessViaDeel service + member_action: the controller picks a Deel contract on the request show page, submits a Deel invoice adjustment for the request total via the existing flow, and stamps the request with processed_at + paid_via=deel + the back-reference to the DeelInvoiceAdjustment. Doesn't yet POST a QBO BillPayment to mark the underlying Bills Paid in QBO — that's a follow-up. 2. Process via QBO is a deep link: "Open Vendor in QBO" opens the contributor's connected vendor record in a new tab so the controller can use Bill Pay's UI to ACH each Bill. No Stacks-side state change on click — instead, daily_enterprise_tasks now iterates pending requests after sync_all_bills! and calls maybe_auto_process!, which flips processed_at when every Bill in the request has gone Paid. 3. TaskBuilder discovery LedgerWithdrawalRequests surfaces every pending request as a task assigned to global Stacks admins (admin_fallback), mirroring MissingQboVendors. New :ledger_withdrawal_request_needs_processing type + display + subject_url wired into StacksTask. Manual escape hatches on the show page: "Mark Processed" (sets paid_via=manual; for cases where the controller paid via a path we can't auto-detect) and "Cancel Request" (with reason), both admin-only. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 67 ++++++++++++++++++- app/models/stacks_task.rb | 6 ++ .../process_via_deel.rb | 49 ++++++++++++++ .../ledger_withdrawal_requests/_show.html.erb | 37 ++++++++++ lib/stacks/task_builder.rb | 2 + .../discoveries/ledger_withdrawal_requests.rb | 24 +++++++ lib/tasks/stacks.rake | 16 +++++ 7 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 app/services/ledger_withdrawal_requests/process_via_deel.rb create mode 100644 lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index a8b658dd..81cdb96f 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -5,11 +5,76 @@ permit_params :ledger_id, :notes, bills_attributes: [:qbo_account_id, :qbo_bill_id, :amount_snapshot] + action_item :process_via_qbo, only: :show, if: proc { resource.pending? && current_admin_user.is_admin? } do + url = qbo_vendor_url_for(resource) + if url.present? + link_to "Open Vendor in QBO ↗", url, target: "_blank", rel: "noopener" + else + msg = "No QBO vendor mapped for this contributor on #{resource.enterprise.name}" + link_to "Open Vendor in QBO ↗", "#", onclick: "alert(#{msg.to_json}); return false;" + end + end + + action_item :mark_processed_manual, only: :show, if: proc { resource.pending? && current_admin_user.is_admin? } do + link_to "Mark Processed", + mark_processed_admin_ledger_withdrawal_request_path(resource), + method: :post, + data: { confirm: "Mark this request processed without changing any bills?" } + end + + action_item :cancel, only: :show, if: proc { resource.pending? && current_admin_user.is_admin? } do + link_to "Cancel Request", cancel_admin_ledger_withdrawal_request_path(resource), method: :post, + data: { confirm: "Cancel this withdrawal request? Bills go back to selectable for the contributor." } + end + + member_action :process_via_deel, method: :post do + LedgerWithdrawalRequests::ProcessViaDeel.call( + request: resource, + processed_by: current_admin_user, + contract_id: params.require(:contract_id), + description: params[:description].to_s, + date_submitted: params[:date_submitted].presence || Date.current, + ) + redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Processed via Deel." + rescue LedgerWithdrawalRequests::ProcessViaDeel::Error => e + redirect_to admin_ledger_withdrawal_request_path(resource), alert: e.message + end + + member_action :mark_processed, method: :post do + resource.update!(processed_at: Time.current, paid_via: LedgerWithdrawalRequest::PAID_VIA_MANUAL) + redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Marked processed." + end + + member_action :cancel, method: :post do + resource.update!( + cancelled_at: Time.current, + cancelled_by: current_admin_user, + cancelled_reason: params[:reason].to_s.presence || "Cancelled by admin", + ) + redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Request cancelled." + end + controller do - helper_method :enumerate_candidates_for, :contributor_owns_ledger? + helper_method :enumerate_candidates_for, :contributor_owns_ledger?, :qbo_vendor_url_for before_action :require_ledger_param, only: [:new] before_action :verify_ledger_access!, only: [:new, :create] + before_action :require_admin_for_processing!, only: [:process_via_deel, :process_manual, :cancel] + + def require_admin_for_processing! + return if current_admin_user.is_admin? + redirect_to admin_ledger_withdrawal_request_path(resource), alert: "Only Stacks admins can process or cancel requests." + end + + # Best-effort deep link to the connected QBO vendor record for this + # ledger's contributor. Used by the "Process via QBO" action. + def qbo_vendor_url_for(request) + qa = request.enterprise.qbo_account + return nil if qa.nil? + vendor = request.contributor.qbo_vendor_for(qa) + return nil if vendor.nil? + "https://qbo.intuit.com/app/vendordetail?nameId=#{vendor.qbo_id}" + end def require_ledger_param return if params[:ledger_id].present? diff --git a/app/models/stacks_task.rb b/app/models/stacks_task.rb index 85a3dfdc..7faee539 100644 --- a/app/models/stacks_task.rb +++ b/app/models/stacks_task.rb @@ -46,6 +46,9 @@ class StacksTask # Ledger issues missing_qbo_vendor_for_contributor: "Contributor needs a QBO vendor for this enterprise's ledger", + + # Withdrawal request issues + ledger_withdrawal_request_needs_processing: "Withdrawal request needs processing", }.freeze # type — Symbol classifying the task (:project_capsule_incomplete, :survey, …) @@ -99,6 +102,8 @@ def subject_display_name when Stacks::Notion::Lead then subject.try(:page_title).presence || "Notion Lead" when PayCycle then "#{subject.enterprise.name} — #{subject.starts_at.to_s(:long)} to #{subject.ends_at.to_s(:long)}" when Ledger then "#{subject.contributor.forecast_person&.email || "Contributor ##{subject.contributor_id}"} on #{subject.enterprise.name}" + when LedgerWithdrawalRequest + "#{subject.contributor.forecast_person&.email} on #{subject.enterprise.name} — #{ActionController::Base.helpers.number_to_currency(subject.total_amount)} (#{subject.bills.size} bills)" else subject.try(:display_name).presence || subject.try(:name).presence || subject.to_s end @@ -121,6 +126,7 @@ def subject_url 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 LedgerWithdrawalRequest then helpers.admin_ledger_withdrawal_request_path(subject) else subject.try(:external_link) end end diff --git a/app/services/ledger_withdrawal_requests/process_via_deel.rb b/app/services/ledger_withdrawal_requests/process_via_deel.rb new file mode 100644 index 00000000..df37c54a --- /dev/null +++ b/app/services/ledger_withdrawal_requests/process_via_deel.rb @@ -0,0 +1,49 @@ +module LedgerWithdrawalRequests + # Resolves a pending request by creating one Deel invoice adjustment for + # the request's total amount. Mirror of the manual Deel withdrawal flow, + # but driven from the controller's "Process via Deel" button on a + # request show page rather than a per-row form. + # + # Doesn't touch the underlying QBO Bills' Paid state — that's a + # follow-up. Once shipped, this service should additionally POST a + # BillPayment per Bill to keep QBO accounting in sync. + class ProcessViaDeel + class Error < StandardError; end + + def self.call(**kwargs) + new(**kwargs).call + end + + def initialize(request:, processed_by:, contract_id:, description:, date_submitted: Date.current) + @request = request + @processed_by = processed_by + @contract_id = contract_id.to_s + @description = description.to_s + @date_submitted = date_submitted + end + + def call + raise Error, "Request is not pending" unless @request.pending? + + adjustment = Contributors::SubmitDeelInvoiceAdjustment.call( + contributor: @request.contributor, + ledger: @request.ledger, + contract_id: @contract_id, + amount: @request.total_amount, + description: @description.presence || "Stacks withdrawal request ##{@request.id}", + date_submitted: @date_submitted, + skip_balance_validation: @processed_by&.is_admin? || false, + ) + + @request.update!( + processed_at: Time.current, + paid_via: LedgerWithdrawalRequest::PAID_VIA_DEEL, + deel_invoice_adjustment_id: adjustment.id, + ) + + adjustment + rescue Contributors::SubmitDeelInvoiceAdjustment::Error => e + raise Error, e.message + end + end +end diff --git a/app/views/admin/ledger_withdrawal_requests/_show.html.erb b/app/views/admin/ledger_withdrawal_requests/_show.html.erb index 95b2511e..484fff3d 100644 --- a/app/views/admin/ledger_withdrawal_requests/_show.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_show.html.erb @@ -34,6 +34,43 @@ +<% if resource.pending? && current_admin_user.is_admin? %> + <% + # Filter Deel contracts to the request's enterprise legal entity (if set) + # using the same filter the existing per-row Deel withdrawal form uses. + deel_contracts = DeelContract.sorted_for_balance_withdrawal_select( + resource.contributor.deel_person_id, + deel_legal_entity_id: resource.enterprise.deel_legal_entity_id, + ) + %> +
+
+

Process via Deel

+
+ <% if resource.contributor.deel_person_id.blank? || deel_contracts.empty? %> +

No Deel contracts available for this contributor on <%= resource.enterprise.name %>. Use the QBO path instead.

+ <% else %> + <%= form_with url: process_via_deel_admin_ledger_withdrawal_request_path(resource), method: :post, local: true do |f| %> +

+ + <%= select_tag :contract_id, options_for_select(deel_contracts.map { |dc| [dc.display_name_for_deel_invoice_select, dc.deel_id] }) %> +

+

+ + <%= text_field_tag :description, "Stacks withdrawal request ##{resource.id}", size: 60 %> +

+

+ + <%= date_field_tag :date_submitted, Date.current %> +

+

<%= submit_tag "Process via Deel — #{number_to_currency(resource.total_amount)}", class: "button", data: { confirm: "Submit a Deel invoice adjustment for #{number_to_currency(resource.total_amount)} and mark this request processed?" } %>

+ <% end %> + <% end %> +
+
+
+<% end %> + diff --git a/lib/stacks/task_builder.rb b/lib/stacks/task_builder.rb index 386fa77e..edab9317 100644 --- a/lib/stacks/task_builder.rb +++ b/lib/stacks/task_builder.rb @@ -9,6 +9,7 @@ require_relative "task_builder/discoveries/surveys" require_relative "task_builder/discoveries/pay_cycles" require_relative "task_builder/discoveries/missing_qbo_vendors" +require_relative "task_builder/discoveries/ledger_withdrawal_requests" module Stacks # Single source of truth for "what needs attention right now" across the system. @@ -49,6 +50,7 @@ class TaskBuilder Discoveries::Surveys, Discoveries::PayCycles, Discoveries::MissingQboVendors, + Discoveries::LedgerWithdrawalRequests, ].freeze # Returns Array — every open task system-wide. diff --git a/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb b/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb new file mode 100644 index 00000000..a20310d5 --- /dev/null +++ b/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb @@ -0,0 +1,24 @@ +module Stacks + class TaskBuilder + module Discoveries + # Surfaces every pending LedgerWithdrawalRequest as a task for global + # Stacks admins (mirrors MissingQboVendors — the per-enterprise admins + # don't have the QBO Bill Pay UI surfaced to them, so this lands on + # the financial controller / super-admin cohort). + class LedgerWithdrawalRequests < Base + def tasks + LedgerWithdrawalRequest + .pending + .includes(:ledger) + .map do |req| + task( + subject: req, + type: :ledger_withdrawal_request_needs_processing, + owners: @admin_fallback, + ) + end + end + end + end + end +end diff --git a/lib/tasks/stacks.rake b/lib/tasks/stacks.rake index 33f2ff4b..5d16e9f3 100644 --- a/lib/tasks/stacks.rake +++ b/lib/tasks/stacks.rake @@ -93,6 +93,22 @@ namespace :stacks do Sentry.capture_exception(e) if defined?(Sentry) end Rails.logger.info("[stacks:daily_enterprise_tasks] Materialized #{materialized} recurring ledger adjustment(s)") + + # Auto-process any pending LedgerWithdrawalRequests whose Bills have + # all flipped to Paid in QBO since the last run. The QboAccount + # sync_all! step above refreshes the QboBill mirror; this propagates + # "Bill paid in QBO" into "withdrawal request processed". Idempotent + # across same-day reruns. + auto_processed = 0 + LedgerWithdrawalRequest.pending.find_each do |req| + before = req.processed_at + req.maybe_auto_process! + auto_processed += 1 if req.processed_at.present? && before.nil? + rescue => e + Rails.logger.error("[stacks:daily_enterprise_tasks] LedgerWithdrawalRequest ##{req.id} auto-process failed: #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + end + Rails.logger.info("[stacks:daily_enterprise_tasks] Auto-processed #{auto_processed} withdrawal request(s)") rescue => e system_task.mark_as_error(e) else From ea9ed2fb8fae421f46ed4218a2ea030a77fe6c30 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 14:36:49 -0400 Subject: [PATCH 04/67] Render the selection screen inside ActiveAdmin's form DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous bare ERB template didn't pick up the AA layout (no nav, no chrome) and listed every bill on the ledger — including those already paid in QBO or not yet payable — which was both visually noisy and contradicted what the contributor actually had to choose from. Rewrite using AA's `form do |f|` DSL so the page renders with AA's nav/chrome/styling automatically. `build_resource` pre-binds the ledger from the URL param so the form can scope to it. The bill table: - shows only candidates with selectable == true (drops "Not yet payable", "Already paid in QBO", "Already in an open request") - sorts by effective_on descending (most recent first), matching the ledger view - collapses to plain Date / Type / Amount / QBO columns instead of the noisy full-description blob EnumerateCandidateBills::Row gets an effective_on field so the form can sort and display dates without redoing the lookup. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 65 +++++++++++++++++-- .../enumerate_candidate_bills.rb | 10 +-- .../ledger_withdrawal_requests/new.html.erb | 64 ------------------ 3 files changed, 65 insertions(+), 74 deletions(-) delete mode 100644 app/views/admin/ledger_withdrawal_requests/new.html.erb diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index 81cdb96f..256959fd 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -104,11 +104,12 @@ def enumerate_candidates_for(ledger) LedgerWithdrawalRequests::EnumerateCandidateBills.call(ledger) end - def new - ledger = Ledger.find(params[:ledger_id]) - @ledger = ledger - @candidates = enumerate_candidates_for(ledger) - @ledger_withdrawal_request = LedgerWithdrawalRequest.new(ledger: ledger) + # Pre-set the ledger from the URL param so AA's form DSL has access to it. + def build_resource + resource = super + ledger_id = params[:ledger_id] || params.dig(:ledger_withdrawal_request, :ledger_id) + resource.ledger ||= Ledger.find_by(id: ledger_id) if ledger_id.present? + resource end def create @@ -179,4 +180,58 @@ def scoped_collection show do render partial: "show", locals: { resource: resource } end + + form do |f| + ledger = f.object.ledger + if ledger.nil? + panel "Pick a ledger first" do + para "Open this form from a contributor's ledger tab so we know which enterprise to bill against." + end + else + candidates = LedgerWithdrawalRequests::EnumerateCandidateBills.call(ledger) + .select(&:selectable) + .sort_by { |r| [r.effective_on || Date.new(1970, 1, 1), r.qbo_bill_id.to_s] } + .reverse + + f.semantic_errors + + panel "Request payment — #{ledger.enterprise.name}" do + para "Pick the bills you want to be paid for. Once submitted, the request lands on the financial controller's desk to process via Deel or QBO Bill Pay." + end + + f.input :ledger_id, as: :hidden, input_html: { value: ledger.id } + + if candidates.empty? + panel "Nothing to request" do + para "No bills on this ledger are ready to request payment for. Anything not yet payable or already paid in QuickBooks won't appear here." + end + else + panel "Bills ready to request" do + table_for candidates do + column do |row| + key = "#{row.qbo_account_id}:#{row.qbo_bill_id}" + check_box_tag "ledger_withdrawal_request[selected_bill_keys][]", key, false + end + column("Date") { |row| row.effective_on } + column("Type") { |row| row.description } + column("Amount") { |row| number_to_currency(row.amount) } + column("QBO") do |row| + if row.qbo_bill.present? + link_to "View ↗", row.qbo_bill.qbo_url, target: "_blank", rel: "noopener" + end + end + end + end + end + + f.inputs "Notes" do + f.input :notes, as: :text, input_html: { rows: 3 }, label: false, hint: "Optional. Anything the financial controller should know." + end + + f.actions do + f.action :submit, label: "Submit Withdrawal Request" + f.cancel_link(admin_contributor_path(ledger.contributor)) + end + end + end end diff --git a/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb b/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb index 69bf361f..cca04142 100644 --- a/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb +++ b/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb @@ -13,7 +13,7 @@ class EnumerateCandidateBills PayStub, ].freeze - Row = Struct.new(:host, :qbo_bill, :qbo_bill_id, :qbo_account_id, :amount, :selectable, :reason, :description, keyword_init: true) + Row = Struct.new(:host, :qbo_bill, :qbo_bill_id, :qbo_account_id, :amount, :selectable, :reason, :description, :effective_on, keyword_init: true) def self.call(ledger) new(ledger).call @@ -46,6 +46,7 @@ def collect_rows qbo_account_id: qbo_account_id, amount: host.respond_to?(:amount) ? host.amount.to_f : 0, description: row_description(host), + effective_on: host.respond_to?(:effective_on_for_display) ? host.effective_on_for_display : nil, selectable: false, reason: nil, ) @@ -96,11 +97,10 @@ def open_request_bill_keys .to_set end + # Plain "type" label — date and description are rendered separately so + # we don't pile everything into one column. def row_description(host) - type = host.class.name.titleize - effective = host.respond_to?(:effective_on_for_display) ? host.effective_on_for_display : nil - base = effective.present? ? "#{type} — #{effective}" : type - host.respond_to?(:description) && host.description.present? ? "#{base}: #{host.description.to_s.truncate(60)}" : base + host.class.name.titleize end end end diff --git a/app/views/admin/ledger_withdrawal_requests/new.html.erb b/app/views/admin/ledger_withdrawal_requests/new.html.erb deleted file mode 100644 index 50e01ac1..00000000 --- a/app/views/admin/ledger_withdrawal_requests/new.html.erb +++ /dev/null @@ -1,64 +0,0 @@ -<%# Selection form: contributor picks the QBO Bills they want paid. - Selectable rows have a checkbox; non-selectable rows show why. %> -

Request Payment — <%= @ledger.enterprise.name %>

-

- Pick the bills you want to be paid for. Once submitted, the request lands on - the financial controller's desk to process via Deel or QBO Bill Pay. -

- -<%= form_with url: admin_ledger_withdrawal_requests_path, scope: :ledger_withdrawal_request, local: true do |f| %> - <%= f.hidden_field :ledger_id, value: @ledger.id %> - -
- - - - - - - - - - - <% @candidates.each_with_index do |row, idx| %> - <% key = "#{row.qbo_account_id}:#{row.qbo_bill_id}" %> - " style="<%= 'opacity: 0.5;' unless row.selectable %>"> - - - - - - - <% end %> - <% if @candidates.empty? %> - - <% end %> - -
BillAmountStatusQBO
- <% if row.selectable %> - <%= check_box_tag "ledger_withdrawal_request[selected_bill_keys][]", key, false %> - <% else %> - - <% end %> - <%= row.description %><%= number_to_currency(row.amount) %> - <% if row.selectable %> - Ready to request - <% else %> - <%= row.reason %> - <% end %> - - <% if row.qbo_bill.present? %> - <%= link_to "View ↗", row.qbo_bill.qbo_url, target: "_blank", rel: "noopener" %> - <% end %> -
No bills found for this ledger.
- -
- <%= f.label :notes, "Notes (optional)" %>
- <%= f.text_area :notes, rows: 3, style: "width: 100%;" %> -
- -
- <%= f.submit "Submit Withdrawal Request", class: "button" %> - <%= link_to "Cancel", admin_contributor_path(@ledger.contributor), class: "button" %> -
-<% end %> From 940cf53fd309ef0e3011cfd1ad203bc82fdeb646 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 14:48:11 -0400 Subject: [PATCH 05/67] Drop "Mark Processed" escape hatch, prompt for cancellation reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QBO is the source of truth for whether a Bill was paid — the daily sync auto-flips processed_at when every Bill in a request goes Paid, and the Deel path is its own explicit flow. The manual "Mark Processed" button only created a foot-gun where the controller could lie to Stacks about settled state, so drop it entirely. Cancellation: surface a JS prompt for the reason on the action item itself (POSTs through a synthesized form so the reason actually reaches the controller). The previous flow had no reason input and fell back to "Cancelled by admin", which read on the show page as if that string was the "by" field. Show page now displays both cancelled_by.email AND the reason (or "(no reason given)" when blank). Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 38 +++++++++++-------- .../ledger_withdrawal_requests/_show.html.erb | 3 ++ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index 256959fd..f82d2ae0 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -15,16 +15,27 @@ end end - action_item :mark_processed_manual, only: :show, if: proc { resource.pending? && current_admin_user.is_admin? } do - link_to "Mark Processed", - mark_processed_admin_ledger_withdrawal_request_path(resource), - method: :post, - data: { confirm: "Mark this request processed without changing any bills?" } - end - action_item :cancel, only: :show, if: proc { resource.pending? && current_admin_user.is_admin? } do - link_to "Cancel Request", cancel_admin_ledger_withdrawal_request_path(resource), method: :post, - data: { confirm: "Cancel this withdrawal request? Bills go back to selectable for the contributor." } + # Browser-prompt for the reason so the controller doesn't have to bounce + # through a separate form just to capture one string. JS submits the + # reason as a form param via a synthesized POST. + confirm_msg = "Cancel this withdrawal request? The bills go back to selectable for the contributor." + cancel_url = cancel_admin_ledger_withdrawal_request_path(resource) + link_to "Cancel Request", "#", onclick: <<~JS.html_safe + (function(){ + if (!confirm(#{confirm_msg.to_json})) return false; + var reason = prompt("Reason for cancelling? (optional)"); + if (reason === null) return false; + var token = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + var form = document.createElement('form'); + form.method = 'post'; + form.action = #{cancel_url.to_json}; + var t = document.createElement('input'); t.type = 'hidden'; t.name = 'authenticity_token'; t.value = token; form.appendChild(t); + if (reason) { var r = document.createElement('input'); r.type = 'hidden'; r.name = 'reason'; r.value = reason; form.appendChild(r); } + document.body.appendChild(form); form.submit(); + return false; + })(); return false; + JS end member_action :process_via_deel, method: :post do @@ -40,16 +51,11 @@ redirect_to admin_ledger_withdrawal_request_path(resource), alert: e.message end - member_action :mark_processed, method: :post do - resource.update!(processed_at: Time.current, paid_via: LedgerWithdrawalRequest::PAID_VIA_MANUAL) - redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Marked processed." - end - member_action :cancel, method: :post do resource.update!( cancelled_at: Time.current, cancelled_by: current_admin_user, - cancelled_reason: params[:reason].to_s.presence || "Cancelled by admin", + cancelled_reason: params[:reason].to_s.presence, ) redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Request cancelled." end @@ -59,7 +65,7 @@ before_action :require_ledger_param, only: [:new] before_action :verify_ledger_access!, only: [:new, :create] - before_action :require_admin_for_processing!, only: [:process_via_deel, :process_manual, :cancel] + before_action :require_admin_for_processing!, only: [:process_via_deel, :cancel] def require_admin_for_processing! return if current_admin_user.is_admin? diff --git a/app/views/admin/ledger_withdrawal_requests/_show.html.erb b/app/views/admin/ledger_withdrawal_requests/_show.html.erb index 484fff3d..f647d513 100644 --- a/app/views/admin/ledger_withdrawal_requests/_show.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_show.html.erb @@ -12,8 +12,11 @@ Status: <% if resource.cancelled? %> Cancelled <%= resource.cancelled_at.to_date %> + by <%= resource.cancelled_by&.email || "(unknown)" %> <% if resource.cancelled_reason.present? %> — <%= resource.cancelled_reason %> + <% else %> + (no reason given) <% end %> <% elsif resource.processed? %> Processed via <%= resource.paid_via %> on <%= resource.processed_at.to_date %> From 5d4f16280526348256b1a36bde8a0884ed3766ff Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 14:58:45 -0400 Subject: [PATCH 06/67] Fail fast with a friendly screen when QBO vendor isn't mapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withdrawal requests can only resolve into Bills paid against a specific QBO vendor record. If the contributor has no ContributorQboVendor for the ledger's enterprise QBO account, the form would only lead to a confusing dead end downstream. Add a before_action that catches the case and renders a small "This ledger isn't connected to QuickBooks yet" panel under AA's chrome — telling the contributor to ask the Stacks admin team to associate a QBO vendor record. Falls back to a more pointed message when the enterprise itself has no connected QBO account. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 20 +++++++++++++ .../missing_qbo_vendor.html.erb | 30 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index f82d2ae0..5592127b 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -65,8 +65,28 @@ before_action :require_ledger_param, only: [:new] before_action :verify_ledger_access!, only: [:new, :create] + before_action :verify_qbo_vendor_mapping!, only: [:new, :create] before_action :require_admin_for_processing!, only: [:process_via_deel, :cancel] + # Fail fast (with a friendly screen, not a redirect) when the contributor + # has no ContributorQboVendor for the ledger's enterprise QBO account. + # Withdrawal requests can only resolve into Bills that need to be paid + # against a specific vendor; without that mapping nothing downstream + # will work and the form would only produce a confusing dead end. + def verify_qbo_vendor_mapping! + ledger_id = params[:ledger_id] || params.dig(:ledger_withdrawal_request, :ledger_id) + ledger = Ledger.find_by(id: ledger_id) + return if ledger.nil? + + qa = ledger.enterprise.qbo_account + vendor = qa && ledger.contributor.qbo_vendor_for(qa) + return if vendor.present? + + @missing_vendor_ledger = ledger + @missing_vendor_qbo_account = qa + render :missing_qbo_vendor, status: :unprocessable_entity + end + def require_admin_for_processing! return if current_admin_user.is_admin? redirect_to admin_ledger_withdrawal_request_path(resource), alert: "Only Stacks admins can process or cancel requests." diff --git a/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb b/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb new file mode 100644 index 00000000..1c80ac79 --- /dev/null +++ b/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb @@ -0,0 +1,30 @@ +
+
+
+

🔌 This ledger isn't connected to QuickBooks yet

+
+
+

+ Your <%= @missing_vendor_ledger.enterprise.name %> ledger + doesn't have a QBO vendor record attached to your contributor account, so + we can't send a withdrawal request to QuickBooks. +

+

+ Please ask the Stacks admin team to associate a QBO vendor record to your + Stacks contributor account on <%= @missing_vendor_ledger.enterprise.name %>. + Once that's set up, this page will let you submit a request. +

+ <% if @missing_vendor_qbo_account.nil? %> +

+ Heads up: this enterprise isn't connected to a QuickBooks account + at all yet. The admin team will need to set that up first. +

+ <% end %> +

+ <%= link_to "Back to ledger ↗", + admin_contributor_path(@missing_vendor_ledger.contributor, ledger: @missing_vendor_ledger.id), + class: "button" %> +

+
+
+
From 43d0f92c5febb4e87438b0e38bb25a728d1df65a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 15:00:49 -0400 Subject: [PATCH 07/67] Render missing-vendor screen under ActiveAdmin's layout render :missing_qbo_vendor from a before_action skips the implicit layout selection AA does for its own actions, so the panel was rendered bare (no nav, no chrome). Specify layout: "active_admin" explicitly to put it back under the standard AA wrapping. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index 5592127b..9be5e42c 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -84,7 +84,7 @@ def verify_qbo_vendor_mapping! @missing_vendor_ledger = ledger @missing_vendor_qbo_account = qa - render :missing_qbo_vendor, status: :unprocessable_entity + render :missing_qbo_vendor, layout: "active_admin", status: :unprocessable_entity end def require_admin_for_processing! From 516d4f1ad40e3bc758cf5d2aa61f1da8d2531803 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 15:22:29 -0400 Subject: [PATCH 08/67] Skip form chrome on the empty-state withdrawal request page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the ledger has no candidate bills, the Notes field and Submit button were still rendered — letting a contributor "submit" a request that the server would silently reject. Replace the inner form block with a stand-alone empty-state panel that explains why nothing's listed and links back to the ledger. The full form (header, table, notes, actions) only renders when there's actually something to pick. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 43 +++++++++++++++---------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index 9be5e42c..cdc34c15 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -219,19 +219,28 @@ def scoped_collection .sort_by { |r| [r.effective_on || Date.new(1970, 1, 1), r.qbo_bill_id.to_s] } .reverse - f.semantic_errors - - panel "Request payment — #{ledger.enterprise.name}" do - para "Pick the bills you want to be paid for. Once submitted, the request lands on the financial controller's desk to process via Deel or QBO Bill Pay." - end - - f.input :ledger_id, as: :hidden, input_html: { value: ledger.id } - if candidates.empty? - panel "Nothing to request" do - para "No bills on this ledger are ready to request payment for. Anything not yet payable or already paid in QuickBooks won't appear here." + # No bills available — render the empty-state panel only. No Notes + # input, no submit button (there's nothing to submit), no Deel/QBO + # column. Just an explanation and a way back to the ledger. + panel "Nothing to request right now" do + para "No bills on your #{ledger.enterprise.name} ledger are ready to request payment for yet." + para "Anything that's not yet payable (e.g. waiting on cycle approval), already paid in QuickBooks, or already included in another open request won't show up here. Once a new bill becomes payable, come back and submit a request." + div(style: "margin-top: 16px;") do + a "Back to ledger ↗", + href: admin_contributor_path(ledger.contributor, ledger: ledger.id), + class: "button" + end end else + f.semantic_errors + + panel "Request payment — #{ledger.enterprise.name}" do + para "Pick the bills you want to be paid for. Once submitted, the request lands on the financial controller's desk to process via Deel or QBO Bill Pay." + end + + f.input :ledger_id, as: :hidden, input_html: { value: ledger.id } + panel "Bills ready to request" do table_for candidates do column do |row| @@ -248,15 +257,15 @@ def scoped_collection end end end - end - f.inputs "Notes" do - f.input :notes, as: :text, input_html: { rows: 3 }, label: false, hint: "Optional. Anything the financial controller should know." - end + f.inputs "Notes" do + f.input :notes, as: :text, input_html: { rows: 3 }, label: false, hint: "Optional. Anything the financial controller should know." + end - f.actions do - f.action :submit, label: "Submit Withdrawal Request" - f.cancel_link(admin_contributor_path(ledger.contributor)) + f.actions do + f.action :submit, label: "Submit Withdrawal Request" + f.cancel_link(admin_contributor_path(ledger.contributor)) + end end end end From 161c1671b77775317cb1bbaf726550c0d4d5dea7 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 15:30:20 -0400 Subject: [PATCH 09/67] Share one panel between the empty-state and missing-vendor screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both screens now render through the same _message_panel partial: - dashboard-modules / module-header / module-body chrome - consistent

spacing - one shared "Back to ledger ↗" link styled with class="button" The empty-state branch in the form block stops using AA's default `panel` Arbre helper (different chrome) and renders the partial instead, so the two states are visually indistinguishable. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 23 +++++----- .../_message_panel.html.erb | 21 +++++++++ .../missing_qbo_vendor.html.erb | 45 +++++++------------ 3 files changed, 47 insertions(+), 42 deletions(-) create mode 100644 app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index cdc34c15..86438c63 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -220,18 +220,17 @@ def scoped_collection .reverse if candidates.empty? - # No bills available — render the empty-state panel only. No Notes - # input, no submit button (there's nothing to submit), no Deel/QBO - # column. Just an explanation and a way back to the ledger. - panel "Nothing to request right now" do - para "No bills on your #{ledger.enterprise.name} ledger are ready to request payment for yet." - para "Anything that's not yet payable (e.g. waiting on cycle approval), already paid in QuickBooks, or already included in another open request won't show up here. Once a new bill becomes payable, come back and submit a request." - div(style: "margin-top: 16px;") do - a "Back to ledger ↗", - href: admin_contributor_path(ledger.contributor, ledger: ledger.id), - class: "button" - end - end + # No bills available — render the shared message panel (same chrome + # as the missing-QBO-vendor screen) instead of the AA panel default. + # No Notes input, no submit button — there's nothing to submit. + render partial: "admin/ledger_withdrawal_requests/message_panel", locals: { + title: "📭 Nothing to request right now", + paragraphs: [ + "No bills on your #{ledger.enterprise.name} ledger are ready to request payment for yet.", + "Anything that's not yet payable (e.g. waiting on cycle approval), already paid in QuickBooks, or already included in another open request won't show up here. Once a new bill becomes payable, come back and submit a request.", + ], + back_path: admin_contributor_path(ledger.contributor, ledger: ledger.id), + } else f.semantic_errors diff --git a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb new file mode 100644 index 00000000..105eab89 --- /dev/null +++ b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb @@ -0,0 +1,21 @@ +<%# Shared panel for empty/error states on the withdrawal request flow. + Locals: + title — string shown in the module header + paragraphs — Array of strings, each rendered as its own

+ back_path — URL the "Back to ledger ↗" button points at +%> +

+
+
+

<%= title %>

+
+
+ <% paragraphs.each do |p| %> +

<%= p %>

+ <% end %> +

+ <%= link_to "Back to ledger ↗", back_path, class: "button" %> +

+
+
+
diff --git a/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb b/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb index 1c80ac79..a0305b4c 100644 --- a/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb @@ -1,30 +1,15 @@ -
-
-
-

🔌 This ledger isn't connected to QuickBooks yet

-
-
-

- Your <%= @missing_vendor_ledger.enterprise.name %> ledger - doesn't have a QBO vendor record attached to your contributor account, so - we can't send a withdrawal request to QuickBooks. -

-

- Please ask the Stacks admin team to associate a QBO vendor record to your - Stacks contributor account on <%= @missing_vendor_ledger.enterprise.name %>. - Once that's set up, this page will let you submit a request. -

- <% if @missing_vendor_qbo_account.nil? %> -

- Heads up: this enterprise isn't connected to a QuickBooks account - at all yet. The admin team will need to set that up first. -

- <% end %> -

- <%= link_to "Back to ledger ↗", - admin_contributor_path(@missing_vendor_ledger.contributor, ledger: @missing_vendor_ledger.id), - class: "button" %> -

-
-
-
+<% + enterprise_name = @missing_vendor_ledger.enterprise.name + paragraphs = [ + "Your #{ERB::Util.html_escape(enterprise_name)} ledger doesn't have a QBO vendor record attached to your contributor account, so we can't send a withdrawal request to QuickBooks.".html_safe, + "Please ask the Stacks admin team to associate a QBO vendor record to your Stacks contributor account on #{ERB::Util.html_escape(enterprise_name)}. Once that's set up, this page will let you submit a request.".html_safe, + ] + if @missing_vendor_qbo_account.nil? + paragraphs << "Heads up: this enterprise isn't connected to a QuickBooks account at all yet. The admin team will need to set that up first.".html_safe + end +%> +<%= render partial: "message_panel", locals: { + title: "🔌 This ledger isn't connected to QuickBooks yet", + paragraphs: paragraphs, + back_path: admin_contributor_path(@missing_vendor_ledger.contributor, ledger: @missing_vendor_ledger.id), +} %> From 6b555422e4870a6f758cdaa81d3a838264534dce Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 15:32:51 -0400 Subject: [PATCH 10/67] Style the shared back-to-ledger button as a blue pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit class="button" picks up AA's default dark-grey pill, which doesn't match the other ledger-page actions (Edit Contributor / Submit Reimbursement / New Adjustment) — those are blue pills only because the title-bar action_items SCSS rule scopes the style. Inline the same look on the partial's button so both screens read as primary actions. Co-Authored-By: Claude Opus 4.7 --- .../admin/ledger_withdrawal_requests/_message_panel.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb index 105eab89..48abef3f 100644 --- a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb @@ -14,7 +14,8 @@

<%= p %>

<% end %>

- <%= link_to "Back to ledger ↗", back_path, class: "button" %> + <%= link_to "Back to ledger ↗", back_path, + style: "display: inline-block; background: #1F78FF; color: white; border-radius: 999px; padding: 14px 30px; font-size: 18px; text-decoration: none;" %>

From 2d8ce59171bf8e3f5d710a532dda95b6c750446c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 15:37:11 -0400 Subject: [PATCH 11/67] Match the AA submit-button pill on the shared back-to-ledger link The dark pill `a.button` styling is scoped to `form a.button` in active_admin.scss, so a bare `class="button"` outside a form renders as nothing-styled. Mirror the form-submit look inline (background #414141, white text, 999px radius, 14/30 padding) so the back button on the empty-state and missing-vendor screens reads as primary. Co-Authored-By: Claude Opus 4.7 --- .../admin/ledger_withdrawal_requests/_message_panel.html.erb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb index 48abef3f..8da36816 100644 --- a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb @@ -14,8 +14,11 @@

<%= p %>

<% end %>

+ <%# Mirrors the AA form-submit pill (background #414141, white text, + 999px border-radius, 14px/30px padding) since `class="button"` is + only styled when inside a

. %> <%= link_to "Back to ledger ↗", back_path, - style: "display: inline-block; background: #1F78FF; color: white; border-radius: 999px; padding: 14px 30px; font-size: 18px; text-decoration: none;" %> + style: "display: inline-block; background: #414141; color: white; border-radius: 999px; padding: 14px 30px; font-size: 18px; font-weight: 700; text-decoration: none; border: none;" %>

From b80505c4d560f416d486f0dc30d3106dfd7cb079 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 15:38:30 -0400 Subject: [PATCH 12/67] Wrap the back-to-ledger link in a bare instead of inlining styles The dark pill look comes from `form a.button` (color/padding) plus `form input[type=submit], form input[type=button], form button` (the border-radius/font-weight). Both are scoped to a form ancestor. Wrap the link in a bare so both rules apply for free and we don't have to keep the inline style copy in sync with the SCSS. Co-Authored-By: Claude Opus 4.7 --- .../_message_panel.html.erb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb index 8da36816..59484c20 100644 --- a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb @@ -13,13 +13,12 @@ <% paragraphs.each do |p| %>

<%= p %>

<% end %> -

- <%# Mirrors the AA form-submit pill (background #414141, white text, - 999px border-radius, 14px/30px padding) since `class="button"` is - only styled when inside a . %> - <%= link_to "Back to ledger ↗", back_path, - style: "display: inline-block; background: #414141; color: white; border-radius: 999px; padding: 14px 30px; font-size: 18px; font-weight: 700; text-decoration: none; border: none;" %> -

+ <%# `a.button` is styled by AA's `form a.button` rule, so wrap the + link in a bare form so that scope applies without re-implementing + the pill style. %> + + <%= link_to "Back to ledger ↗", back_path, class: "button" %> + From fe8b02f7a638a54872c838f4735137b00ecd4f37 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 15:48:10 -0400 Subject: [PATCH 13/67] Restyle AA .panel chrome on LedgerWithdrawalRequest pages Hook into body.admin_ledger_withdrawal_requests and re-skin .panel + .panel > h3 + .panel > .panel_contents to match the .dashboard-module look used elsewhere in Stacks: white card, 0 1px 20px shadow, 8px rounded corners, dashed-border header with bold grey title. Scope is tight so we don't redecorate panels on every admin resource. Also covers the Notes fieldset.inputs in the same namespace so the form block reads as one consistent surface. Co-Authored-By: Claude Opus 4.7 --- app/assets/stylesheets/active_admin.scss | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/app/assets/stylesheets/active_admin.scss b/app/assets/stylesheets/active_admin.scss index 6cdff0a4..fb575c3a 100644 --- a/app/assets/stylesheets/active_admin.scss +++ b/app/assets/stylesheets/active_admin.scss @@ -1166,3 +1166,55 @@ body.logged_out #login a { background-image: none !important; } +// Match the Active Admin .panel chrome on the LedgerWithdrawalRequest +// forms to the .dashboard-module look used elsewhere in Stacks (white +// rounded card, dashed header divider, bold grey title). Scoped to the +// resource so we don't accidentally re-skin panels everywhere. +body.admin_ledger_withdrawal_requests { + .panel { + background: white; + box-shadow: 0 1px 20px #b7b7b7; + border-radius: 8px; + margin-bottom: 20px; + overflow: hidden; + + > h3 { + display: block; + padding: 20px; + margin: 0; + font-size: 18px; + font-weight: 700; + color: $color-grey; + border-bottom: 1px dashed $color-light-grey; + background: none; + } + + > .panel_contents { + padding: 20px; + margin-top: 0; + } + } + + // Formtastic fieldset for the Notes block should match the same chrome. + form fieldset.inputs { + background: white; + box-shadow: 0 1px 20px #b7b7b7; + border-radius: 8px; + margin-bottom: 20px; + padding: 0; + + > legend > span { + display: block; + padding: 20px; + font-size: 18px; + font-weight: 700; + color: $color-grey; + border-bottom: 1px dashed $color-light-grey; + } + + > ol { + padding: 20px; + } + } +} + From d0da301b98c3ff234eb26e944bb52b3a9733c4b9 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 15:54:49 -0400 Subject: [PATCH 14/67] Override AA section chrome harder on LedgerWithdrawalRequest pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier pass set new backgrounds on .panel / .panel > h3 but didn't squash the existing AA chrome — `secondary-gradient`, the 1px #cdcdcd border, the inset box-shadow, and the text-shadow all came through and reintroduced the striped header look. Add !important overrides on background / background-image / border / box-shadow / text-shadow for both the panel and h3, replicate the same chrome on fieldset.inputs + legend > span so the Notes block matches, and re-style the table inside .panel_contents so it reads like our index_table — uppercase grey column headers, 1px row borders, subtle hover background. Co-Authored-By: Claude Opus 4.7 --- app/assets/stylesheets/active_admin.scss | 102 +++++++++++++++++++---- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/app/assets/stylesheets/active_admin.scss b/app/assets/stylesheets/active_admin.scss index fb575c3a..c8f308a1 100644 --- a/app/assets/stylesheets/active_admin.scss +++ b/app/assets/stylesheets/active_admin.scss @@ -1166,14 +1166,19 @@ body.logged_out #login a { background-image: none !important; } -// Match the Active Admin .panel chrome on the LedgerWithdrawalRequest -// forms to the .dashboard-module look used elsewhere in Stacks (white -// rounded card, dashed header divider, bold grey title). Scoped to the -// resource so we don't accidentally re-skin panels everywhere. +// Match the Active Admin .panel + form chrome on LedgerWithdrawalRequest +// pages to the .dashboard-module look used elsewhere in Stacks (white +// rounded card, dashed header divider, bold grey title). The AA section +// mixin layers a linear-gradient + border + inset shadow on h3/.panel +// that we need to override aggressively. Scoped to the resource so we +// don't accidentally re-skin panels everywhere. body.admin_ledger_withdrawal_requests { + // --- Panel chrome -------------------------------------------------- .panel { - background: white; + background: white !important; + background-image: none !important; box-shadow: 0 1px 20px #b7b7b7; + border: none; border-radius: 8px; margin-bottom: 20px; overflow: hidden; @@ -1185,8 +1190,14 @@ body.admin_ledger_withdrawal_requests { font-size: 18px; font-weight: 700; color: $color-grey; - border-bottom: 1px dashed $color-light-grey; - background: none; + background: white !important; + background-image: none !important; + border: none !important; + border-bottom: 1px dashed $color-light-grey !important; + box-shadow: none !important; + text-shadow: none !important; + text-transform: none; + letter-spacing: normal; } > .panel_contents { @@ -1195,25 +1206,84 @@ body.admin_ledger_withdrawal_requests { } } - // Formtastic fieldset for the Notes block should match the same chrome. + // --- Tables inside panels read like our index_table elsewhere ------ + .panel .panel_contents table { + width: 100%; + margin: 0; + border-collapse: collapse; + + th { + background: none; + color: $color-grey; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid #e8e8e8; + text-shadow: none; + } + + td { + padding: 14px 16px; + border-bottom: 1px solid #f0f0f0; + vertical-align: middle; + } + + tr:hover td { + background: rgba(31, 120, 255, 0.04); + } + } + + // --- Notes block (formtastic fieldset.inputs) ---------------------- form fieldset.inputs { - background: white; + background: white !important; + background-image: none !important; box-shadow: 0 1px 20px #b7b7b7; + border: none; border-radius: 8px; margin-bottom: 20px; padding: 0; - > legend > span { - display: block; - padding: 20px; - font-size: 18px; - font-weight: 700; - color: $color-grey; - border-bottom: 1px dashed $color-light-grey; + > legend { + width: 100%; + margin: 0; + padding: 0; + background: none !important; + background-image: none !important; + border: none !important; + box-shadow: none !important; + + > span { + display: block; + width: 100%; + padding: 20px; + font-size: 18px; + font-weight: 700; + color: $color-grey; + background: white !important; + background-image: none !important; + border: none !important; + border-bottom: 1px dashed $color-light-grey !important; + box-shadow: none !important; + text-shadow: none !important; + text-transform: none; + letter-spacing: normal; + } } > ol { padding: 20px; + margin: 0; + background: none; + + > li { + padding: 0; + background: none; + border: none; + margin-bottom: 0; + } } } } From a6b4a7ac3b593ae7d5faa792cd15712fbb2c832f Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 16:04:20 -0400 Subject: [PATCH 15/67] Render withdrawal form panels with the ledger's dashboard-modules markup The previous SCSS overrides tried to re-skin AA's .panel + fieldset chrome and kept losing to the section mixin (gradient header, inset shadows, border). Switch the form to render the same dashboard-modules / module-header / module-body / index_table markup the Contributor ledger view uses. The intro, bills selection, and notes blocks each live in their own partial so the form block is short and the chrome matches one-for-one without overrides. Drop the SCSS override block entirely. The notes textarea is a raw text_area_tag inside the dashboard-module shell (the f.inputs fieldset.inputs carried its own chrome we had to fight). Its name still maps onto ledger_withdrawal_request[notes] because we're rendered inside the AA form_for block. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 29 +---- app/assets/stylesheets/active_admin.scss | 121 ------------------ .../_bills_panel.html.erb | 39 ++++++ .../_intro_panel.html.erb | 13 ++ .../_notes_panel.html.erb | 20 +++ 5 files changed, 79 insertions(+), 143 deletions(-) create mode 100644 app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb create mode 100644 app/views/admin/ledger_withdrawal_requests/_intro_panel.html.erb create mode 100644 app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index 86438c63..8f1f7a5a 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -234,32 +234,17 @@ def scoped_collection else f.semantic_errors - panel "Request payment — #{ledger.enterprise.name}" do - para "Pick the bills you want to be paid for. Once submitted, the request lands on the financial controller's desk to process via Deel or QBO Bill Pay." - end + # Reach for the same dashboard-modules / module-header / module-body + # / index_table HTML the rest of Stacks uses (see the Contributor + # ledger view) so the chrome here matches without us trying to skin + # AA's .panel and fieldset chrome from the outside. + render partial: "admin/ledger_withdrawal_requests/intro_panel", locals: { ledger: ledger } f.input :ledger_id, as: :hidden, input_html: { value: ledger.id } - panel "Bills ready to request" do - table_for candidates do - column do |row| - key = "#{row.qbo_account_id}:#{row.qbo_bill_id}" - check_box_tag "ledger_withdrawal_request[selected_bill_keys][]", key, false - end - column("Date") { |row| row.effective_on } - column("Type") { |row| row.description } - column("Amount") { |row| number_to_currency(row.amount) } - column("QBO") do |row| - if row.qbo_bill.present? - link_to "View ↗", row.qbo_bill.qbo_url, target: "_blank", rel: "noopener" - end - end - end - end + render partial: "admin/ledger_withdrawal_requests/bills_panel", locals: { candidates: candidates } - f.inputs "Notes" do - f.input :notes, as: :text, input_html: { rows: 3 }, label: false, hint: "Optional. Anything the financial controller should know." - end + render partial: "admin/ledger_withdrawal_requests/notes_panel", locals: { f: f } f.actions do f.action :submit, label: "Submit Withdrawal Request" diff --git a/app/assets/stylesheets/active_admin.scss b/app/assets/stylesheets/active_admin.scss index c8f308a1..38180272 100644 --- a/app/assets/stylesheets/active_admin.scss +++ b/app/assets/stylesheets/active_admin.scss @@ -1166,125 +1166,4 @@ body.logged_out #login a { background-image: none !important; } -// Match the Active Admin .panel + form chrome on LedgerWithdrawalRequest -// pages to the .dashboard-module look used elsewhere in Stacks (white -// rounded card, dashed header divider, bold grey title). The AA section -// mixin layers a linear-gradient + border + inset shadow on h3/.panel -// that we need to override aggressively. Scoped to the resource so we -// don't accidentally re-skin panels everywhere. -body.admin_ledger_withdrawal_requests { - // --- Panel chrome -------------------------------------------------- - .panel { - background: white !important; - background-image: none !important; - box-shadow: 0 1px 20px #b7b7b7; - border: none; - border-radius: 8px; - margin-bottom: 20px; - overflow: hidden; - - > h3 { - display: block; - padding: 20px; - margin: 0; - font-size: 18px; - font-weight: 700; - color: $color-grey; - background: white !important; - background-image: none !important; - border: none !important; - border-bottom: 1px dashed $color-light-grey !important; - box-shadow: none !important; - text-shadow: none !important; - text-transform: none; - letter-spacing: normal; - } - - > .panel_contents { - padding: 20px; - margin-top: 0; - } - } - - // --- Tables inside panels read like our index_table elsewhere ------ - .panel .panel_contents table { - width: 100%; - margin: 0; - border-collapse: collapse; - - th { - background: none; - color: $color-grey; - font-size: 13px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - text-align: left; - padding: 12px 16px; - border-bottom: 1px solid #e8e8e8; - text-shadow: none; - } - - td { - padding: 14px 16px; - border-bottom: 1px solid #f0f0f0; - vertical-align: middle; - } - - tr:hover td { - background: rgba(31, 120, 255, 0.04); - } - } - - // --- Notes block (formtastic fieldset.inputs) ---------------------- - form fieldset.inputs { - background: white !important; - background-image: none !important; - box-shadow: 0 1px 20px #b7b7b7; - border: none; - border-radius: 8px; - margin-bottom: 20px; - padding: 0; - - > legend { - width: 100%; - margin: 0; - padding: 0; - background: none !important; - background-image: none !important; - border: none !important; - box-shadow: none !important; - - > span { - display: block; - width: 100%; - padding: 20px; - font-size: 18px; - font-weight: 700; - color: $color-grey; - background: white !important; - background-image: none !important; - border: none !important; - border-bottom: 1px dashed $color-light-grey !important; - box-shadow: none !important; - text-shadow: none !important; - text-transform: none; - letter-spacing: normal; - } - } - - > ol { - padding: 20px; - margin: 0; - background: none; - - > li { - padding: 0; - background: none; - border: none; - margin-bottom: 0; - } - } - } -} diff --git a/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb new file mode 100644 index 00000000..8fa8f731 --- /dev/null +++ b/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb @@ -0,0 +1,39 @@ +<%# Bills selection table. Mirrors the structure used by + contributors/_show.html.erb so it picks up the existing index_table / + .even / .odd styling for free. %> +
+
+
+

Bills ready to request

+
+
+ + + + + + + + + + + + <% candidates.each_with_index do |row, idx| %> + <% key = "#{row.qbo_account_id}:#{row.qbo_bill_id}" %> + "> + + + + + + + <% end %> + +
DateTypeAmountQBO
<%= check_box_tag "ledger_withdrawal_request[selected_bill_keys][]", key, false %><%= row.effective_on %><%= row.description %><%= number_to_currency(row.amount) %> + <% if row.qbo_bill.present? %> + <%= link_to "View ↗", row.qbo_bill.qbo_url, target: "_blank", rel: "noopener" %> + <% end %> +
+
+
+
diff --git a/app/views/admin/ledger_withdrawal_requests/_intro_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_intro_panel.html.erb new file mode 100644 index 00000000..907ae599 --- /dev/null +++ b/app/views/admin/ledger_withdrawal_requests/_intro_panel.html.erb @@ -0,0 +1,13 @@ +<%# Header explanation card. Uses the same dashboard-modules / module-header + / module-body markup the Contributor ledger view uses, so the chrome + matches automatically without restyling AA's .panel. %> +
+
+
+

Request payment — <%= ledger.enterprise.name %>

+
+
+

Pick the bills you want to be paid for. Once submitted, the request lands on the financial controller's desk to process via Deel or QBO Bill Pay.

+
+
+
diff --git a/app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb new file mode 100644 index 00000000..c70f3b32 --- /dev/null +++ b/app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb @@ -0,0 +1,20 @@ +<%# Notes block. Doesn't use formtastic's fieldset.inputs (which carries + its own chrome we'd have to fight) — just the same dashboard-module + shell with a raw textarea inside. The textarea's name maps onto the + standard ledger_withdrawal_request[notes] param because we're inside + the AA form_for block. %> +
+
+
+

Notes

+
+
+

+ Optional. Anything the financial controller should know. +

+ <%= text_area_tag "ledger_withdrawal_request[notes]", nil, + rows: 4, + style: "width: 100%; box-sizing: border-box;" %> +
+
+
From 4371c56d0510b52ed55f7bcf73a23c14c7752108 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 16:07:22 -0400 Subject: [PATCH 16/67] Pre-check rows, add bulk-toggle, live total on submit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default state on the selection screen is "everything checked" — most contributors request all their available bills at once. The header column now holds a "select all" checkbox that toggles every row in bulk; the header checkbox itself tracks the per-row state (drops off when any row gets unchecked, comes back when all are checked). A short inline script recomputes the running total whenever a checkbox toggles and rewrites the form's submit button label to "Submit Withdrawal Request for $X,XXX.XX". When nothing is selected, the label flips to "Select bills to request" and the button disables itself. The initial server-rendered label uses the same total math so no-JS still reads sensibly on first paint. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 7 +- .../_bills_panel.html.erb | 72 ++++++++++++++++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index 8f1f7a5a..1628b530 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -246,8 +246,13 @@ def scoped_collection render partial: "admin/ledger_withdrawal_requests/notes_panel", locals: { f: f } + # Initial label reflects "everything selected" (the default state on + # first render). JS in _bills_panel keeps it in sync as the user + # toggles rows. + initial_total = candidates.sum(&:amount) f.actions do - f.action :submit, label: "Submit Withdrawal Request" + f.action :submit, + label: "Submit Withdrawal Request for #{number_to_currency(initial_total)}" f.cancel_link(admin_contributor_path(ledger.contributor)) end end diff --git a/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb index 8fa8f731..0bd6945e 100644 --- a/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb @@ -1,6 +1,12 @@ <%# Bills selection table. Mirrors the structure used by contributors/_show.html.erb so it picks up the existing index_table / - .even / .odd styling for free. %> + .even / .odd styling for free. + + Rows are pre-checked by default — most contributors want to request + everything they have available — and a header checkbox toggles them in + bulk. JS below recomputes the running total whenever a row toggles and + rewrites the submit button label so the contributor knows what they're + about to request before they click. %>
@@ -10,7 +16,9 @@ - + @@ -21,7 +29,9 @@ <% candidates.each_with_index do |row, idx| %> <% key = "#{row.qbo_account_id}:#{row.qbo_bill_id}" %> "> - + @@ -37,3 +47,59 @@ + + From 05eba1d4d7a63c28b3400a56b29210ba89d3530a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 16:14:37 -0400 Subject: [PATCH 17/67] Defer bills-panel JS init until DOMContentLoaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f.actions emits the submit button AFTER this partial in the render order, so at script-parse time form.querySelector('input[type=submit]') was null and the init guard bailed before wiring any listeners. Wrap the wiring in an init() and run it after DOMContentLoaded (or immediately if the doc is already loaded — covers bfcache and dev reloads). formatCurrency lives at the IIFE scope so init() can call it. Co-Authored-By: Claude Opus 4.7 --- .../_bills_panel.html.erb | 93 ++++++++++--------- 1 file changed, 51 insertions(+), 42 deletions(-) diff --git a/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb index 0bd6945e..d4255fba 100644 --- a/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb @@ -50,56 +50,65 @@ From 2e1e65d84403bfab709e19641f8dee5cdb0481ed Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 16:21:18 -0400 Subject: [PATCH 18/67] Make Deel withdrawals admin-only via the LedgerWithdrawalRequest flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the contributor-facing "New Deel Withdrawal" action item from the Contributor show page. New Deel withdrawals are now created only by admins, only from the LedgerWithdrawalRequest show page's Process via Deel form: - The form now offers an editable Amount field (defaults to the request total; admin can override before submitting). - Description defaults to an auto-generated itemized statement of every Bill in the request — "Stacks withdrawal request #N — #1234 ($X.XX, 2026-04-01); #1235 ($Y.YY, 2026-05-01)" — and remains editable so the admin can tweak before it ships to Deel. - ProcessViaDeel service accepts an optional `amount:` argument that overrides request.total_amount when present. - DeelInvoiceAdjustments admin now restricts :new / :create to admins with a clear redirect ("Submit a Ledger Withdrawal Request instead") so a contributor can't reach the legacy form via URL. :index / :show remain available for auditing existing rows. Co-Authored-By: Claude Opus 4.7 --- app/admin/contributors.rb | 17 ++++------- app/admin/deel_invoice_adjustments.rb | 11 +++++++ app/admin/ledger_withdrawal_requests.rb | 1 + .../process_via_deel.rb | 5 ++-- .../ledger_withdrawal_requests/_show.html.erb | 30 ++++++++++++++----- 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index ea88caab..b6cf4e5e 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -48,18 +48,11 @@ 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 - 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) - else - link_to "New Deel Withdrawal", "#", - onclick: "alert(#{LEDGER_REQUIRED_ALERT.to_json}); return false;" - end - end + # "New Deel Withdrawal" was retired: contributors now submit a Ledger + # Withdrawal Request and admins process it via Deel from that request's + # show page (or via QBO Bill Pay). The legacy + # admin/contributors/:id/deel_invoice_adjustments routes remain so + # historical rows are viewable, but no entry point is exposed here. action_item :new_contributor_adjustment, only: :show, if: proc { current_admin_user.is_admin? } do selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger]) diff --git a/app/admin/deel_invoice_adjustments.rb b/app/admin/deel_invoice_adjustments.rb index 84b38761..8c5bbb52 100644 --- a/app/admin/deel_invoice_adjustments.rb +++ b/app/admin/deel_invoice_adjustments.rb @@ -27,6 +27,17 @@ def manual_deel_invoice_submission_allowed?(contributor) end def verify_deel_invoice_access! + # :new / :create were retired in favor of the LedgerWithdrawalRequest + # flow — only admins can still reach them (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 now. Submit a Ledger Withdrawal Request instead." + 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/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index 1628b530..94f1d2eb 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -44,6 +44,7 @@ processed_by: current_admin_user, contract_id: params.require(:contract_id), description: params[:description].to_s, + amount: params[:amount].presence, date_submitted: params[:date_submitted].presence || Date.current, ) redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Processed via Deel." diff --git a/app/services/ledger_withdrawal_requests/process_via_deel.rb b/app/services/ledger_withdrawal_requests/process_via_deel.rb index df37c54a..55e8b58b 100644 --- a/app/services/ledger_withdrawal_requests/process_via_deel.rb +++ b/app/services/ledger_withdrawal_requests/process_via_deel.rb @@ -14,11 +14,12 @@ def self.call(**kwargs) new(**kwargs).call end - def initialize(request:, processed_by:, contract_id:, description:, date_submitted: Date.current) + def initialize(request:, processed_by:, contract_id:, description:, amount: nil, date_submitted: Date.current) @request = request @processed_by = processed_by @contract_id = contract_id.to_s @description = description.to_s + @amount = amount.presence # nil → fall back to request.total_amount @date_submitted = date_submitted end @@ -29,7 +30,7 @@ def call contributor: @request.contributor, ledger: @request.ledger, contract_id: @contract_id, - amount: @request.total_amount, + amount: @amount || @request.total_amount, description: @description.presence || "Stacks withdrawal request ##{@request.id}", date_submitted: @date_submitted, skip_balance_validation: @processed_by&.is_admin? || false, diff --git a/app/views/admin/ledger_withdrawal_requests/_show.html.erb b/app/views/admin/ledger_withdrawal_requests/_show.html.erb index f647d513..c55c691d 100644 --- a/app/views/admin/ledger_withdrawal_requests/_show.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_show.html.erb @@ -39,34 +39,48 @@ <% if resource.pending? && current_admin_user.is_admin? %> <% - # Filter Deel contracts to the request's enterprise legal entity (if set) - # using the same filter the existing per-row Deel withdrawal form uses. deel_contracts = DeelContract.sorted_for_balance_withdrawal_select( resource.contributor.deel_person_id, deel_legal_entity_id: resource.enterprise.deel_legal_entity_id, ) + + # Default description lists each Bill that's being settled by this + # request so the line that lands in Deel reads like an itemized + # statement. Admin can still rewrite it inline before submitting. + bill_lines = resource.bills.includes(:qbo_account).map do |b| + effective = b.qbo_bill && b.qbo_bill.data.is_a?(Hash) ? b.qbo_bill.data["txn_date"] : nil + "##{b.qbo_bill_id} (#{number_to_currency(b.amount_snapshot)}#{effective ? ", #{effective}" : ""})" + end + default_description = "Stacks withdrawal request ##{resource.id} — #{bill_lines.join("; ")}" %>

Process via Deel

-
+
<% if resource.contributor.deel_person_id.blank? || deel_contracts.empty? %>

No Deel contracts available for this contributor on <%= resource.enterprise.name %>. Use the QBO path instead.

<% else %> <%= form_with url: process_via_deel_admin_ledger_withdrawal_request_path(resource), method: :post, local: true do |f| %>

- +
<%= select_tag :contract_id, options_for_select(deel_contracts.map { |dc| [dc.display_name_for_deel_invoice_select, dc.deel_id] }) %>

- - <%= text_field_tag :description, "Stacks withdrawal request ##{resource.id}", size: 60 %> +
+ <%= number_field_tag :amount, resource.total_amount, step: 0.01, min: 0.01, + style: "width: 200px;" %> + Defaults to the request total. Override if Deel needs a different number. +

+

+
+ <%= text_area_tag :description, default_description, rows: 3, + style: "width: 100%; box-sizing: border-box;" %>

- +
<%= date_field_tag :date_submitted, Date.current %>

-

<%= submit_tag "Process via Deel — #{number_to_currency(resource.total_amount)}", class: "button", data: { confirm: "Submit a Deel invoice adjustment for #{number_to_currency(resource.total_amount)} and mark this request processed?" } %>

+

<%= submit_tag "Process via Deel", class: "button", data: { confirm: "Submit a Deel invoice adjustment and mark this request processed?" } %>

<% end %> <% end %>
From 44946b7f7eae9cb5b7d90667e89d7b9d8ac16e73 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 16:38:25 -0400 Subject: [PATCH 19/67] Add admin overpayment toggle + richer Deel description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the Process via Deel flow on the LedgerWithdrawalRequest show page: 1. ProcessViaDeel now takes an explicit allow_overpayment: parameter instead of auto-skipping the settled-balance check for any admin. Default false — a typo would have silently submitted a Deel withdrawal that exceeded the contributor's actual balance. The form exposes a checkbox the admin has to tick on purpose; the service also reasserts is_admin? before respecting the flag. 2. Default description for the Deel adjustment is now an itemized statement, one bill per line: Stacks withdrawal request #N — settling the following bills: - Contributor Payout — 2026-04-01 — $7,930.13 (QBO Bill #1234: https://qbo.intuit.com/app/bill?txnId=1234) - Pay Stub — 2026-05-15 — $514.80 (QBO Bill #1235: https://qbo.intuit.com/app/bill?txnId=1235) The host type ("Contributor Payout" / "Pay Stub" / etc.) comes from the new LedgerWithdrawalRequestBill#host_record helper which walks the SyncsAsQboBill host tables by qbo_bill_id. Description stays editable so the admin can tweak before submit. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 1 + app/models/ledger_withdrawal_request_bill.rb | 34 +++++++++++++++++++ .../process_via_deel.rb | 8 +++-- .../ledger_withdrawal_requests/_show.html.erb | 28 +++++++++++---- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index 94f1d2eb..fc41721d 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -46,6 +46,7 @@ description: params[:description].to_s, amount: params[:amount].presence, date_submitted: params[:date_submitted].presence || Date.current, + allow_overpayment: ActiveModel::Type::Boolean.new.cast(params[:allow_overpayment]), ) redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Processed via Deel." rescue LedgerWithdrawalRequests::ProcessViaDeel::Error => e diff --git a/app/models/ledger_withdrawal_request_bill.rb b/app/models/ledger_withdrawal_request_bill.rb index 115d4486..42393b2d 100644 --- a/app/models/ledger_withdrawal_request_bill.rb +++ b/app/models/ledger_withdrawal_request_bill.rb @@ -9,6 +9,18 @@ class LedgerWithdrawalRequestBill < ApplicationRecord message: "already attached to this request", } + # Host classes that emit Bills through SyncsAsQboBill. Used by + # #host_record to resolve which ledger item is being settled by this + # row — that's how the Process via Deel description lists things like + # "Contributor Payout" / "Pay Stub" instead of just bare QBO Bill ids. + HOST_CLASSES = [ + ContributorPayout, + ContributorAdjustment, + ProfitShare, + Trueup, + PayStub, + ].freeze + # Resolve the local QboBill mirror via the composite (qbo_account_id, qbo_id) # key — same pattern SyncsAsQboBill hosts use. Memoized per instance. def qbo_bill @@ -16,6 +28,28 @@ def qbo_bill @_qbo_bill = QboBill.find_by(qbo_account_id: qbo_account_id, qbo_id: qbo_bill_id) end + # Walks the SyncsAsQboBill host tables and returns the (ledger item) row + # that pushed this QBO Bill. There's no reverse FK we can rely on, so + # we look up by qbo_bill_id across each candidate class. Memoized. + def host_record + return @_host_record if defined?(@_host_record) + @_host_record = HOST_CLASSES.lazy.filter_map do |klass| + klass.find_by(qbo_bill_id: qbo_bill_id) + end.first + end + + # Human label for the host class, falling back to "QBO Bill" if we can't + # resolve which host emitted it (e.g. mirror existed at request time but + # the host row has since been hard-deleted). + def host_label + host_record ? host_record.class.name.titleize : "QBO Bill" + end + + def host_effective_on + return nil unless host_record.respond_to?(:effective_on_for_display) + host_record.effective_on_for_display + end + # "Paid in QBO" is the source of truth. Defer to the QboBill mirror's # status field, which the daily sync updates from QBO. def paid? diff --git a/app/services/ledger_withdrawal_requests/process_via_deel.rb b/app/services/ledger_withdrawal_requests/process_via_deel.rb index 55e8b58b..e9c0223e 100644 --- a/app/services/ledger_withdrawal_requests/process_via_deel.rb +++ b/app/services/ledger_withdrawal_requests/process_via_deel.rb @@ -14,13 +14,17 @@ def self.call(**kwargs) new(**kwargs).call end - def initialize(request:, processed_by:, contract_id:, description:, amount: nil, date_submitted: Date.current) + def initialize(request:, processed_by:, contract_id:, description:, amount: nil, date_submitted: Date.current, allow_overpayment: false) @request = request @processed_by = processed_by @contract_id = contract_id.to_s @description = description.to_s @amount = amount.presence # nil → fall back to request.total_amount @date_submitted = date_submitted + # Skipping the settled-balance cap is an admin-only override. Default + # false so a typo doesn't silently submit a withdrawal that exceeds + # the contributor's actual balance. + @allow_overpayment = allow_overpayment && @processed_by&.is_admin? end def call @@ -33,7 +37,7 @@ def call amount: @amount || @request.total_amount, description: @description.presence || "Stacks withdrawal request ##{@request.id}", date_submitted: @date_submitted, - skip_balance_validation: @processed_by&.is_admin? || false, + skip_balance_validation: @allow_overpayment, ) @request.update!( diff --git a/app/views/admin/ledger_withdrawal_requests/_show.html.erb b/app/views/admin/ledger_withdrawal_requests/_show.html.erb index c55c691d..a1717504 100644 --- a/app/views/admin/ledger_withdrawal_requests/_show.html.erb +++ b/app/views/admin/ledger_withdrawal_requests/_show.html.erb @@ -44,14 +44,22 @@ deel_legal_entity_id: resource.enterprise.deel_legal_entity_id, ) - # Default description lists each Bill that's being settled by this - # request so the line that lands in Deel reads like an itemized - # statement. Admin can still rewrite it inline before submitting. + # Default description is an itemized statement of every Bill this + # request is settling. One line per bill; each line names the source + # ledger item type (Contributor Payout / Pay Stub / etc.), the + # effective date when we can resolve it, the snapshot amount, and a + # deep link into QBO so the Deel recipient can verify. Admin can + # rewrite the whole thing inline before submit. bill_lines = resource.bills.includes(:qbo_account).map do |b| - effective = b.qbo_bill && b.qbo_bill.data.is_a?(Hash) ? b.qbo_bill.data["txn_date"] : nil - "##{b.qbo_bill_id} (#{number_to_currency(b.amount_snapshot)}#{effective ? ", #{effective}" : ""})" + parts = [b.host_label] + parts << b.host_effective_on.to_s if b.host_effective_on.present? + parts << number_to_currency(b.amount_snapshot) + qbo_url = b.qbo_bill&.qbo_url || "https://qbo.intuit.com/app/bill?&txnId=#{b.qbo_bill_id}" + "- #{parts.join(" — ")} (QBO Bill ##{b.qbo_bill_id}: #{qbo_url})" end - default_description = "Stacks withdrawal request ##{resource.id} — #{bill_lines.join("; ")}" + + default_description = +"Stacks withdrawal request ##{resource.id} — settling the following bills:\n\n" + default_description << bill_lines.join("\n") %>
@@ -80,6 +88,14 @@
<%= date_field_tag :date_submitted, Date.current %>

+

+ +
+ By default the Deel API call will fail if the amount exceeds the contributor's Stacks balance. Tick this only when you mean to advance a balance. +

<%= submit_tag "Process via Deel", class: "button", data: { confirm: "Submit a Deel invoice adjustment and mark this request processed?" } %>

<% end %> <% end %> From 1abb463692efd4c949f2664bf42433fc3221e777 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 16:40:44 -0400 Subject: [PATCH 20/67] Add Pending / Paid / Cancelled index scope tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pending is the default so admins land on the queue that actually needs attention. Paid (aliased from the .processed scope) and Cancelled stay one click away for audit / followup. The underlying model scopes were already there — just exposing them at the admin level. Co-Authored-By: Claude Opus 4.7 --- app/admin/ledger_withdrawal_requests.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb index fc41721d..1200d80c 100644 --- a/app/admin/ledger_withdrawal_requests.rb +++ b/app/admin/ledger_withdrawal_requests.rb @@ -3,6 +3,10 @@ config.filters = false actions :index, :new, :create, :show + scope :pending, default: true + scope "Paid", :processed + scope :cancelled + permit_params :ledger_id, :notes, bills_attributes: [:qbo_account_id, :qbo_bill_id, :amount_snapshot] action_item :process_via_qbo, only: :show, if: proc { resource.pending? && current_admin_user.is_admin? } do From 6df938d6a2a9f0e15004981ebf6169551870e82a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 16:44:50 -0400 Subject: [PATCH 21/67] Collapse the withdrawal-request task into a single queue indicator Previously the discovery emitted one task per pending row, so an admin with three pending requests saw three identical bullet points. Switch to a single aggregate task: - Subject is the oldest pending row (so the cache descriptor stays stable as the queue churns). - Display name reads "N pending ledger withdrawal request(s)", with N pulled live from LedgerWithdrawalRequest.pending.count at hydrate time so the count tracks even between cache rebuilds. - URL points to /admin/ledger_withdrawal_requests?scope=pending so clicking lands the admin on the queue itself. - humanized_type bumped to "You have pending ledger withdrawal requests". LedgerWithdrawalRequest now includes BustsTaskCache so any save / destroy busts the TaskBuilder cache and the next read rebuilds with the current queue state. Co-Authored-By: Claude Opus 4.7 --- app/models/ledger_withdrawal_request.rb | 5 +++ app/models/stacks_task.rb | 20 ++++++++++-- .../discoveries/ledger_withdrawal_requests.rb | 32 +++++++++++-------- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/app/models/ledger_withdrawal_request.rb b/app/models/ledger_withdrawal_request.rb index 697995cd..899a2867 100644 --- a/app/models/ledger_withdrawal_request.rb +++ b/app/models/ledger_withdrawal_request.rb @@ -1,4 +1,9 @@ class LedgerWithdrawalRequest < ApplicationRecord + # Any change to processed_at / cancelled_at flips this row's + # discovery-eligibility, so bust the TaskBuilder cache on save/destroy + # so the next admin page render rebuilds the queue. + include BustsTaskCache + PAID_VIA_DEEL = "deel".freeze PAID_VIA_QBO_BILL_PAY = "qbo_bill_pay".freeze PAID_VIA_MANUAL = "manual".freeze diff --git a/app/models/stacks_task.rb b/app/models/stacks_task.rb index 7faee539..7f4e9f71 100644 --- a/app/models/stacks_task.rb +++ b/app/models/stacks_task.rb @@ -48,7 +48,7 @@ class StacksTask missing_qbo_vendor_for_contributor: "Contributor needs a QBO vendor for this enterprise's ledger", # Withdrawal request issues - ledger_withdrawal_request_needs_processing: "Withdrawal request needs processing", + ledger_withdrawal_request_needs_processing: "You have pending ledger withdrawal requests", }.freeze # type — Symbol classifying the task (:project_capsule_incomplete, :survey, …) @@ -103,7 +103,16 @@ def subject_display_name when PayCycle then "#{subject.enterprise.name} — #{subject.starts_at.to_s(:long)} to #{subject.ends_at.to_s(:long)}" when Ledger then "#{subject.contributor.forecast_person&.email || "Contributor ##{subject.contributor_id}"} on #{subject.enterprise.name}" when LedgerWithdrawalRequest - "#{subject.contributor.forecast_person&.email} on #{subject.enterprise.name} — #{ActionController::Base.helpers.number_to_currency(subject.total_amount)} (#{subject.bills.size} bills)" + # The discovery surfaces ONE aggregate task that stands in for the + # whole pending queue, so prefer a count-based label over the + # specific subject row's details. Falls back to per-row info if + # this is ever wired as a per-row task. + if type == :ledger_withdrawal_request_needs_processing + count = LedgerWithdrawalRequest.pending.count + "#{count} pending ledger withdrawal request#{"s" if count != 1}" + else + "#{subject.contributor.forecast_person&.email} on #{subject.enterprise.name} — #{ActionController::Base.helpers.number_to_currency(subject.total_amount)} (#{subject.bills.size} bills)" + end else subject.try(:display_name).presence || subject.try(:name).presence || subject.to_s end @@ -126,7 +135,12 @@ def subject_url 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 LedgerWithdrawalRequest then helpers.admin_ledger_withdrawal_request_path(subject) + when LedgerWithdrawalRequest + if type == :ledger_withdrawal_request_needs_processing + helpers.admin_ledger_withdrawal_requests_path(scope: "pending") + else + helpers.admin_ledger_withdrawal_request_path(subject) + end else subject.try(:external_link) end end diff --git a/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb b/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb index a20310d5..467ef4a5 100644 --- a/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb +++ b/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb @@ -1,22 +1,28 @@ module Stacks class TaskBuilder module Discoveries - # Surfaces every pending LedgerWithdrawalRequest as a task for global - # Stacks admins (mirrors MissingQboVendors — the per-enterprise admins - # don't have the QBO Bill Pay UI surfaced to them, so this lands on - # the financial controller / super-admin cohort). + # Surfaces ONE aggregate task for global Stacks admins whenever + # there's at least one pending LedgerWithdrawalRequest in the + # queue — clicking it lands them on the pending scope of the + # index, not on a specific request. The subject is the oldest + # pending row so the cache descriptor is stable as long as any + # row remains pending; the display name + URL on StacksTask + # treat the request as an indicator for the whole queue. class LedgerWithdrawalRequests < Base def tasks - LedgerWithdrawalRequest + oldest_pending = LedgerWithdrawalRequest .pending - .includes(:ledger) - .map do |req| - task( - subject: req, - type: :ledger_withdrawal_request_needs_processing, - owners: @admin_fallback, - ) - end + .order(:requested_at) + .first + return [] if oldest_pending.nil? + + [ + task( + subject: oldest_pending, + type: :ledger_withdrawal_request_needs_processing, + owners: @admin_fallback, + ), + ] end end end From c43f7b11ecba0de78b5904cf5ae9203162bc033d Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 17:12:04 -0400 Subject: [PATCH 22/67] Splice withdrawal requests into the ledger view as informational milestones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a contributor submits a LedgerWithdrawalRequest we want to see that event in their ledger timeline, but we don't want it to move the balance — the underlying bills (ContributorPayouts, PayStubs, etc.) are still what actually changes balance + unsettled, both before and after the request is paid. A request is just the "contributor asked for these bills to be paid on this date" milestone. How it's wired: - LedgerWithdrawalRequest gets an effective_on_for_display so the generic timeline machinery (Ledger#items_grouped_by_month, Contributor#all_items_grouped_by_month) can place it in a month bucket like any other ledger item. - Ledger#all_items_with_deleted (display-only) and Contributor#all_items_grouped_by_month (display + balance walk feed) both now include withdrawal requests. They are intentionally NOT added to Ledger#visible_items, so balance / unsettled don't see them. - Contributor#new_deal_balance and Ledger#items_grouped_by_month's total_income are if/elsif chains keyed off is_a?(SomeKlass) with no else branch; LedgerWithdrawalRequest falls through harmlessly, so no further changes were needed to keep balance untouched. - The contributors/_show partial renders the row with a "voided" Type pill ("Withdrawal Request"), a Pending / Processed / Cancelled Status pill with bill-progress in the split slot, a muted total in the Amount column tagged "No balance impact", and a deep link to the request show page in the Details column. - _show's Details cell guarded against soft-deleted items with `li.deleted_at.present?` — LedgerWithdrawalRequest isn't paranoid and has no such column, so that became `li.try(:deleted_at)` to keep it safe across all types. Verified via runner: per-ledger and all-enterprises views both splice the request, and Ledger#balance + new_deal_balance return unchanged numbers with the withdrawal request present. Co-Authored-By: Claude Opus 4.7 --- app/models/contributor.rb | 30 +++++++++++++++++++++ app/models/ledger.rb | 3 +++ app/models/ledger_withdrawal_request.rb | 11 ++++++++ app/views/admin/contributors/_show.html.erb | 25 ++++++++++++++++- 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/app/models/contributor.rb b/app/models/contributor.rb index 9fb279b7..ff1537eb 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -123,6 +123,17 @@ def pay_stubs_with_deleted .where(ledgers: { contributor_id: id }).to_a end + # Withdrawal requests are not soft-deletable — `_with_deleted` is a naming + # convention here so the splice into all_items_grouped_by_month reads + # uniformly with the other ledger-item collections. + def ledger_withdrawal_requests_with_deleted + @_ledger_withdrawal_requests_with_deleted ||= + LedgerWithdrawalRequest + .joins(:ledger) + .includes(:bills, :cancelled_by) + .where(ledgers: { contributor_id: id }).to_a + end + private # Resolves the composite-key QboInvoice lookup that `payable?` does on @@ -187,6 +198,9 @@ def preload_for_ledger_view! @_pay_stubs_with_deleted = PayStub.with_deleted.joins(:ledger).where(ledgers: { contributor_id: id }) .includes(:ledger, :pay_cycle).to_a + @_ledger_withdrawal_requests_with_deleted = + LedgerWithdrawalRequest.joins(:ledger).where(ledgers: { contributor_id: id }) + .includes(:ledger, :bills, :cancelled_by).to_a # Short-circuit `item.contributor` (and any downstream delegate hop) to # this Contributor. All preloaded items belong to ledgers whose contributor @@ -199,6 +213,7 @@ def preload_for_ledger_view! @_profit_shares_with_deleted, @_deel_invoice_adjustments_with_deleted, @_pay_stubs_with_deleted, + @_ledger_withdrawal_requests_with_deleted, ].each do |items| items.each do |item| item.ledger.association(:contributor).target = self @@ -399,6 +414,7 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at preloaded_adjustments = contributor_adjustments_with_deleted preloaded_deel_invoice_adjustments = deel_invoice_adjustments_with_deleted preloaded_pay_stubs = pay_stubs_with_deleted + preloaded_withdrawal_requests = ledger_withdrawal_requests_with_deleted if override_ledger_ends_at.present? ledger_ends_at = override_ledger_ends_at @@ -409,6 +425,7 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at *preloaded_adjustments, *preloaded_deel_invoice_adjustments, *preloaded_pay_stubs, + *preloaded_withdrawal_requests, ].reduce(Date.today) do |acc, li| if li.is_a?(ContributorPayout) acc = li.invoice_tracker.invoice_pass.start_of_month if li.invoice_tracker.invoice_pass.start_of_month > acc @@ -426,6 +443,9 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at elsif li.is_a?(PayStub) d = li.effective_on_for_display acc = d if d > acc + elsif li.is_a?(LedgerWithdrawalRequest) + d = li.effective_on_for_display + acc = d if d && d > acc end acc end + 2.months @@ -489,6 +509,11 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at ps.effective_on_for_display <= period.ends_at end + withdrawal_requests_in_period = preloaded_withdrawal_requests.select do |wr| + d = wr.effective_on_for_display + d.present? && d >= period.starts_at && d <= period.ends_at + end + sorted = [ *contributor_payouts_in_period, @@ -498,6 +523,7 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at *adjustments_in_period, *deel_invoice_in_period, *pay_stubs_in_period, + *withdrawal_requests_in_period, ].sort do |a, b| date_a = nil if a.is_a?(Trueup) @@ -514,6 +540,8 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at date_a = a.date_submitted elsif a.is_a?(PayStub) date_a = a.effective_on_for_display + elsif a.is_a?(LedgerWithdrawalRequest) + date_a = a.effective_on_for_display end date_b = nil @@ -531,6 +559,8 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at date_b = b.date_submitted elsif b.is_a?(PayStub) date_b = b.effective_on_for_display + elsif b.is_a?(LedgerWithdrawalRequest) + date_b = b.effective_on_for_display end date_b <=> date_a diff --git a/app/models/ledger.rb b/app/models/ledger.rb index 5de56abe..253beab8 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -134,6 +134,8 @@ def visible_items end # Includes soft-deleted rows — used by items_grouped_by_month for display. + # LedgerWithdrawalRequests render as milestone rows here; they're left out + # of `visible_items` so they don't move balance/unsettled. def all_items_with_deleted [ ContributorPayout.with_deleted.includes(invoice_tracker: :invoice_pass).where(ledger_id: id).to_a, @@ -143,6 +145,7 @@ def all_items_with_deleted ProfitShare.with_deleted.includes(:periodic_report).where(ledger_id: id).to_a, DeelInvoiceAdjustment.with_deleted.where(ledger_id: id).to_a, PayStub.with_deleted.includes(:pay_cycle).where(ledger_id: id).to_a, + LedgerWithdrawalRequest.includes(:bills, :cancelled_by).where(ledger_id: id).to_a, ].flatten end end diff --git a/app/models/ledger_withdrawal_request.rb b/app/models/ledger_withdrawal_request.rb index 899a2867..a2b43b16 100644 --- a/app/models/ledger_withdrawal_request.rb +++ b/app/models/ledger_withdrawal_request.rb @@ -59,6 +59,17 @@ def all_bills_paid? bills.any? && bills.all?(&:paid?) end + # Lets Ledger#items_grouped_by_month / Contributor#all_items_grouped_by_month + # splice withdrawal requests into the timeline alongside regular ledger items + # without a custom code path — they're rendered as informational milestones + # ("contributor asked for these bills to be paid on this date") rather than + # balance-affecting rows. No payable?/signed_amount on purpose: the balance + # walks never branch on this class, so withdrawal requests stay invisible to + # balance / unsettled / total_income. + def effective_on_for_display + requested_at&.to_date + end + # Auto-process: when every Bill in this request is Paid in QBO, flip # processed_at without requiring a controller click. Called by the daily # QBO sync after the QboBill mirror updates. Idempotent. diff --git a/app/views/admin/contributors/_show.html.erb b/app/views/admin/contributors/_show.html.erb index 21ffeaab..21147114 100644 --- a/app/views/admin/contributors/_show.html.erb +++ b/app/views/admin/contributors/_show.html.erb @@ -94,11 +94,15 @@ <%= li.date_submitted.strftime("%B %d, %Y") %> <% elsif li.is_a?(PayStub) %> <%= li.pay_cycle.ends_at.strftime("%B %d, %Y") %> + <% elsif li.is_a?(LedgerWithdrawalRequest) %> + <%= li.requested_at.to_date.strftime("%B %d, %Y") %> <% end %>
From 06c5d0faf20c8b32eff52ba90d95a038a62cdc79 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 17:19:21 -0400 Subject: [PATCH 23/67] Drop the 'No balance impact' pill from the withdrawal-request row The muted italic on the amount already reads as informational; the extra pill landed as 'this number is somehow wrong / suspect' to the contributor's eye, which was the opposite of what we wanted. Co-Authored-By: Claude Opus 4.7 --- app/views/admin/contributors/_show.html.erb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/admin/contributors/_show.html.erb b/app/views/admin/contributors/_show.html.erb index 21147114..9b56f0a2 100644 --- a/app/views/admin/contributors/_show.html.erb +++ b/app/views/admin/contributors/_show.html.erb @@ -288,7 +288,6 @@ <% elsif li.is_a?(LedgerWithdrawalRequest) %> <%= number_to_currency(li.total_amount) %> - No balance impact <% end %> diff --git a/lib/stacks/task_builder.rb b/lib/stacks/task_builder.rb index 9ae5a596..9f720159 100644 --- a/lib/stacks/task_builder.rb +++ b/lib/stacks/task_builder.rb @@ -9,7 +9,6 @@ require_relative "task_builder/discoveries/surveys" require_relative "task_builder/discoveries/pay_cycles" require_relative "task_builder/discoveries/missing_qbo_vendors" -require_relative "task_builder/discoveries/ledger_withdrawal_requests" module Stacks # Single source of truth for "what needs attention right now" across the system. From 6b611391fbca3f375064c3668b704ef764902a70 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:50:41 -0400 Subject: [PATCH 52/67] Remove one-shot QBO-cutover audit scripts --- script/accountant_reconciliation_worklist.rb | 202 ----------------- script/audit_qbo_cutover_balance_drift.rb | 167 -------------- script/why_balance_goes_up.rb | 221 ------------------- 3 files changed, 590 deletions(-) delete mode 100644 script/accountant_reconciliation_worklist.rb delete mode 100644 script/audit_qbo_cutover_balance_drift.rb delete mode 100644 script/why_balance_goes_up.rb diff --git a/script/accountant_reconciliation_worklist.rb b/script/accountant_reconciliation_worklist.rb deleted file mode 100644 index d9fe7481..00000000 --- a/script/accountant_reconciliation_worklist.rb +++ /dev/null @@ -1,202 +0,0 @@ -# Accountant-facing reconciliation worklist for the proposed cutover. -# -# Model under audit: -# - Every negative ContributorAdjustment is deleted (they all represent -# payments already made off-platform; positive CAs survive because -# they're upward adjustments, not payment offsets) -# - DeelInvoiceAdjustment no longer deducts (audit trail only) -# - SyncsAsQboBill hosts drop out of balance when their QboBill mirror -# is Paid -# -# Ledgers whose contributor has NO QBO vendor mapping for the ledger's -# enterprise QBO account are excluded entirely — items on those ledgers -# can't sync as QBO Bills in the first place, so the cutover model has -# nothing to act on. Most of the "missing data on either side" noise in -# the previous report lived in exactly these ledgers (job@thedeskofjob, -# info@driesbos, etc.). -# -# After the accountant goes through every affected contributor's open -# QBO bills and marks the ones that have genuinely been paid, the -# Stacks balance should converge to the QBO truth. - -QBO_HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].freeze - -def eligible_ledger_ids_for(contributor) - contributor.ledgers.includes(:enterprise).filter_map do |l| - qa = l.enterprise.qbo_account - next nil if qa.nil? - vendor = contributor.qbo_vendor_for(qa) - next nil if vendor.nil? - l.id - end.to_set -end - -def current_balance_filtered(ledger_items, eligible_ids) - ledger_items[:all].reduce({ balance: 0, unsettled: 0 }) do |acc, li| - next acc unless li.respond_to?(:ledger_id) && eligible_ids.include?(li.ledger_id) - next acc if li.respond_to?(:deleted_at) && li.deleted_at.present? - - case li - when ContributorPayout - if li.payable? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end - when Reimbursement - if li.accepted? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end - when Trueup - acc[:balance] += li.amount - when ProfitShare - if li.payable? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end - when ContributorAdjustment - if li.payable? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end - when PayStub - if li.payable? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end - when DeelInvoiceAdjustment - acc[:balance] -= li.amount if li.deducts_balance? - end - acc - end -end - -def post_cutover_balance(ledger_items, eligible_ids) - ledger_items[:all].reduce({ balance: 0, unsettled: 0 }) do |acc, li| - next acc unless li.respond_to?(:ledger_id) && eligible_ids.include?(li.ledger_id) - next acc if li.respond_to?(:deleted_at) && li.deleted_at.present? - next acc if li.is_a?(DeelInvoiceAdjustment) - next acc if li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 - - case li - when ContributorPayout, ProfitShare, ContributorAdjustment, PayStub - if li.payable? - next acc if li.qbo_bill&.paid? - acc[:balance] += li.amount - else - acc[:unsettled] += li.amount - end - when Trueup - next acc if li.qbo_bill&.paid? - acc[:balance] += li.amount - when Reimbursement - if li.accepted? - acc[:balance] += li.amount - else - acc[:unsettled] += li.amount - end - end - acc - end -end - -# Open QBO bills traceable to a contributor via any host class. -# Walk every host row that has a qbo_bill_id set, route through the -# per-class qbo_account_for_bill helper (ContributorPayout / PayStub / -# ProfitShare / Trueup don't have a qbo_account_id column — only -# ContributorAdjustment does — so a raw column lookup misses them). -open_bills_by_contributor = Hash.new { |h, k| h[k] = [] } - -QBO_HOST_KLASSES.each do |klass| - klass.where.not(qbo_bill_id: nil).includes(ledger: :enterprise).find_each do |row| - qb = row.qbo_bill rescue nil - next if qb.nil? || qb.paid? - contributor = row.ledger&.contributor - next if contributor.nil? - # Only count this open bill if the contributor has a QBO vendor - # mapping for the ledger's enterprise — otherwise the bill couldn't - # have synced in the first place. - qa = row.ledger.enterprise.qbo_account - next if qa.nil? || contributor.qbo_vendor_for(qa).nil? - open_bills_by_contributor[contributor.id] << { - host_class: klass.name, - host_id: row.id, - qbo_url: qb.qbo_url, - balance: qb.total_amount.to_f, - } - end -end - -dia_contrib_ids = - DeelInvoiceAdjustment.joins(:ledger).where(deleted_at: nil).pluck("ledgers.contributor_id").uniq -neg_ca_contrib_ids = - ContributorAdjustment.where("amount < 0").joins(:ledger).pluck("ledgers.contributor_id").uniq -candidate_ids = (dia_contrib_ids + neg_ca_contrib_ids + open_bills_by_contributor.keys).uniq - -rows = [] - -skipped_no_vendor = 0 - -Contributor.unscoped.where(id: candidate_ids).find_each do |c| - next if c.forecast_person.nil? - eligible_ids = eligible_ledger_ids_for(c) - if eligible_ids.empty? - skipped_no_vendor += 1 - next - end - - c.preload_for_ledger_view! - items = c.all_items_grouped_by_month(false) - - current = current_balance_filtered(items, eligible_ids) - proposed = post_cutover_balance(items, eligible_ids) - - d_bal = (proposed[:balance] - current[:balance]).to_f.round(2) - open_bills = open_bills_by_contributor[c.id] || [] - sum_open = open_bills.sum { |b| b[:balance] }.round(2) - - next if d_bal.abs < 0.01 && open_bills.empty? - - rows << { - id: c.id, - email: c.forecast_person.email, - cur_bal: current[:balance].to_f.round(2), - new_bal: proposed[:balance].to_f.round(2), - d_bal: d_bal, - open_bills: open_bills, - sum_open: sum_open, - } -end - -puts "Skipped #{skipped_no_vendor} contributors with no QBO vendor mapping on any ledger" -puts - -total_d_bal = rows.sum { |r| r[:d_bal] }.round(2) -sum_all_open = rows.sum { |r| r[:sum_open] }.round(2) -up = rows.count { |r| r[:d_bal] > 0.01 } -down = rows.count { |r| r[:d_bal] < -0.01 } -flat = rows.count { |r| r[:d_bal].abs < 0.01 } - -puts "Contributors needing accountant review: #{rows.size}" -puts " with open QBO bills: #{rows.count { |r| r[:open_bills].any? }} (sum: $#{sum_all_open})" -puts " balance would go UP: #{up} (under-recorded payments in QBO)" -puts " balance would go DOWN: #{down} (over-marked Paid in QBO OR missing offset CA)" -puts " balance unchanged: #{flat} (only have open bills to review)" -puts " Σ Δbalance: #{total_d_bal}" -puts - -# Group by direction -[ - ["UP (most likely cohort — accountant marks open bills as Paid where applicable)", ->(r) { r[:d_bal] > 0.01 }], - ["DOWN (review — Stacks expected this person to be owed money but QBO shows Paid)", ->(r) { r[:d_bal] < -0.01 }], - ["FLAT (only the open bills below need accountant confirmation)", ->(r) { r[:d_bal].abs < 0.01 && r[:open_bills].any? }], -].each do |label, filter| - matching = rows.select(&filter).sort_by { |r| -r[:d_bal].abs } - next if matching.empty? - puts "=" * 78 - puts label - puts "=" * 78 - matching.first(20).each do |r| - puts " ##{r[:id]} #{r[:email]}" - puts " Balance now: $#{r[:cur_bal]} After cutover: $#{r[:new_bal]} Δ#{r[:d_bal]}" - if r[:open_bills].any? - puts " Open QBO bills (#{r[:open_bills].size} bills, sum $#{r[:sum_open]}):" - r[:open_bills].first(5).each do |b| - puts " - #{b[:host_class]} ##{b[:host_id]} $#{b[:balance].round(2)} #{b[:qbo_url]}" - end - puts " ... (#{r[:open_bills].size - 5} more)" if r[:open_bills].size > 5 - else - puts " (no open QBO bills — Δ implies missing data on either side)" - end - puts - end - if matching.size > 20 - puts " ... and #{matching.size - 20} more in this cohort" - puts - end -end diff --git a/script/audit_qbo_cutover_balance_drift.rb b/script/audit_qbo_cutover_balance_drift.rb deleted file mode 100644 index dfd4ec73..00000000 --- a/script/audit_qbo_cutover_balance_drift.rb +++ /dev/null @@ -1,167 +0,0 @@ -# Dry-run audit for the QBO-cutover balance invariant. -# -# What it does: for every contributor whose balance could plausibly shift -# under the proposed cutover (anyone with a deducting DeelInvoiceAdjustment -# OR a paid-in-QBO bill), compute their current balance and their would-be -# balance under the new rules. Report non-zero deltas. -# -# Run with: bundle exec rails runner script/audit_qbo_cutover_balance_drift.rb -# -# New-rule semantics: -# - DeelInvoiceAdjustment no longer affects balance (deducts_balance? → false going forward) -# - SyncsAsQboBill hosts (ContributorPayout / ContributorAdjustment / ProfitShare / -# Trueup / PayStub) drop out of balance when their QBO Bill mirror is Paid - -QBO_HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].freeze - -# Three deletion scopes for the "off-platform payment offset" pattern: -# :strict — negative CAs whose description references a Deel URL -# :mid — negative CAs whose description starts with "Misc payment:" -# (covers Deel, Justworks, BUS, S-Corp draws, etc. — all -# off-platform offsets entered by hand) -# :broad — every negative ContributorAdjustment -# -# Each scope assumes the corresponding QBO Bill that the CA was offsetting -# has since been marked Paid in QBO (so under the new rules it drops out -# of balance naturally). -def deletion_scope_matches?(li, scope) - return false unless li.is_a?(ContributorAdjustment) - return false unless li.amount.to_f < 0 - case scope - when :strict then li.description.to_s.match?(/deel\.com/i) - when :mid then li.description.to_s.start_with?("Misc payment:") - when :broad then true - end -end - -def post_cutover_balance(ledger_items, scope:) - ledger_items[:all].reduce({ balance: 0, unsettled: 0 }) do |acc, li| - next acc if li.respond_to?(:deleted_at) && li.deleted_at.present? - next acc if li.is_a?(DeelInvoiceAdjustment) # no longer deducts under new rules - next acc if deletion_scope_matches?(li, scope) # treat as deleted - - if li.is_a?(ContributorPayout) - if li.payable? - next acc if li.qbo_bill&.paid? - acc[:balance] += li.amount - else - acc[:unsettled] += li.amount - end - elsif li.is_a?(Reimbursement) - if li.accepted? - acc[:balance] += li.amount - else - acc[:unsettled] += li.amount - end - elsif li.is_a?(Trueup) - next acc if li.qbo_bill&.paid? - acc[:balance] += li.amount - elsif li.is_a?(ProfitShare) - if li.payable? - next acc if li.qbo_bill&.paid? - acc[:balance] += li.amount - else - acc[:unsettled] += li.amount - end - elsif li.is_a?(ContributorAdjustment) - if li.payable? - next acc if li.qbo_bill&.paid? - acc[:balance] += li.amount - else - acc[:unsettled] += li.amount - end - elsif li.is_a?(PayStub) - if li.payable? - next acc if li.qbo_bill&.paid? - acc[:balance] += li.amount - else - acc[:unsettled] += li.amount - end - end - acc - end -end - -# Candidates: anyone with at least one row that the cutover could touch. -dia_contrib_ids = - DeelInvoiceAdjustment.joins(:ledger).where(deleted_at: nil).pluck("ledgers.contributor_id").uniq -paid_qbo_pairs = - QboBill.pluck(:qbo_account_id, :qbo_id, :data).filter_map do |qa_id, qbo_id, data| - balance = data&.dig("balance") - next nil if balance.nil? - next nil unless balance.to_f <= 0 - [qa_id, qbo_id] - end - -paid_host_contrib_ids = QBO_HOST_KLASSES.flat_map do |klass| - next [] unless klass.column_names.include?("qbo_account_id") && klass.column_names.include?("qbo_bill_id") - paid_qbo_pairs.flat_map do |qa_id, qbo_id| - klass.where(qbo_account_id: qa_id, qbo_bill_id: qbo_id).joins(:ledger).pluck("ledgers.contributor_id") - end -end.uniq - -neg_ca_contrib_ids = - ContributorAdjustment.where("amount < 0").joins(:ledger).pluck("ledgers.contributor_id").uniq - -candidate_ids = (dia_contrib_ids + paid_host_contrib_ids + neg_ca_contrib_ids).uniq -puts "Candidates: #{candidate_ids.size} contributor(s)" -puts " with DIA: #{dia_contrib_ids.size}" -puts " with paid-in-QBO bills: #{paid_host_contrib_ids.size}" -puts " with negative CA rows: #{neg_ca_contrib_ids.size}" -puts - -results_per_scope = { strict: [], mid: [], broad: [] } - -Contributor.unscoped.where(id: candidate_ids).find_each do |c| - next if c.forecast_person.nil? - c.preload_for_ledger_view! - items = c.all_items_grouped_by_month(false) - - current = c.new_deal_balance(items) - - [:strict, :mid, :broad].each do |scope| - proposed = post_cutover_balance(items, scope: scope) - d_bal = (proposed[:balance] - current[:balance]).to_f.round(2) - d_uns = (proposed[:unsettled] - current[:unsettled]).to_f.round(2) - next if d_bal.abs < 0.01 && d_uns.abs < 0.01 - results_per_scope[scope] << { - id: c.id, - email: c.forecast_person.email, - cur_bal: current[:balance].to_f.round(2), - new_bal: proposed[:balance].to_f.round(2), - d_bal: d_bal, - d_uns: d_uns, - } - end -end - -[:strict, :mid, :broad].each do |scope| - affected = results_per_scope[scope] - label = case scope - when :strict then 'STRICT — delete CAs whose description references a Deel URL' - when :mid then 'MID — delete CAs starting with "Misc payment:"' - when :broad then 'BROAD — delete every negative CA' - end - puts '=' * 78 - puts "SCENARIO: #{label}" - puts '=' * 78 - if affected.empty? - puts " Zero drift on all candidates — invariant holds." - puts - next - end - total_d_bal = affected.sum { |r| r[:d_bal] }.round(2) - total_d_uns = affected.sum { |r| r[:d_uns] }.round(2) - pos = affected.count { |r| r[:d_bal] > 0 } - neg = affected.count { |r| r[:d_bal] < 0 } - near_zero = affected.count { |r| r[:d_bal].abs < 1.0 } - puts " Affected: #{affected.size} contributor(s) with non-zero delta" - puts " Δbalance: #{total_d_bal} sum (#{pos} UP, #{neg} DOWN, #{near_zero} within $1)" - puts " Δunsettled: #{total_d_uns}" - puts - puts " Top 15 by |Δbalance|:" - affected.sort_by { |r| -r[:d_bal].abs }.first(15).each do |r| - puts " ##{r[:id]} #{r[:email]}: $#{r[:cur_bal]} → $#{r[:new_bal]} (Δ#{r[:d_bal]})" - end - puts -end diff --git a/script/why_balance_goes_up.rb b/script/why_balance_goes_up.rb deleted file mode 100644 index 73a0c347..00000000 --- a/script/why_balance_goes_up.rb +++ /dev/null @@ -1,221 +0,0 @@ -# For each contributor whose post-cutover balance goes UP, decompose the -# delta into its three drivers and print row-level detail. -# -# Math: -# Δ = balance_new − balance_current -# = (positive hosts that stay in balance) -# − (positive hosts in balance now − DIA total − |neg CA total|) -# = DIA_total + |neg_CA_total| − paid_host_drops -# -# So a contributor goes UP exactly when the deductions we remove -# (DIAs + negative CAs) exceed the positive hosts that drop out via -# Paid QBO bills. The per-row detail tells us WHICH deductions weren't -# matched by a Paid bill. - -QBO_HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].freeze - -def eligible_ledger_ids_for(contributor) - contributor.ledgers.includes(:enterprise).filter_map do |l| - qa = l.enterprise.qbo_account - next nil if qa.nil? - vendor = contributor.qbo_vendor_for(qa) - next nil if vendor.nil? - l.id - end.to_set -end - -def safe_qbo_bill(li) - li.qbo_bill -rescue StandardError - nil -end - -def breakdown(items, eligible_ids) - out = { - dias: [], # [li] - neg_cas: [], # [li] - pos_hosts_paid: [], # [li] — drop out under cutover - pos_hosts_open: [], # [li] — have a QBO bill but not paid (stay) - pos_hosts_nobill: [],# [li] — no QBO bill at all (stay) - } - - items[:all].each do |li| - next unless li.respond_to?(:ledger_id) && eligible_ids.include?(li.ledger_id) - next if li.respond_to?(:deleted_at) && li.deleted_at.present? - - case li - when DeelInvoiceAdjustment - out[:dias] << li if li.deducts_balance? - when ContributorAdjustment - if li.amount.to_f < 0 - out[:neg_cas] << li - elsif li.payable? - qb = safe_qbo_bill(li) - if qb&.paid? - out[:pos_hosts_paid] << [li, qb] - elsif qb - out[:pos_hosts_open] << [li, qb] - else - out[:pos_hosts_nobill] << li - end - end - when ContributorPayout, ProfitShare, PayStub - next unless li.payable? - qb = safe_qbo_bill(li) - if qb&.paid? - out[:pos_hosts_paid] << [li, qb] - elsif qb - out[:pos_hosts_open] << [li, qb] - else - out[:pos_hosts_nobill] << li - end - when Trueup - qb = safe_qbo_bill(li) - if qb&.paid? - out[:pos_hosts_paid] << [li, qb] - elsif qb - out[:pos_hosts_open] << [li, qb] - else - out[:pos_hosts_nobill] << li - end - end - end - - out -end - -def sum_amount(rows) - rows.sum { |r| (r.is_a?(Array) ? r[0] : r).amount.to_f }.round(2) -end - -def classify(dia_total, neg_ca_total, paid_total) - removed = dia_total + neg_ca_total - if neg_ca_total > 0 && dia_total < 0.01 - "NEG-CA-DRIVEN" - elsif dia_total > 0 && neg_ca_total < 0.01 - "DIA-DRIVEN" - elsif neg_ca_total > 0 && dia_total > 0 - "BOTH" - else - "?" - end -end - -# Candidate set — same as the worklist -dia_contrib_ids = - DeelInvoiceAdjustment.joins(:ledger).where(deleted_at: nil).pluck("ledgers.contributor_id").uniq -neg_ca_contrib_ids = - ContributorAdjustment.where("amount < 0").joins(:ledger).pluck("ledgers.contributor_id").uniq - -# We need open-bills too so we can match the worklist's "UP" set definitionally, -# but the candidate set itself only matters for selecting whom to scan. -qbo_host_contrib_ids = QBO_HOST_KLASSES.flat_map do |klass| - klass.where.not(qbo_bill_id: nil).joins(:ledger).pluck("ledgers.contributor_id") -end.uniq - -candidate_ids = (dia_contrib_ids + neg_ca_contrib_ids + qbo_host_contrib_ids).uniq - -results = [] - -Contributor.unscoped.where(id: candidate_ids).find_each do |c| - next if c.forecast_person.nil? - eligible_ids = eligible_ledger_ids_for(c) - next if eligible_ids.empty? - - c.preload_for_ledger_view! - items = c.all_items_grouped_by_month(false) - - bd = breakdown(items, eligible_ids) - - dia_total = sum_amount(bd[:dias]) - neg_ca_total = sum_amount(bd[:neg_cas]).abs - paid_total = sum_amount(bd[:pos_hosts_paid]) - open_total = sum_amount(bd[:pos_hosts_open]) - nobill_total = sum_amount(bd[:pos_hosts_nobill]) - - # Δ = dia + neg_ca − paid - d_bal = (dia_total + neg_ca_total - paid_total).round(2) - next if d_bal < 0.01 - - results << { - c: c, - bd: bd, - dia_total: dia_total, - neg_ca_total: neg_ca_total, - paid_total: paid_total, - open_total: open_total, - nobill_total: nobill_total, - d_bal: d_bal, - klass: classify(dia_total, neg_ca_total, paid_total), - } -end - -results.sort_by! { |r| -r[:d_bal] } - -puts "#{results.size} contributors with Δbalance > 0" -puts -puts "Pattern distribution:" -results.group_by { |r| r[:klass] }.sort_by { |_, rs| -rs.size }.each do |k, rs| - total = rs.sum { |r| r[:d_bal] }.round(2) - puts " #{k.ljust(20)} #{rs.size} contributors Σ Δ +$#{total}" -end -puts - -results.each_with_index do |r, idx| - c = r[:c] - bd = r[:bd] - puts "=" * 78 - puts "#{idx + 1}. ##{c.id} #{c.forecast_person.email} [#{r[:klass]}]" - puts " Δ = +$#{r[:d_bal]} (DIA $#{r[:dia_total]} + |negCA| $#{r[:neg_ca_total]} − paidQBO $#{r[:paid_total]})" - puts " Positive hosts still on the books after cutover:" - puts " - open QBO bills (will drop when accountant marks Paid): $#{r[:open_total]} (#{bd[:pos_hosts_open].size} rows)" - puts " - no QBO bill at all (will NEVER drop, but never deducted via QBO either): $#{r[:nobill_total]} (#{bd[:pos_hosts_nobill].size} rows)" - puts - - if bd[:neg_cas].any? - puts " Negative CAs being deleted (#{bd[:neg_cas].size}, $#{r[:neg_ca_total]}):" - bd[:neg_cas].first(8).each do |ca| - desc = ca.description.to_s[0, 70] - puts " - CA ##{ca.id} $#{ca.amount.to_f.round(2)} #{ca.created_at.to_date} #{desc}" - end - puts " ... (#{bd[:neg_cas].size - 8} more)" if bd[:neg_cas].size > 8 - puts - end - - if bd[:dias].any? - puts " DIAs being ignored (#{bd[:dias].size}, $#{r[:dia_total]}):" - bd[:dias].first(8).each do |dia| - desc = dia.description.to_s[0, 70] - puts " - DIA ##{dia.id} $#{dia.amount.to_f.round(2)} #{dia.date_submitted} #{dia.deel_status} #{desc}" - end - puts " ... (#{bd[:dias].size - 8} more)" if bd[:dias].size > 8 - puts - end - - if bd[:pos_hosts_paid].any? - puts " Positive hosts dropping out via Paid QBO bills (#{bd[:pos_hosts_paid].size}, $#{r[:paid_total]}):" - bd[:pos_hosts_paid].first(5).each do |li, qb| - puts " - #{li.class.name} ##{li.id} $#{li.amount.to_f.round(2)} #{qb.qbo_url}" - end - puts " ... (#{bd[:pos_hosts_paid].size - 5} more)" if bd[:pos_hosts_paid].size > 5 - puts - end - - if bd[:pos_hosts_open].any? - puts " Positive hosts with OPEN QBO bills (will drop when accountant marks Paid):" - bd[:pos_hosts_open].first(5).each do |li, qb| - puts " - #{li.class.name} ##{li.id} $#{li.amount.to_f.round(2)} #{qb.qbo_url}" - end - puts " ... (#{bd[:pos_hosts_open].size - 5} more)" if bd[:pos_hosts_open].size > 5 - puts - end - - if bd[:pos_hosts_nobill].any? - puts " Positive hosts with NO QBO bill at all (these never synced):" - bd[:pos_hosts_nobill].first(5).each do |li| - puts " - #{li.class.name} ##{li.id} $#{li.amount.to_f.round(2)} ledger=#{li.ledger_id}" - end - puts " ... (#{bd[:pos_hosts_nobill].size - 5} more)" if bd[:pos_hosts_nobill].size > 5 - puts - end -end From a5a76ef671bec38b0b568be2306a6281ccdd5a5c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:55:38 -0400 Subject: [PATCH 53/67] Final sweep: remove stale LedgerWithdrawalRequest rake block, dead test setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/tasks/stacks.rake: remove auto-process loop that references the deleted LedgerWithdrawalRequest model (missed by commit e470fc2) - test/models/ledger_test.rb: drop dead setup block in HostInBalanceUnderQboBoundTest (Enterprise/Contributor/Ledger rows were never read by any test in the class; hardcoded forecast_id: 993_001 also eliminated) - test/models/ledger_test.rb: correct misleading Trueup test description - app/models/trueup.rb: correct inaccurate "no payable? gate" comment (payable? always returns true, so the bill state is what governs — the comment now says so) Co-Authored-By: Claude Opus 4.7 --- app/models/trueup.rb | 3 ++- lib/tasks/stacks.rake | 16 ---------------- test/models/ledger_test.rb | 10 +--------- 3 files changed, 3 insertions(+), 26 deletions(-) diff --git a/app/models/trueup.rb b/app/models/trueup.rb index 067444f5..75c6f74f 100644 --- a/app/models/trueup.rb +++ b/app/models/trueup.rb @@ -20,7 +20,8 @@ def payable? true end - # Trueups always represent settled income; no payable? gate. + # Trueups always represent settled income; payable? always returns true so + # only the qbo_bill payment state governs the qbo_bound balance rule. def in_balance_under_qbo_bound? !qbo_bill&.paid? end diff --git a/lib/tasks/stacks.rake b/lib/tasks/stacks.rake index 5d16e9f3..33f2ff4b 100644 --- a/lib/tasks/stacks.rake +++ b/lib/tasks/stacks.rake @@ -93,22 +93,6 @@ namespace :stacks do Sentry.capture_exception(e) if defined?(Sentry) end Rails.logger.info("[stacks:daily_enterprise_tasks] Materialized #{materialized} recurring ledger adjustment(s)") - - # Auto-process any pending LedgerWithdrawalRequests whose Bills have - # all flipped to Paid in QBO since the last run. The QboAccount - # sync_all! step above refreshes the QboBill mirror; this propagates - # "Bill paid in QBO" into "withdrawal request processed". Idempotent - # across same-day reruns. - auto_processed = 0 - LedgerWithdrawalRequest.pending.find_each do |req| - before = req.processed_at - req.maybe_auto_process! - auto_processed += 1 if req.processed_at.present? && before.nil? - rescue => e - Rails.logger.error("[stacks:daily_enterprise_tasks] LedgerWithdrawalRequest ##{req.id} auto-process failed: #{e.class}: #{e.message}") - Sentry.capture_exception(e) if defined?(Sentry) - end - Rails.logger.info("[stacks:daily_enterprise_tasks] Auto-processed #{auto_processed} withdrawal request(s)") rescue => e system_task.mark_as_error(e) else diff --git a/test/models/ledger_test.rb b/test/models/ledger_test.rb index 974cae5c..ed58b3d6 100644 --- a/test/models/ledger_test.rb +++ b/test/models/ledger_test.rb @@ -189,14 +189,6 @@ class LedgerModeAndPaymentMethodsTest < ActiveSupport::TestCase end class HostInBalanceUnderQboBoundTest < ActiveSupport::TestCase - setup do - Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "QBoundPred-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: 993_001, email: "qbp#{SecureRandom.hex(2)}@example.com", data: {}) - @contributor = Contributor.create!(forecast_person: fp) - @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor) - end - test "DeelInvoiceAdjustment is never in balance under qbo_bound" do dia = DeelInvoiceAdjustment.new(amount: 100, deel_status: "approved") refute dia.in_balance_under_qbo_bound? @@ -228,7 +220,7 @@ class HostInBalanceUnderQboBoundTest < ActiveSupport::TestCase refute cp.in_balance_under_qbo_bound? end - test "Trueup: in balance when qbo_bill unpaid (no payable? check)" do + test "Trueup: in balance when qbo_bill unpaid (payable? always true; bill state governs)" do t = Trueup.new t.stubs(:qbo_bill).returns(nil) assert t.in_balance_under_qbo_bound? From 9ffc209c818128a5ae251c09d98147fe193c861a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 19:32:09 -0400 Subject: [PATCH 54/67] db/schema.rb: restore composite-FK comments for qbo_invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three composite FKs (adhoc_invoice_trackers, contributor_adjustments, invoice_trackers → qbo_invoices) reference (qbo_account_id, qbo_id) but Rails' add_foreign_key DSL can only express single-column FKs, so it auto-dumps them as REFERENCES qbo_invoices(qbo_account_id) — which fails on a fresh schema load with PG: 'no unique constraint matching given keys for referenced table qbo_invoices'. Main keeps a manual comment-and-skip workaround. Earlier migrations on this branch regenerated schema.rb and clobbered it, which is why every CI run on this branch (going back to #680) errored at db:schema:load before any test could run. Restoring the comment lines from main. --- db/schema.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 927ec9b1..eccb9e45 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1225,14 +1225,14 @@ add_foreign_key "account_lead_periods", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "qbo_accounts" - add_foreign_key "adhoc_invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_adhoc_invoice_trackers_qbo_invoice" + # Composite FK fk_adhoc_invoice_trackers_qbo_invoice managed by migration (not expressible in schema.rb) add_foreign_key "admin_user_salary_windows", "admin_users" add_foreign_key "associates_award_agreements", "admin_users" add_foreign_key "commissions", "contributors" add_foreign_key "commissions", "project_trackers" add_foreign_key "contributor_adjustments", "ledgers" add_foreign_key "contributor_adjustments", "qbo_accounts" - add_foreign_key "contributor_adjustments", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_contributor_adjustments_qbo_invoice" + # Composite FK fk_contributor_adjustments_qbo_invoice managed by migration (not expressible in schema.rb) add_foreign_key "contributor_payouts", "admin_users", column: "created_by_id" add_foreign_key "contributor_payouts", "invoice_trackers" add_foreign_key "contributor_payouts", "ledgers" @@ -1251,7 +1251,7 @@ add_foreign_key "invoice_trackers", "admin_users" add_foreign_key "invoice_trackers", "invoice_passes" add_foreign_key "invoice_trackers", "qbo_accounts" - add_foreign_key "invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_invoice_trackers_qbo_invoice" + # Composite FK fk_invoice_trackers_qbo_invoice managed by migration (not expressible in schema.rb) add_foreign_key "ledgers", "contributors" add_foreign_key "ledgers", "enterprises" add_foreign_key "mailing_list_subscribers", "mailing_lists" From cbf52715b2d2ef5bb05741db202151c59c03a65a Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 18:31:26 -0400 Subject: [PATCH 55/67] qbo_bound: paid items drop from unsettled too, not just from balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: a positive host whose QBO bill is Paid was correctly leaving Ledger#balance under qbo_bound, but was getting shoved into Ledger#unsettled instead of disappearing — because the rule was reject(&:in_balance_under_qbo_bound?), which puts every item not in balance into unsettled. That included items already settled in QBO. Wrong: a@adapopowicz.com's qbo_bound preview showed unsettled $5,175 — three paid CPs that should have dropped entirely. Vendor record in QBO showed $0. Fix: unsettled in qbo_bound = reject(&:payable?), same predicate as legacy. Items that are payable AND have their QBO bill paid now drop from both buckets. Same change in Ledgers::QboBoundMigrationCheck so the gate matches. After fix, the rake task flipped another 53 ledgers (85 → 32 blocked) — every one of those was in this paid-but-no-offsetting-neg-CA shape. --- app/models/ledger.rb | 11 ++++++++--- .../ledgers/qbo_bound_migration_check.rb | 2 +- test/models/ledger_test.rb | 18 +++++++++++++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/models/ledger.rb b/app/models/ledger.rb index eae59f55..f53b8d69 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -81,8 +81,13 @@ def self.ensure_for_enterprise!(enterprise) rows.size end - # Balance/unsettled split. legacy preserves today's rules; qbo_bound trusts - # the QBO Bill Paid status as the single source of truth. + # 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 if legacy? visible_items.select(&:payable?).sum(&:signed_amount) @@ -97,7 +102,7 @@ def unsettled if legacy? visible_items.reject(&:payable?).sum(&:signed_amount) elsif qbo_bound? - qbo_bound_visible_items.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + qbo_bound_visible_items.reject(&:payable?).sum(&:signed_amount) else raise "Unknown ledger mode: #{mode.inspect}" end diff --git a/app/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb index 3bf25069..216a34bb 100644 --- a/app/services/ledgers/qbo_bound_migration_check.rb +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -22,7 +22,7 @@ def self.call(ledger) 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_visible.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f - new_u = qbb_visible.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f + new_u = qbb_visible.reject(&:payable?).sum(&:signed_amount).to_f db = (new_b - legacy_b).round(2) du = (new_u - legacy_u).round(2) diff --git a/test/models/ledger_test.rb b/test/models/ledger_test.rb index ed58b3d6..2cec9a80 100644 --- a/test/models/ledger_test.rb +++ b/test/models/ledger_test.rb @@ -257,7 +257,7 @@ class LedgerBalanceUnderQboBoundTest < ActiveSupport::TestCase assert_equal 100, @ledger.balance.to_f end - test "qbo_bound mode drops a positive host whose qbo_bill is paid" do + 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") @@ -270,7 +270,23 @@ class LedgerBalanceUnderQboBoundTest < ActiveSupport::TestCase 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(:in_balance_under_qbo_bound?).returns(false) + pending.stubs(:signed_amount).returns(100) + 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 From 868f5d2ef013817b6a9cd1225b52371c17239b09 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 18:44:00 -0400 Subject: [PATCH 56/67] =?UTF-8?q?Migrate=20panel:=20explain=20=CE=94=20by?= =?UTF-8?q?=20driver=20category=20instead=20of=20mislabeling=20open=20bill?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open QBO bills aren't blocking — they contribute equally to balance under legacy and qbo_bound. The "blocking" label was wrong. Rework the gate's Result struct and the Ledger admin panel to make this honest: Result fields (renamed): blocking_bills → dropped_paid_hosts + open_qbo_bills ignored_negative_cas → removed_neg_cas Plus new: removed_dias (was always present in the math, never surfaced) Panel layout (when not ready): • "What's driving the Δ" — three categories with per-category sums: Negative CAs ignored (audit-only) +Δ contribution DIAs ignored (audit-only) +Δ contribution Paid QBO bills dropping out −Δ contribution • "Remedy options" when Δ > 0: open QBO bills — marking each Paid in QBO turns it into a dropped paid host on the next check, reducing Δ by its amount • Note when Δ < 0: paid hosts unmatched by audit-only deductions; accept the lower balance or add a corrective adjustment Removes the misleading "Open QBO bills blocking the migration" header. --- app/admin/ledgers.rb | 67 ++++++++++++++++--- .../ledgers/qbo_bound_migration_check.rb | 49 +++++++++++--- db/schema.rb | 40 +---------- test/integration/ledger_migration_test.rb | 2 +- test/lib/tasks/ledgers_rake_test.rb | 2 +- .../ledgers/qbo_bound_migration_check_test.rb | 6 +- 6 files changed, 104 insertions(+), 62 deletions(-) diff --git a/app/admin/ledgers.rb b/app/admin/ledgers.rb index 2a15e245..16fa9ff8 100644 --- a/app/admin/ledgers.rb +++ b/app/admin/ledgers.rb @@ -35,38 +35,83 @@ if resource.legacy? panel "Migrate to QBO-bound" do result = Ledgers::QboBoundMigrationCheck.call(resource) + div do - para "Current (legacy): balance #{number_to_currency(result.current_balance)} unsettled #{number_to_currency(result.current_unsettled)}" + 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 + if result.ready? div do para "Net-zero change — safe to migrate." button_to "Migrate to QBO-bound", migrate_to_qbo_bound_admin_ledger_path(resource), method: :post, data: { confirm: "Flip this ledger to qbo_bound?" } end else + 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) + div do - if result.blocking_bills.any? - para "Open QBO bills blocking the migration:" + h4 "What's driving the Δ" + para "Under qbo_bound these items behave differently. The net (sum of audit-only deductions removed, minus paid hosts dropping out) is Δbalance." + + 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.blocking_bills.first(20).each do |bb| + result.dropped_paid_hosts.first(15).each do |b| li do - text_node "#{bb.host.class.name} ##{bb.host.id} — #{number_to_currency(bb.amount.to_f)} — " - link_to "Pay in QBO ↗", bb.qbo_bill.qbo_url, target: "_blank", rel: "noopener" + 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 - if result.ignored_negative_cas.any? - para "Negative CAs (audit-only after migration):" + end + + if result.balance_delta > 0 && result.open_qbo_bills.any? + div do + h4 "Remedy options" + para "Δ is positive, so qbo_bound would show MORE balance than legacy. Marking any of these open QBO bills as Paid in QBO will drop the corresponding host from qbo_bound balance, reducing Δ by its amount." ul do - result.ignored_negative_cas.first(10).each do |ca| - li "CA ##{ca.id} — #{number_to_currency(ca.amount.to_f)}" + 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 - para "Resolve the open bills in QBO, then refresh this page or click Re-check." + elsif result.balance_delta < 0 + div do + h4 "Note" + para "Δ is negative, so qbo_bound would show LESS balance than legacy. There are paid QBO bills here that aren't matched by audit-only deductions — either accept the lower balance and migrate, or add a corrective adjustment in legacy first." + end + end + + div do button_to "Re-check", admin_ledger_path(resource), method: :get end end diff --git a/app/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb index 216a34bb..543000d7 100644 --- a/app/services/ledgers/qbo_bound_migration_check.rb +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -1,7 +1,18 @@ module Ledgers # Computes whether a legacy Ledger can flip to qbo_bound with zero # change to balance or unsettled. Returns a Result struct exposing - # the deltas and the open QBO bills that explain any gap. + # the deltas and the per-item drivers — items whose treatment differs + # between the two rules. + # + # Driver categories (each contributes to Δbalance): + # - removed_neg_cas — negative CAs ignored under qbo_bound (Δ += |amount|) + # - removed_dias — DIAs ignored under qbo_bound (Δ += amount) + # - dropped_paid_hosts — payable hosts whose QBO bill is Paid (Δ −= amount) + # + # open_qbo_bills are unpaid bills on the ledger. They're informational — + # they contribute equally to balance under both rules (no Δ), but marking + # one Paid in QBO turns it into a dropped_paid_host on the next check, + # which is a remedy when Δ > 0. class QboBoundMigrationCheck TOLERANCE = 0.01 @@ -9,11 +20,12 @@ class QboBoundMigrationCheck :current_balance, :current_unsettled, :proposed_balance, :proposed_unsettled, :balance_delta, :unsettled_delta, - :ready?, :blocking_bills, :ignored_negative_cas, + :ready?, + :removed_neg_cas, :removed_dias, :dropped_paid_hosts, :open_qbo_bills, keyword_init: true, ) - BlockingBill = Struct.new(:host, :qbo_bill, :amount, keyword_init: true) + OpenBill = Struct.new(:host, :qbo_bill, :amount, keyword_init: true) def self.call(ledger) legacy_visible = ledger.send(:visible_items) @@ -35,15 +47,32 @@ def self.call(ledger) balance_delta: db, unsettled_delta: du, ready?: db.abs < TOLERANCE && du.abs < TOLERANCE, - blocking_bills: collect_blocking_bills(legacy_visible), - ignored_negative_cas: legacy_visible.select { |li| li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 }, + 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 - # Open QBO bills that explain why qbo_bound balance != legacy balance. - # Skips audit-only rows (DIAs, negative CAs) and rows that don't sync - # as QBO Bills at all (e.g. Reimbursement). - def self.collect_blocking_bills(items) + # Payable hosts whose QBO bill is Paid. They drop from qbo_bound balance, + # decreasing Δ by their amount. + 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. They don't cause Δ — they contribute + # equally under both rules — but they're surfaced so the controller can + # mark them Paid to remedy a positive Δ. + 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) @@ -52,7 +81,7 @@ def self.collect_blocking_bills(items) qb = li.qbo_bill next nil if qb.nil? || qb.paid? - BlockingBill.new(host: li, qbo_bill: qb, amount: li.amount.to_f) + OpenBill.new(host: li, qbo_bill: qb, amount: li.amount.to_f) end end end diff --git a/db/schema.rb b/db/schema.rb index eccb9e45..5d7c4ff4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -891,23 +891,6 @@ t.index ["enterprise_id"], name: "index_qbo_accounts_on_enterprise_id" end - create_table "qbo_bill_account_mappings", force: :cascade do |t| - t.bigint "enterprise_id", null: false - t.string "line_item_key", null: false - t.bigint "project_tracker_id" - t.bigint "contributor_id" - t.string "qbo_chart_account_qbo_id", null: false - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false - t.index ["contributor_id"], name: "index_qbo_bill_account_mappings_on_contributor_id" - t.index ["enterprise_id", "line_item_key", "contributor_id"], name: "idx_qbo_bill_acct_mappings_contributor", unique: true, where: "(contributor_id IS NOT NULL)" - t.index ["enterprise_id", "line_item_key", "project_tracker_id"], name: "idx_qbo_bill_acct_mappings_tracker", unique: true, where: "(project_tracker_id IS NOT NULL)" - t.index ["enterprise_id", "line_item_key"], name: "idx_qbo_bill_acct_mappings_default", unique: true, where: "((project_tracker_id IS NULL) AND (contributor_id IS NULL))" - t.index ["enterprise_id"], name: "index_qbo_bill_account_mappings_on_enterprise_id" - t.index ["project_tracker_id"], name: "index_qbo_bill_account_mappings_on_project_tracker_id" - t.check_constraint "(project_tracker_id IS NULL) OR (contributor_id IS NULL)", name: "qbo_bill_acct_mappings_one_subject" - end - create_table "qbo_bills", force: :cascade do |t| t.string "qbo_id", null: false t.jsonb "data" @@ -919,19 +902,6 @@ t.index ["qbo_vendor_id"], name: "index_qbo_bills_on_qbo_vendor_id" end - create_table "qbo_chart_accounts", force: :cascade do |t| - t.string "qbo_id", null: false - t.bigint "qbo_account_id", null: false - t.string "name", null: false - t.string "acct_num" - t.string "classification" - t.string "account_type" - t.boolean "active", default: true, null: false - t.jsonb "data" - t.index ["qbo_account_id", "qbo_id"], name: "index_qbo_chart_accounts_on_qbo_account_and_qbo_id", unique: true - t.index ["qbo_account_id"], name: "index_qbo_chart_accounts_on_qbo_account_id" - end - create_table "qbo_invoices", force: :cascade do |t| t.string "qbo_id", null: false t.jsonb "data" @@ -1225,14 +1195,14 @@ add_foreign_key "account_lead_periods", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "qbo_accounts" - # Composite FK fk_adhoc_invoice_trackers_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "adhoc_invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_adhoc_invoice_trackers_qbo_invoice" add_foreign_key "admin_user_salary_windows", "admin_users" add_foreign_key "associates_award_agreements", "admin_users" add_foreign_key "commissions", "contributors" add_foreign_key "commissions", "project_trackers" add_foreign_key "contributor_adjustments", "ledgers" add_foreign_key "contributor_adjustments", "qbo_accounts" - # Composite FK fk_contributor_adjustments_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "contributor_adjustments", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_contributor_adjustments_qbo_invoice" add_foreign_key "contributor_payouts", "admin_users", column: "created_by_id" add_foreign_key "contributor_payouts", "invoice_trackers" add_foreign_key "contributor_payouts", "ledgers" @@ -1251,7 +1221,7 @@ add_foreign_key "invoice_trackers", "admin_users" add_foreign_key "invoice_trackers", "invoice_passes" add_foreign_key "invoice_trackers", "qbo_accounts" - # Composite FK fk_invoice_trackers_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_invoice_trackers_qbo_invoice" add_foreign_key "ledgers", "contributors" add_foreign_key "ledgers", "enterprises" add_foreign_key "mailing_list_subscribers", "mailing_lists" @@ -1305,11 +1275,7 @@ add_foreign_key "project_tracker_links", "project_trackers" add_foreign_key "project_trackers", "runn_projects", primary_key: "runn_id" add_foreign_key "qbo_accounts", "enterprises" - add_foreign_key "qbo_bill_account_mappings", "contributors" - add_foreign_key "qbo_bill_account_mappings", "enterprises" - add_foreign_key "qbo_bill_account_mappings", "project_trackers" add_foreign_key "qbo_bills", "qbo_accounts" - add_foreign_key "qbo_chart_accounts", "qbo_accounts" add_foreign_key "qbo_invoices", "qbo_accounts" add_foreign_key "qbo_profit_and_loss_reports", "qbo_accounts" add_foreign_key "qbo_tokens", "qbo_accounts" diff --git a/test/integration/ledger_migration_test.rb b/test/integration/ledger_migration_test.rb index ec152159..d58bf56d 100644 --- a/test/integration/ledger_migration_test.rb +++ b/test/integration/ledger_migration_test.rb @@ -30,7 +30,7 @@ class LedgerMigrationTest < ActionDispatch::IntegrationTest current_balance: 0, current_unsettled: 0, proposed_balance: 100, proposed_unsettled: 0, balance_delta: 100, unsettled_delta: 0, - ready?: false, blocking_bills: [], ignored_negative_cas: [], + ready?: false, removed_neg_cas: [], removed_dias: [], dropped_paid_hosts: [], open_qbo_bills: [], ) Ledgers::QboBoundMigrationCheck.expects(:call).with(@ledger).returns(not_ready) diff --git a/test/lib/tasks/ledgers_rake_test.rb b/test/lib/tasks/ledgers_rake_test.rb index 89420cc2..d9b4939f 100644 --- a/test/lib/tasks/ledgers_rake_test.rb +++ b/test/lib/tasks/ledgers_rake_test.rb @@ -23,7 +23,7 @@ class LedgersRakeTest < ActiveSupport::TestCase @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, ready?: false, blocking_bills: [], ignored_negative_cas: [], + balance_delta: 100, unsettled_delta: 0, ready?: false, removed_neg_cas: [], removed_dias: [], dropped_paid_hosts: [], open_qbo_bills: [], ) Ledgers::QboBoundMigrationCheck.stubs(:call).returns(blocked) diff --git a/test/services/ledgers/qbo_bound_migration_check_test.rb b/test/services/ledgers/qbo_bound_migration_check_test.rb index a7f5d408..553c4c7e 100644 --- a/test/services/ledgers/qbo_bound_migration_check_test.rb +++ b/test/services/ledgers/qbo_bound_migration_check_test.rb @@ -22,8 +22,10 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase assert_respond_to r, :proposed_balance assert_respond_to r, :balance_delta assert_respond_to r, :ready? - assert_respond_to r, :blocking_bills - assert_respond_to r, :ignored_negative_cas + 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 end test "ledger is blocked when ledger.balance under qbo_bound != legacy" do From eb41f44534df51d8e8614e71943f28be168ba200 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 18:53:14 -0400 Subject: [PATCH 57/67] Reimbursement: sync as QBO Bill like every other payable host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reimbursements were the one payable host that didn't sync — they sat in balance forever (legacy and qbo_bound) and were historically settled via a matching negative ContributorAdjustment. That broke the qbo_bound model: the neg CA is audit-only on qbo_bound, but the reimbursement stays, leaving an orphan Δ on the ledger. Now Reimbursement follows the same pattern as ContributorPayout: • New qbo_bill_id column • include SyncsAsQboBill • bill_txn_date / bill_description / bill_doc_number_code ("RB") • in_balance_under_qbo_bound? = accepted? AND !qbo_bill&.paid? Toggle-acceptance hook now pushes to QBO on Accept (best-effort) and detach-and-destroys the bill on Deny. For existing accepted reimbursements (5 in dev): bundle exec rake reimbursements:backfill_qbo_bills QBO account routing uses the default ("Contractors - Client Services"); override in Reimbursement#find_qbo_account! later if a different category is wanted. --- app/admin/contributors.rb | 11 ++ app/models/reimbursement.rb | 20 ++- ...25103_add_qbo_bill_id_to_reimbursements.rb | 12 ++ db/schema.rb | 146 ++++-------------- lib/tasks/reimbursements.rake | 24 +++ 5 files changed, 98 insertions(+), 115 deletions(-) create mode 100644 db/migrate/20260613225103_add_qbo_bill_id_to_reimbursements.rb create mode 100644 lib/tasks/reimbursements.rake diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index 27e4cb8f..f05f3acc 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -120,6 +120,9 @@ def manual_deel_invoice_visible?(contributor) return unless current_admin_user.is_admin? if r.accepted? + # Un-accepting: detach + destroy the QBO bill so the books stay aligned. + # No-op if the bill was never synced. + r.detach_and_destroy_qbo_bill if r.qbo_bill_id.present? r.update!( accepted_by: nil, accepted_at: nil @@ -129,6 +132,14 @@ def manual_deel_invoice_visible?(contributor) accepted_by: current_admin_user, accepted_at: DateTime.now ) + # Push to QBO so the reimbursement is payable through the same flow as + # every other host. Best-effort: log + continue if the QBO push fails + # (admin can retry via the Payable QBO Bills page). + begin + r.sync_qbo_bill! + rescue => e + Rails.logger.error("[reimbursement_accept] sync_qbo_bill! failed for ##{r.id}: #{e.class}: #{e.message}") + end end return redirect_to( diff --git a/app/models/reimbursement.rb b/app/models/reimbursement.rb index 3bf9d421..5fe5f6d3 100644 --- a/app/models/reimbursement.rb +++ b/app/models/reimbursement.rb @@ -2,6 +2,7 @@ class Reimbursement < ApplicationRecord acts_as_paranoid include LedgerItem include BustsTaskCache + include SyncsAsQboBill belongs_to :accepted_by, class_name: 'AdminUser', optional: true @@ -33,9 +34,24 @@ def payable? accepted? end - # Reimbursements aren't synced as QBO bills; same gate as legacy. + # Reimbursements sync as QBO Bills like every other payable host. They + # stay in balance until the QBO bill is marked Paid — same lifecycle as + # ContributorPayout/Adjustment/ProfitShare/Trueup/PayStub. def in_balance_under_qbo_bound? - accepted? + accepted? && !qbo_bill&.paid? + end + + # SyncsAsQboBill contract + def bill_txn_date + accepted_at&.to_date || created_at.to_date + end + + def bill_description + "https://stacks.garden3d.net/admin/ledgers/#{ledger_id}/reimbursements/#{id}" + end + + def bill_doc_number_code + "RB" end def effective_on_for_display diff --git a/db/migrate/20260613225103_add_qbo_bill_id_to_reimbursements.rb b/db/migrate/20260613225103_add_qbo_bill_id_to_reimbursements.rb new file mode 100644 index 00000000..c9fcc1e7 --- /dev/null +++ b/db/migrate/20260613225103_add_qbo_bill_id_to_reimbursements.rb @@ -0,0 +1,12 @@ +class AddQboBillIdToReimbursements < ActiveRecord::Migration[6.1] + # Reimbursements now sync as QBO Bills like every other payable host + # (ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub), + # so qbo_bound balance treats them the same: in balance until the QBO bill + # is marked Paid. Existing accepted reimbursements get backfilled via + # `bundle exec rake reimbursements:backfill_qbo_bills` — the API push + # happens out-of-band so the migration stays fast and offline. + def change + add_column :reimbursements, :qbo_bill_id, :string + add_index :reimbursements, :qbo_bill_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 5d7c4ff4..d99135af 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2026_06_12_214545) do +ActiveRecord::Schema.define(version: 2026_06_13_225103) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gist" @@ -891,6 +891,23 @@ t.index ["enterprise_id"], name: "index_qbo_accounts_on_enterprise_id" end + create_table "qbo_bill_account_mappings", force: :cascade do |t| + t.bigint "enterprise_id", null: false + t.string "line_item_key", null: false + t.bigint "project_tracker_id" + t.bigint "contributor_id" + t.string "qbo_chart_account_qbo_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["contributor_id"], name: "index_qbo_bill_account_mappings_on_contributor_id" + t.index ["enterprise_id", "line_item_key", "contributor_id"], name: "idx_qbo_bill_acct_mappings_contributor", unique: true, where: "(contributor_id IS NOT NULL)" + t.index ["enterprise_id", "line_item_key", "project_tracker_id"], name: "idx_qbo_bill_acct_mappings_tracker", unique: true, where: "(project_tracker_id IS NOT NULL)" + t.index ["enterprise_id", "line_item_key"], name: "idx_qbo_bill_acct_mappings_default", unique: true, where: "((project_tracker_id IS NULL) AND (contributor_id IS NULL))" + t.index ["enterprise_id"], name: "index_qbo_bill_account_mappings_on_enterprise_id" + t.index ["project_tracker_id"], name: "index_qbo_bill_account_mappings_on_project_tracker_id" + t.check_constraint "(project_tracker_id IS NULL) OR (contributor_id IS NULL)", name: "qbo_bill_acct_mappings_one_subject" + end + create_table "qbo_bills", force: :cascade do |t| t.string "qbo_id", null: false t.jsonb "data" @@ -902,6 +919,19 @@ t.index ["qbo_vendor_id"], name: "index_qbo_bills_on_qbo_vendor_id" end + create_table "qbo_chart_accounts", force: :cascade do |t| + t.string "qbo_id", null: false + t.bigint "qbo_account_id", null: false + t.string "name", null: false + t.string "acct_num" + t.string "classification" + t.string "account_type" + t.boolean "active", default: true, null: false + t.jsonb "data" + t.index ["qbo_account_id", "qbo_id"], name: "index_qbo_chart_accounts_on_qbo_account_and_qbo_id", unique: true + t.index ["qbo_account_id"], name: "index_qbo_chart_accounts_on_qbo_account_id" + end + create_table "qbo_invoices", force: :cascade do |t| t.string "qbo_id", null: false t.jsonb "data" @@ -982,9 +1012,11 @@ t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.bigint "ledger_id", null: false + t.string "qbo_bill_id" t.index ["accepted_by_id"], name: "index_reimbursements_on_accepted_by_id" t.index ["deleted_at"], name: "index_reimbursements_on_deleted_at" t.index ["ledger_id"], name: "index_reimbursements_on_ledger_id" + t.index ["qbo_bill_id"], name: "index_reimbursements_on_qbo_bill_id" end create_table "review_trees", force: :cascade do |t| @@ -1195,116 +1227,4 @@ add_foreign_key "account_lead_periods", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "qbo_accounts" - add_foreign_key "adhoc_invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_adhoc_invoice_trackers_qbo_invoice" - add_foreign_key "admin_user_salary_windows", "admin_users" - add_foreign_key "associates_award_agreements", "admin_users" - add_foreign_key "commissions", "contributors" - add_foreign_key "commissions", "project_trackers" - add_foreign_key "contributor_adjustments", "ledgers" - add_foreign_key "contributor_adjustments", "qbo_accounts" - add_foreign_key "contributor_adjustments", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_contributor_adjustments_qbo_invoice" - add_foreign_key "contributor_payouts", "admin_users", column: "created_by_id" - add_foreign_key "contributor_payouts", "invoice_trackers" - add_foreign_key "contributor_payouts", "ledgers" - add_foreign_key "contributor_qbo_vendors", "contributors" - add_foreign_key "contributor_qbo_vendors", "qbo_accounts" - add_foreign_key "contributor_qbo_vendors", "qbo_vendors" - add_foreign_key "deel_invoice_adjustments", "deel_contracts", primary_key: "deel_id" - add_foreign_key "deel_invoice_adjustments", "ledgers" - add_foreign_key "enterprise_admins", "admin_users" - add_foreign_key "enterprise_admins", "enterprises" - add_foreign_key "enterprise_forecast_clients", "enterprises" - add_foreign_key "enterprise_forecast_clients", "forecast_clients", primary_key: "forecast_id" - add_foreign_key "finalizations", "reviews" - add_foreign_key "full_time_periods", "admin_users" - add_foreign_key "gifted_profit_shares", "admin_users" - add_foreign_key "invoice_trackers", "admin_users" - add_foreign_key "invoice_trackers", "invoice_passes" - add_foreign_key "invoice_trackers", "qbo_accounts" - add_foreign_key "invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_invoice_trackers_qbo_invoice" - add_foreign_key "ledgers", "contributors" - add_foreign_key "ledgers", "enterprises" - add_foreign_key "mailing_list_subscribers", "mailing_lists" - add_foreign_key "mailing_lists", "studios" - add_foreign_key "misc_payments", "contributors" - add_foreign_key "okr_period_studios", "okr_periods" - add_foreign_key "okr_period_studios", "studios" - add_foreign_key "okr_periods", "okrs" - add_foreign_key "old_deal_creative_lead_periods", "admin_users" - add_foreign_key "old_deal_creative_lead_periods", "project_trackers" - add_foreign_key "old_deal_creative_lead_periods", "studios" - add_foreign_key "old_deal_project_lead_periods", "admin_users" - add_foreign_key "old_deal_project_lead_periods", "project_trackers" - add_foreign_key "old_deal_project_lead_periods", "studios" - add_foreign_key "old_deal_project_safety_representative_periods", "admin_users" - add_foreign_key "old_deal_project_safety_representative_periods", "project_trackers" - add_foreign_key "old_deal_project_safety_representative_periods", "studios" - add_foreign_key "old_deal_technical_lead_periods", "admin_users" - add_foreign_key "old_deal_technical_lead_periods", "project_trackers" - add_foreign_key "old_deal_technical_lead_periods", "studios" - add_foreign_key "pay_cycles", "admin_users", column: "approved_by_id" - add_foreign_key "pay_cycles", "admin_users", column: "created_by_id" - add_foreign_key "pay_cycles", "enterprises" - add_foreign_key "pay_stubs", "admin_users", column: "accepted_by_id" - add_foreign_key "pay_stubs", "ledgers" - add_foreign_key "pay_stubs", "pay_cycles" - add_foreign_key "peer_reviews", "admin_users" - add_foreign_key "peer_reviews", "reviews" - add_foreign_key "periodic_reports", "notifications" - add_foreign_key "pre_profit_share_purchases", "admin_users" - add_foreign_key "profit_share_payments", "admin_users" - add_foreign_key "profit_share_payments", "profit_share_passes" - add_foreign_key "profit_shares", "ledgers" - add_foreign_key "profit_shares", "periodic_reports" - add_foreign_key "project_capsules", "project_trackers" - add_foreign_key "project_lead_periods", "admin_users" - add_foreign_key "project_lead_periods", "project_trackers" - add_foreign_key "project_satisfaction_survey_free_text_question_responses", "project_satisfaction_survey_free_text_questions" - add_foreign_key "project_satisfaction_survey_free_text_question_responses", "project_satisfaction_survey_responses" - add_foreign_key "project_satisfaction_survey_free_text_questions", "project_satisfaction_surveys" - add_foreign_key "project_satisfaction_survey_question_responses", "project_satisfaction_survey_questions" - add_foreign_key "project_satisfaction_survey_question_responses", "project_satisfaction_survey_responses" - add_foreign_key "project_satisfaction_survey_questions", "project_satisfaction_surveys" - add_foreign_key "project_satisfaction_survey_responders", "admin_users" - add_foreign_key "project_satisfaction_survey_responders", "project_satisfaction_surveys" - add_foreign_key "project_satisfaction_survey_responses", "project_satisfaction_surveys" - add_foreign_key "project_satisfaction_surveys", "project_capsules" - add_foreign_key "project_tracker_forecast_projects", "project_trackers" - add_foreign_key "project_tracker_forecast_to_runn_sync_tasks", "notifications" - add_foreign_key "project_tracker_forecast_to_runn_sync_tasks", "project_trackers" - add_foreign_key "project_tracker_links", "project_trackers" - add_foreign_key "project_trackers", "runn_projects", primary_key: "runn_id" - add_foreign_key "qbo_accounts", "enterprises" - add_foreign_key "qbo_bills", "qbo_accounts" - add_foreign_key "qbo_invoices", "qbo_accounts" - add_foreign_key "qbo_profit_and_loss_reports", "qbo_accounts" - add_foreign_key "qbo_tokens", "qbo_accounts" - add_foreign_key "qbo_vendors", "qbo_accounts" - add_foreign_key "recurring_ledger_adjustments", "ledgers" - add_foreign_key "reimbursements", "admin_users", column: "accepted_by_id" - add_foreign_key "reimbursements", "ledgers" - add_foreign_key "review_trees", "reviews" - add_foreign_key "review_trees", "trees" - add_foreign_key "reviews", "admin_users" - add_foreign_key "score_trees", "trees" - add_foreign_key "score_trees", "workspaces" - add_foreign_key "scores", "score_trees" - add_foreign_key "scores", "traits" - add_foreign_key "studio_memberships", "admin_users" - add_foreign_key "studio_memberships", "studios" - add_foreign_key "survey_free_text_question_responses", "survey_free_text_questions" - add_foreign_key "survey_free_text_question_responses", "survey_responses" - add_foreign_key "survey_free_text_questions", "surveys" - add_foreign_key "survey_question_responses", "survey_questions" - add_foreign_key "survey_question_responses", "survey_responses" - add_foreign_key "survey_questions", "surveys" - add_foreign_key "survey_responders", "admin_users" - add_foreign_key "survey_responders", "surveys" - add_foreign_key "survey_responses", "surveys" - add_foreign_key "survey_studios", "studios" - add_foreign_key "survey_studios", "surveys" - add_foreign_key "system_tasks", "notifications" - add_foreign_key "traits", "trees" - add_foreign_key "trueups", "invoice_passes" - add_foreign_key "trueups", "ledgers" end diff --git a/lib/tasks/reimbursements.rake b/lib/tasks/reimbursements.rake new file mode 100644 index 00000000..62d949af --- /dev/null +++ b/lib/tasks/reimbursements.rake @@ -0,0 +1,24 @@ +namespace :reimbursements do + desc "Push every accepted Reimbursement to QBO that hasn't synced yet" + task backfill_qbo_bills: :environment do + synced = 0 + skipped = 0 + errors = 0 + + Reimbursement.where.not(accepted_by_id: nil).where(qbo_bill_id: nil).find_each do |r| + r.sync_qbo_bill! + if r.reload.qbo_bill_id.present? + synced += 1 + else + # sync_qbo_bill! short-circuits silently when the contributor has no + # ContributorQboVendor mapping or the enterprise has no QBO account. + skipped += 1 + end + rescue => e + errors += 1 + warn "Reimbursement ##{r.id}: #{e.class}: #{e.message}" + end + + puts "Synced #{synced} reimbursements; #{skipped} skipped (missing mapping); #{errors} errors." + end +end From 3dcb3673e9e82d79f0c3bcdbec227e5aa866f9b6 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 19:12:00 -0400 Subject: [PATCH 58/67] Migration gate: enforce one-to-one match with QBO vendor record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old gate compared two Stacks-side views (legacy vs qbo_bound balance) and asked "would migration change anything?" That missed cases where Stacks IS wrong and QBO is right — Armin-shape ledgers where legacy over-recorded an off-platform payment and QBO has an Expense-to-AP entry to reconcile. The old gate refused to migrate those even though the qbo_bound view actually matches QBO truth. New gate: compare Stacks (proposed qbo_bound balance + unsettled) against QBO vendor's `data["balance"]`. Ready when they match within $0.01. Reasons block: - no QBO vendor mapping (can't verify) - mismatch (likely Expense-to-AP, vendor credit, or open Bill in QBO that Stacks can't see) Result fields added: stacks_open_total, qbo_vendor_balance, qbo_diff, qbo_match?, qbo_vendor_missing?. The legacy-vs-qbo_bound Δ is still surfaced as diagnostic info to explain WHY the two Stacks views differ. Panel reorganized: QBO match check at top (the gate), driver categories and open bills below as diagnostics. Effect on dev data: rake flipped 8 more ledgers (job, enquiries@aufi, and 6 others that match QBO despite Δ ≠ 0). 24 remain blocked — those are the real reconciliation work. --- app/admin/ledgers.rb | 98 +++++++++++-------- .../ledgers/qbo_bound_migration_check.rb | 46 +++++---- test/integration/ledger_migration_test.rb | 9 +- test/lib/tasks/ledgers_rake_test.rb | 10 +- .../ledgers/qbo_bound_migration_check_test.rb | 38 +++++-- 5 files changed, 133 insertions(+), 68 deletions(-) diff --git a/app/admin/ledgers.rb b/app/admin/ledgers.rb index 16fa9ff8..6ed77039 100644 --- a/app/admin/ledgers.rb +++ b/app/admin/ledgers.rb @@ -17,9 +17,12 @@ 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: Δbalance #{helpers.number_to_currency(result.balance_delta)}, Δunsettled #{helpers.number_to_currency(result.unsettled_delta)}." + 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 @@ -37,63 +40,85 @@ result = Ledgers::QboBoundMigrationCheck.call(resource) div do - 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)}" + 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 + end + end end if result.ready? div do - para "Net-zero change — safe to migrate." - button_to "Migrate to QBO-bound", migrate_to_qbo_bound_admin_ledger_path(resource), method: :post, data: { confirm: "Flip this ledger to qbo_bound?" } + 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) - div do - h4 "What's driving the Δ" - para "Under qbo_bound these items behave differently. The net (sum of audit-only deductions removed, minus paid hosts dropping out) is Δbalance." + 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)}" + 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 - 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)}" + 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 - 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" + 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 - li "… and #{result.dropped_paid_hosts.size - 15} more" if result.dropped_paid_hosts.size > 15 end end end - if result.balance_delta > 0 && result.open_qbo_bills.any? + if result.open_qbo_bills.any? div do - h4 "Remedy options" - para "Δ is positive, so qbo_bound would show MORE balance than legacy. Marking any of these open QBO bills as Paid in QBO will drop the corresponding host from qbo_bound balance, reducing Δ by its amount." + 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 @@ -104,11 +129,6 @@ li "… and #{result.open_qbo_bills.size - 20} more" if result.open_qbo_bills.size > 20 end end - elsif result.balance_delta < 0 - div do - h4 "Note" - para "Δ is negative, so qbo_bound would show LESS balance than legacy. There are paid QBO bills here that aren't matched by audit-only deductions — either accept the lower balance and migrate, or add a corrective adjustment in legacy first." - end end div do diff --git a/app/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb index 543000d7..e25ffa0e 100644 --- a/app/services/ledgers/qbo_bound_migration_check.rb +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -1,18 +1,15 @@ module Ledgers - # Computes whether a legacy Ledger can flip to qbo_bound with zero - # change to balance or unsettled. Returns a Result struct exposing - # the deltas and the per-item drivers — items whose treatment differs - # between the two rules. + # Decides whether a legacy Ledger can flip to qbo_bound. # - # Driver categories (each contributes to Δbalance): - # - removed_neg_cas — negative CAs ignored under qbo_bound (Δ += |amount|) - # - removed_dias — DIAs ignored under qbo_bound (Δ += amount) - # - dropped_paid_hosts — payable hosts whose QBO bill is Paid (Δ −= amount) + # 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. # - # open_qbo_bills are unpaid bills on the ledger. They're informational — - # they contribute equally to balance under both rules (no Δ), but marking - # one Paid in QBO turns it into a dropped_paid_host on the next check, - # which is a remedy when Δ > 0. + # 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 @@ -20,6 +17,8 @@ class QboBoundMigrationCheck :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, @@ -39,6 +38,13 @@ def self.call(ledger) 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 + Result.new( current_balance: legacy_b.round(2), current_unsettled: legacy_u.round(2), @@ -46,7 +52,12 @@ def self.call(ledger) proposed_unsettled: new_u.round(2), balance_delta: db, unsettled_delta: du, - ready?: db.abs < TOLERANCE && du.abs < TOLERANCE, + 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?: !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), @@ -54,8 +65,8 @@ def self.call(ledger) ) end - # Payable hosts whose QBO bill is Paid. They drop from qbo_bound balance, - # decreasing Δ by their amount. + # 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) @@ -69,9 +80,8 @@ def self.collect_dropped_paid_hosts(items) end end - # Unpaid QBO bills on the ledger. They don't cause Δ — they contribute - # equally under both rules — but they're surfaced so the controller can - # mark them Paid to remedy a positive Δ. + # 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) diff --git a/test/integration/ledger_migration_test.rb b/test/integration/ledger_migration_test.rb index d58bf56d..1ae8bd74 100644 --- a/test/integration/ledger_migration_test.rb +++ b/test/integration/ledger_migration_test.rb @@ -3,10 +3,13 @@ class LedgerMigrationTest < ActionDispatch::IntegrationTest setup do Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "MigPanel-#{SecureRandom.hex(2)}") + @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", @@ -25,11 +28,13 @@ class LedgerMigrationTest < ActionDispatch::IntegrationTest assert @ledger.qbo_bound? end - test "Migrate refuses to flip a ledger with non-zero drift" do + 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) diff --git a/test/lib/tasks/ledgers_rake_test.rb b/test/lib/tasks/ledgers_rake_test.rb index d9b4939f..c45360c3 100644 --- a/test/lib/tasks/ledgers_rake_test.rb +++ b/test/lib/tasks/ledgers_rake_test.rb @@ -7,10 +7,13 @@ class LedgersRakeTest < ActiveSupport::TestCase Rake::Task["ledgers:migrate_qbo_bound_zero_drift"].reenable Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "RakeMig-#{SecureRandom.hex(2)}") + @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 @@ -23,7 +26,10 @@ class LedgersRakeTest < ActiveSupport::TestCase @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, ready?: false, removed_neg_cas: [], removed_dias: [], dropped_paid_hosts: [], open_qbo_bills: [], + 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) diff --git a/test/services/ledgers/qbo_bound_migration_check_test.rb b/test/services/ledgers/qbo_bound_migration_check_test.rb index 553c4c7e..d8aad979 100644 --- a/test/services/ledgers/qbo_bound_migration_check_test.rb +++ b/test/services/ledgers/qbo_bound_migration_check_test.rb @@ -3,17 +3,22 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase setup do Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "MigCheck-#{SecureRandom.hex(2)}") + @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 is ready (Δ = 0 trivially)" do + test "empty legacy ledger with QBO vendor at $0 is ready" do result = Ledgers::QboBoundMigrationCheck.call(@ledger) assert result.ready? - assert_in_delta 0, result.balance_delta, 0.001 - assert_in_delta 0, result.unsettled_delta, 0.001 + 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 @@ -26,9 +31,29 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase 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 "ledger is blocked when ledger.balance under qbo_bound != legacy" do + test "blocked when Stacks open total does not match QBO vendor balance" do + @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(-999, result.qbo_diff, 0.01) + end + + test "blocked when contributor has no QBO vendor mapping" do + ContributorQboVendor.where(contributor: @contributor).destroy_all + result = Ledgers::QboBoundMigrationCheck.call(@ledger) + refute result.ready? + assert result.qbo_vendor_missing? + 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) @@ -43,7 +68,6 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase @ledger.stubs(:qbo_bound_visible_items).returns([cp]) result = Ledgers::QboBoundMigrationCheck.call(@ledger) - assert_in_delta -50, result.balance_delta, 0.01 - refute result.ready? + assert_in_delta(-50, result.balance_delta, 0.01) end end From d801db5d57171e5106705a17487b0fa1522ed4d3 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 19:26:34 -0400 Subject: [PATCH 59/67] Reimbursement Deny: leave QBO Bill in place (don't destroy) --- app/admin/contributors.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index f05f3acc..03d9f339 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -120,9 +120,6 @@ def manual_deel_invoice_visible?(contributor) return unless current_admin_user.is_admin? if r.accepted? - # Un-accepting: detach + destroy the QBO bill so the books stay aligned. - # No-op if the bill was never synced. - r.detach_and_destroy_qbo_bill if r.qbo_bill_id.present? r.update!( accepted_by: nil, accepted_at: nil From 502e27506373449e3321ff93660b16bb5ede90be Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 19:28:23 -0400 Subject: [PATCH 60/67] Reimbursement acceptance: sync QBO bill on either toggle --- app/admin/contributors.rb | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index 03d9f339..dd46ae24 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -129,14 +129,15 @@ def manual_deel_invoice_visible?(contributor) accepted_by: current_admin_user, accepted_at: DateTime.now ) - # Push to QBO so the reimbursement is payable through the same flow as - # every other host. Best-effort: log + continue if the QBO push fails - # (admin can retry via the Payable QBO Bills page). - begin - r.sync_qbo_bill! - rescue => e - Rails.logger.error("[reimbursement_accept] sync_qbo_bill! failed for ##{r.id}: #{e.class}: #{e.message}") - end + end + + # 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 via the Payable QBO Bills page. + begin + r.sync_qbo_bill! + rescue => e + Rails.logger.error("[reimbursement_accept] sync_qbo_bill! failed for ##{r.id}: #{e.class}: #{e.message}") end return redirect_to( From 16780ae5903c2f02c0a3a1ee019ccf19ff0c1201 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 19:42:17 -0400 Subject: [PATCH 61/67] Gate: auto-flip trivially-empty ledgers without QBO check --- app/services/ledgers/qbo_bound_migration_check.rb | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb index e25ffa0e..7cb5bd67 100644 --- a/app/services/ledgers/qbo_bound_migration_check.rb +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -45,6 +45,13 @@ def self.call(ledger) 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), @@ -57,7 +64,7 @@ def self.call(ledger) qbo_diff: qbo_diff, qbo_match?: qbo_match, qbo_vendor_missing?: vendor.nil?, - ready?: !vendor.nil? && qbo_match, + 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), From 6fb37a1a0287a507bd9a7b9bdfbe04ecbf433200 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 19:46:25 -0400 Subject: [PATCH 62/67] Migrate panel: 'Refresh QBO vendor data' action for stale-cache cases --- app/admin/ledgers.rb | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/admin/ledgers.rb b/app/admin/ledgers.rb index 6ed77039..ca03aee6 100644 --- a/app/admin/ledgers.rb +++ b/app/admin/ledgers.rb @@ -8,6 +8,19 @@ 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." @@ -56,6 +69,15 @@ 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 From 1be755a0ed0fd09fa7dc1279bfdf7b637943e407 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 19:48:34 -0400 Subject: [PATCH 63/67] Restore schema.rb composite-FK comments + update tests for trivial-empty bypass --- db/schema.rb | 142 ++++++++++++++---- .../ledgers/qbo_bound_migration_check_test.rb | 31 +++- 2 files changed, 141 insertions(+), 32 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index d99135af..5859f0fc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -891,23 +891,6 @@ t.index ["enterprise_id"], name: "index_qbo_accounts_on_enterprise_id" end - create_table "qbo_bill_account_mappings", force: :cascade do |t| - t.bigint "enterprise_id", null: false - t.string "line_item_key", null: false - t.bigint "project_tracker_id" - t.bigint "contributor_id" - t.string "qbo_chart_account_qbo_id", null: false - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false - t.index ["contributor_id"], name: "index_qbo_bill_account_mappings_on_contributor_id" - t.index ["enterprise_id", "line_item_key", "contributor_id"], name: "idx_qbo_bill_acct_mappings_contributor", unique: true, where: "(contributor_id IS NOT NULL)" - t.index ["enterprise_id", "line_item_key", "project_tracker_id"], name: "idx_qbo_bill_acct_mappings_tracker", unique: true, where: "(project_tracker_id IS NOT NULL)" - t.index ["enterprise_id", "line_item_key"], name: "idx_qbo_bill_acct_mappings_default", unique: true, where: "((project_tracker_id IS NULL) AND (contributor_id IS NULL))" - t.index ["enterprise_id"], name: "index_qbo_bill_account_mappings_on_enterprise_id" - t.index ["project_tracker_id"], name: "index_qbo_bill_account_mappings_on_project_tracker_id" - t.check_constraint "(project_tracker_id IS NULL) OR (contributor_id IS NULL)", name: "qbo_bill_acct_mappings_one_subject" - end - create_table "qbo_bills", force: :cascade do |t| t.string "qbo_id", null: false t.jsonb "data" @@ -919,19 +902,6 @@ t.index ["qbo_vendor_id"], name: "index_qbo_bills_on_qbo_vendor_id" end - create_table "qbo_chart_accounts", force: :cascade do |t| - t.string "qbo_id", null: false - t.bigint "qbo_account_id", null: false - t.string "name", null: false - t.string "acct_num" - t.string "classification" - t.string "account_type" - t.boolean "active", default: true, null: false - t.jsonb "data" - t.index ["qbo_account_id", "qbo_id"], name: "index_qbo_chart_accounts_on_qbo_account_and_qbo_id", unique: true - t.index ["qbo_account_id"], name: "index_qbo_chart_accounts_on_qbo_account_id" - end - create_table "qbo_invoices", force: :cascade do |t| t.string "qbo_id", null: false t.jsonb "data" @@ -1227,4 +1197,116 @@ add_foreign_key "account_lead_periods", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "project_trackers" add_foreign_key "adhoc_invoice_trackers", "qbo_accounts" + # Composite FK fk_adhoc_invoice_trackers_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "admin_user_salary_windows", "admin_users" + add_foreign_key "associates_award_agreements", "admin_users" + add_foreign_key "commissions", "contributors" + add_foreign_key "commissions", "project_trackers" + add_foreign_key "contributor_adjustments", "ledgers" + add_foreign_key "contributor_adjustments", "qbo_accounts" + # Composite FK fk_contributor_adjustments_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "contributor_payouts", "admin_users", column: "created_by_id" + add_foreign_key "contributor_payouts", "invoice_trackers" + add_foreign_key "contributor_payouts", "ledgers" + add_foreign_key "contributor_qbo_vendors", "contributors" + add_foreign_key "contributor_qbo_vendors", "qbo_accounts" + add_foreign_key "contributor_qbo_vendors", "qbo_vendors" + add_foreign_key "deel_invoice_adjustments", "deel_contracts", primary_key: "deel_id" + add_foreign_key "deel_invoice_adjustments", "ledgers" + add_foreign_key "enterprise_admins", "admin_users" + add_foreign_key "enterprise_admins", "enterprises" + add_foreign_key "enterprise_forecast_clients", "enterprises" + add_foreign_key "enterprise_forecast_clients", "forecast_clients", primary_key: "forecast_id" + add_foreign_key "finalizations", "reviews" + add_foreign_key "full_time_periods", "admin_users" + add_foreign_key "gifted_profit_shares", "admin_users" + add_foreign_key "invoice_trackers", "admin_users" + add_foreign_key "invoice_trackers", "invoice_passes" + add_foreign_key "invoice_trackers", "qbo_accounts" + # Composite FK fk_invoice_trackers_qbo_invoice managed by migration (not expressible in schema.rb) + add_foreign_key "ledgers", "contributors" + add_foreign_key "ledgers", "enterprises" + add_foreign_key "mailing_list_subscribers", "mailing_lists" + add_foreign_key "mailing_lists", "studios" + add_foreign_key "misc_payments", "contributors" + add_foreign_key "okr_period_studios", "okr_periods" + add_foreign_key "okr_period_studios", "studios" + add_foreign_key "okr_periods", "okrs" + add_foreign_key "old_deal_creative_lead_periods", "admin_users" + add_foreign_key "old_deal_creative_lead_periods", "project_trackers" + add_foreign_key "old_deal_creative_lead_periods", "studios" + add_foreign_key "old_deal_project_lead_periods", "admin_users" + add_foreign_key "old_deal_project_lead_periods", "project_trackers" + add_foreign_key "old_deal_project_lead_periods", "studios" + add_foreign_key "old_deal_project_safety_representative_periods", "admin_users" + add_foreign_key "old_deal_project_safety_representative_periods", "project_trackers" + add_foreign_key "old_deal_project_safety_representative_periods", "studios" + add_foreign_key "old_deal_technical_lead_periods", "admin_users" + add_foreign_key "old_deal_technical_lead_periods", "project_trackers" + add_foreign_key "old_deal_technical_lead_periods", "studios" + add_foreign_key "pay_cycles", "admin_users", column: "approved_by_id" + add_foreign_key "pay_cycles", "admin_users", column: "created_by_id" + add_foreign_key "pay_cycles", "enterprises" + add_foreign_key "pay_stubs", "admin_users", column: "accepted_by_id" + add_foreign_key "pay_stubs", "ledgers" + add_foreign_key "pay_stubs", "pay_cycles" + add_foreign_key "peer_reviews", "admin_users" + add_foreign_key "peer_reviews", "reviews" + add_foreign_key "periodic_reports", "notifications" + add_foreign_key "pre_profit_share_purchases", "admin_users" + add_foreign_key "profit_share_payments", "admin_users" + add_foreign_key "profit_share_payments", "profit_share_passes" + add_foreign_key "profit_shares", "ledgers" + add_foreign_key "profit_shares", "periodic_reports" + add_foreign_key "project_capsules", "project_trackers" + add_foreign_key "project_lead_periods", "admin_users" + add_foreign_key "project_lead_periods", "project_trackers" + add_foreign_key "project_satisfaction_survey_free_text_question_responses", "project_satisfaction_survey_free_text_questions" + add_foreign_key "project_satisfaction_survey_free_text_question_responses", "project_satisfaction_survey_responses" + add_foreign_key "project_satisfaction_survey_free_text_questions", "project_satisfaction_surveys" + add_foreign_key "project_satisfaction_survey_question_responses", "project_satisfaction_survey_questions" + add_foreign_key "project_satisfaction_survey_question_responses", "project_satisfaction_survey_responses" + add_foreign_key "project_satisfaction_survey_questions", "project_satisfaction_surveys" + add_foreign_key "project_satisfaction_survey_responders", "admin_users" + add_foreign_key "project_satisfaction_survey_responders", "project_satisfaction_surveys" + add_foreign_key "project_satisfaction_survey_responses", "project_satisfaction_surveys" + add_foreign_key "project_satisfaction_surveys", "project_capsules" + add_foreign_key "project_tracker_forecast_projects", "project_trackers" + add_foreign_key "project_tracker_forecast_to_runn_sync_tasks", "notifications" + add_foreign_key "project_tracker_forecast_to_runn_sync_tasks", "project_trackers" + add_foreign_key "project_tracker_links", "project_trackers" + add_foreign_key "project_trackers", "runn_projects", primary_key: "runn_id" + add_foreign_key "qbo_accounts", "enterprises" + add_foreign_key "qbo_bills", "qbo_accounts" + add_foreign_key "qbo_invoices", "qbo_accounts" + add_foreign_key "qbo_profit_and_loss_reports", "qbo_accounts" + add_foreign_key "qbo_tokens", "qbo_accounts" + add_foreign_key "qbo_vendors", "qbo_accounts" + add_foreign_key "recurring_ledger_adjustments", "ledgers" + add_foreign_key "reimbursements", "admin_users", column: "accepted_by_id" + add_foreign_key "reimbursements", "ledgers" + add_foreign_key "review_trees", "reviews" + add_foreign_key "review_trees", "trees" + add_foreign_key "reviews", "admin_users" + add_foreign_key "score_trees", "trees" + add_foreign_key "score_trees", "workspaces" + add_foreign_key "scores", "score_trees" + add_foreign_key "scores", "traits" + add_foreign_key "studio_memberships", "admin_users" + add_foreign_key "studio_memberships", "studios" + add_foreign_key "survey_free_text_question_responses", "survey_free_text_questions" + add_foreign_key "survey_free_text_question_responses", "survey_responses" + add_foreign_key "survey_free_text_questions", "surveys" + add_foreign_key "survey_question_responses", "survey_questions" + add_foreign_key "survey_question_responses", "survey_responses" + add_foreign_key "survey_questions", "surveys" + add_foreign_key "survey_responders", "admin_users" + add_foreign_key "survey_responders", "surveys" + add_foreign_key "survey_responses", "surveys" + add_foreign_key "survey_studios", "studios" + add_foreign_key "survey_studios", "surveys" + add_foreign_key "system_tasks", "notifications" + add_foreign_key "traits", "trees" + add_foreign_key "trueups", "invoice_passes" + add_foreign_key "trueups", "ledgers" end diff --git a/test/services/ledgers/qbo_bound_migration_check_test.rb b/test/services/ledgers/qbo_bound_migration_check_test.rb index d8aad979..0267eb2c 100644 --- a/test/services/ledgers/qbo_bound_migration_check_test.rb +++ b/test/services/ledgers/qbo_bound_migration_check_test.rb @@ -39,20 +39,47 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase 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(:in_balance_under_qbo_bound?).returns(true) + payable_payout.stubs(:signed_amount).returns(100) + 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]) + @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(-999, result.qbo_diff, 0.01) + assert_in_delta(-899, result.qbo_diff, 0.01) end - test "blocked when contributor has no QBO vendor mapping" do + 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(:in_balance_under_qbo_bound?).returns(true) + payable_payout.stubs(:signed_amount).returns(100) + 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]) + 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) From 9002f087f7a6b677ac3d57c063ec932493ddf82b Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 19:56:57 -0400 Subject: [PATCH 64/67] qbo_bound: use QBO bill's remaining balance for partial payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QBO bill that was paid down to a small unpaid remainder (e.g., $1778.40 bill paid except for $0.40 — Matthew Heigl's case) was still contributing its FULL amount to Stacks balance under qbo_bound, because the rule was 'in balance iff qbo_bill not fully paid' AND signed_amount returned the host's full amount. The result: Stacks said we owed $1778.40 when QBO showed $0.40. Fix: introduce QboBill#remaining_balance (= data["balance"].to_f) and SyncsAsQboBill#qbo_bound_balance_amount that returns the bill's remaining balance when one exists, falling back to the host amount otherwise. Ledger#balance and Ledgers::QboBoundMigrationCheck both sum this new amount in qbo_bound mode so the total matches QBO vendor AP exactly. Effect on dev data: 7 more ledgers auto-flipped via the rake — every one of those had at least one partial-payment bill that's now reflected at its true remaining balance instead of its original amount. --- app/models/concerns/syncs_as_qbo_bill.rb | 10 ++++++++++ app/models/ledger.rb | 4 +++- app/models/qbo_bill.rb | 7 +++++++ app/services/ledgers/qbo_bound_migration_check.rb | 4 +++- 4 files changed, 23 insertions(+), 2 deletions(-) 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/ledger.rb b/app/models/ledger.rb index f53b8d69..a16136ef 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -92,7 +92,9 @@ def balance if legacy? visible_items.select(&:payable?).sum(&:signed_amount) elsif qbo_bound? - qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum do |li| + li.respond_to?(:qbo_bound_balance_amount) ? li.qbo_bound_balance_amount : li.signed_amount + end else raise "Unknown ledger mode: #{mode.inspect}" end diff --git a/app/models/qbo_bill.rb b/app/models/qbo_bill.rb index bae7fa74..5fec9f79 100644 --- a/app/models/qbo_bill.rb +++ b/app/models/qbo_bill.rb @@ -28,6 +28,13 @@ 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/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb index 7cb5bd67..877b58be 100644 --- a/app/services/ledgers/qbo_bound_migration_check.rb +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -32,7 +32,9 @@ def self.call(ledger) 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_visible.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f + new_b = qbb_visible.select(&:in_balance_under_qbo_bound?).sum do |li| + (li.respond_to?(:qbo_bound_balance_amount) ? li.qbo_bound_balance_amount : li.signed_amount).to_f + end new_u = qbb_visible.reject(&:payable?).sum(&:signed_amount).to_f db = (new_b - legacy_b).round(2) From b507de70c8794b9e0d1d275f34e099a2952cb2c6 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 20:04:29 -0400 Subject: [PATCH 65/67] qbo_bound: drop paid bills from unsettled too, use remaining_balance everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hugh's ledger 2 had $675 of paid CPs (#1057 $250, #1698 $250, #2245 $175) sitting in unsettled because they were not-payable yet — the unsettled rule only checked payable?, not bill paid status. A paid bill should drop from BOTH balance and unsettled, regardless of payable? state. Refactor: • New Ledger#qbo_bound_open_items: audit-only filter + drop fully-paid bills. Single source of truth for "what's still on the books in qbo_bound mode." • Ledger#qbo_bound_contribution(li): centralises the qbo_bound_balance_amount fallback so balance and unsettled both use the QBO bill remaining for partial payments. • Ledger#balance and #unsettled (qbo_bound branch) split qbo_bound_open_items by payable?, summing qbo_bound_contribution. • Same change in Ledgers::QboBoundMigrationCheck. Effect on dev data: Hugh's ledger now matches QBO at $34,864.37 exactly, freeing him for migration. 1 ledger auto-flipped via rake (Hugh). --- app/models/ledger.rb | 26 ++++++++++++++++--- .../ledgers/qbo_bound_migration_check.rb | 8 +++--- test/models/ledger_test.rb | 1 + .../ledgers/qbo_bound_migration_check_test.rb | 4 +++ 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/app/models/ledger.rb b/app/models/ledger.rb index a16136ef..919d09df 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -92,9 +92,7 @@ def balance if legacy? visible_items.select(&:payable?).sum(&:signed_amount) elsif qbo_bound? - qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum do |li| - li.respond_to?(:qbo_bound_balance_amount) ? li.qbo_bound_balance_amount : li.signed_amount - end + qbo_bound_open_items.select(&:payable?).sum { |li| qbo_bound_contribution(li) } else raise "Unknown ledger mode: #{mode.inspect}" end @@ -104,7 +102,7 @@ def unsettled if legacy? visible_items.reject(&:payable?).sum(&:signed_amount) elsif qbo_bound? - qbo_bound_visible_items.reject(&:payable?).sum(&:signed_amount) + qbo_bound_open_items.reject(&:payable?).sum { |li| qbo_bound_contribution(li) } else raise "Unknown ledger mode: #{mode.inspect}" end @@ -176,6 +174,26 @@ 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.qbo_bill rescue nil)&.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 # Includes soft-deleted rows — used by items_grouped_by_month for display. diff --git a/app/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb index 877b58be..dc0ca0bb 100644 --- a/app/services/ledgers/qbo_bound_migration_check.rb +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -28,14 +28,12 @@ class QboBoundMigrationCheck def self.call(ledger) legacy_visible = ledger.send(:visible_items) - qbb_visible = ledger.send(:qbo_bound_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_visible.select(&:in_balance_under_qbo_bound?).sum do |li| - (li.respond_to?(:qbo_bound_balance_amount) ? li.qbo_bound_balance_amount : li.signed_amount).to_f - end - new_u = qbb_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) diff --git a/test/models/ledger_test.rb b/test/models/ledger_test.rb index 2cec9a80..1d823c1c 100644 --- a/test/models/ledger_test.rb +++ b/test/models/ledger_test.rb @@ -280,6 +280,7 @@ class LedgerBalanceUnderQboBoundTest < ActiveSupport::TestCase pending.stubs(:payable?).returns(false) pending.stubs(:in_balance_under_qbo_bound?).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) diff --git a/test/services/ledgers/qbo_bound_migration_check_test.rb b/test/services/ledgers/qbo_bound_migration_check_test.rb index 0267eb2c..9420cece 100644 --- a/test/services/ledgers/qbo_bound_migration_check_test.rb +++ b/test/services/ledgers/qbo_bound_migration_check_test.rb @@ -44,11 +44,13 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase payable_payout.stubs(:payable?).returns(true) payable_payout.stubs(:in_balance_under_qbo_bound?).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) @@ -62,11 +64,13 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase payable_payout.stubs(:payable?).returns(true) payable_payout.stubs(:in_balance_under_qbo_bound?).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) From 8a973383026eb46eec87fb8cdf60b7150252528c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 20:16:42 -0400 Subject: [PATCH 66/67] Revert custom withdraw_via_deel UI; restore 'New Deel Withdrawal' action item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original New Deel Withdrawal entry point linked to the existing admin/deel_invoice_adjustments#new form, which already had a working create/sync flow. The prior PR retired the entry point in favor of LedgerWithdrawalRequest; when this branch deleted LedgerWithdrawalRequest, the right move was to restore the original action_item, not build a new inline form on the contributor show page. - Restored action_item :new_deel_withdrawal on contributors admin - Removed the inline form block from contributors/_show.html.erb - Removed the withdraw_via_deel member_action - Deleted DeelInvoiceAdjustments::CreateForLedger service + tests (redundant — the existing controller handles creation directly) --- app/admin/contributors.rb | 40 +++++-------- .../create_for_ledger.rb | 52 ----------------- app/views/admin/contributors/_show.html.erb | 18 ------ .../contributor_withdraw_via_deel_test.rb | 55 ------------------ .../create_for_ledger_test.rb | 57 ------------------- 5 files changed, 14 insertions(+), 208 deletions(-) delete mode 100644 app/services/deel_invoice_adjustments/create_for_ledger.rb delete mode 100644 test/integration/contributor_withdraw_via_deel_test.rb delete mode 100644 test/services/deel_invoice_adjustments/create_for_ledger_test.rb diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index dd46ae24..d1545dd2 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -48,11 +48,20 @@ 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 - # "New Deel Withdrawal" was retired: contributors now submit a Ledger - # Withdrawal Request and admins process it via Deel from that request's - # show page (or via QBO Bill Pay). The legacy - # admin/contributors/:id/deel_invoice_adjustments routes remain so - # historical rows are viewable, but no entry point is exposed here. + action_item :new_deel_withdrawal, only: :show do + selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger]) + if selected_ledger&.deel_enabled? + link_to "New Deel Withdrawal", new_admin_contributor_deel_invoice_adjustment_path(resource, ledger_id: 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;" + end + end action_item :new_contributor_adjustment, only: :show, if: proc { current_admin_user.is_admin? } do selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger]) @@ -74,27 +83,6 @@ def manual_deel_invoice_visible?(contributor) end end - member_action :withdraw_via_deel, method: :post do - ledger = Ledger.find(params.require(:ledger_id)) - unless ledger.deel_enabled? - redirect_back fallback_location: admin_contributor_path(resource), alert: "Deel is not enabled for this ledger." - return - end - - DeelInvoiceAdjustments::CreateForLedger.call( - ledger: ledger, - amount: params.require(:amount), - contract_id: params.require(:contract_id), - description: params[:description].to_s, - date_submitted: params[:date_submitted].presence || Date.current, - initiated_by: current_admin_user, - ) - - redirect_back fallback_location: admin_contributor_path(resource), notice: "Withdrew via Deel." - rescue DeelInvoiceAdjustments::CreateForLedger::Error => e - redirect_back fallback_location: admin_contributor_path(resource), alert: e.message - end - member_action :toggle_contributor_payout_acceptance, method: :post do cp = ContributorPayout.find(params[:contributor_payout_id]) return unless cp.contributor.forecast_person.try(:admin_user) == current_admin_user || current_admin_user.is_admin? diff --git a/app/services/deel_invoice_adjustments/create_for_ledger.rb b/app/services/deel_invoice_adjustments/create_for_ledger.rb deleted file mode 100644 index 1733a4b1..00000000 --- a/app/services/deel_invoice_adjustments/create_for_ledger.rb +++ /dev/null @@ -1,52 +0,0 @@ -module DeelInvoiceAdjustments - # Creates a DeelInvoiceAdjustment in Deel for a given ledger + contract, - # then persists the response as a Stacks-side DIA. - class CreateForLedger - class Error < StandardError; end - - def self.call(ledger:, amount:, contract_id:, description:, date_submitted:, initiated_by:) - new(ledger: ledger, amount: amount, contract_id: contract_id, - description: description, date_submitted: date_submitted, initiated_by: initiated_by).call - end - - def initialize(ledger:, amount:, contract_id:, description:, date_submitted:, initiated_by:) - @ledger = ledger - @amount = BigDecimal(amount.to_s) - @contract_id = contract_id.to_s - @description = description.to_s - @date_submitted = date_submitted - @initiated_by = initiated_by - end - - def call - parsed = call_deel_api - raise Error, "Deel did not return an adjustment id" if parsed.dig("data", "id").blank? - - DeelInvoiceAdjustment.create_from_deel_response!( - ledger: @ledger, - deel_contract_id: @contract_id, - amount: @amount, - description: @description, - date_submitted: @date_submitted, - parsed_response: parsed, - ) - rescue ActiveRecord::RecordInvalid => e - raise Error, "Could not persist DIA: #{e.message}" - end - - private - - # Calls the Deel invoice-adjustments API directly, matching the payload - # structure from Contributors::SubmitDeelInvoiceAdjustment#call. - def call_deel_api - Stacks::Deel.create_invoice_adjustment!( - amount: @amount, - contract_id: @contract_id, - description: @description, - date_submitted: @date_submitted, - ) - rescue Stacks::Deel::ApiError => e - raise Error, e.message - end - end -end diff --git a/app/views/admin/contributors/_show.html.erb b/app/views/admin/contributors/_show.html.erb index 658ed1e8..5f7e0bd2 100644 --- a/app/views/admin/contributors/_show.html.erb +++ b/app/views/admin/contributors/_show.html.erb @@ -297,24 +297,6 @@ <% end %> -<% if view_mode == :ledger && current_ledger&.deel_enabled? %> -
-
-
-

Withdraw via Deel

-
-
- <%= form_tag(withdraw_via_deel_admin_contributor_path(contributor), method: :post, style: "display: inline") do %> - <%= hidden_field_tag :ledger_id, current_ledger.id %> - <%= number_field_tag :amount, current_ledger.balance.to_f, step: "0.01", min: "0.01", max: current_ledger.balance.to_f %> - <%= select_tag :contract_id, options_for_select(contributor.deel_person&.deel_contracts&.map { |c| [c.deel_contract_type_label, c.deel_id] } || []) %> - <%= submit_tag "Withdraw via Deel" %> - <% end %> -
-
-
-<% end %> - <%# Per-contributor recurring ledger adjustments. Filters by the active ledger when one is selected; shows all ledgers' rows on the All view. Renders a compact table with links to the top-level admin for editing. %> diff --git a/test/integration/contributor_withdraw_via_deel_test.rb b/test/integration/contributor_withdraw_via_deel_test.rb deleted file mode 100644 index 25957c33..00000000 --- a/test/integration/contributor_withdraw_via_deel_test.rb +++ /dev/null @@ -1,55 +0,0 @@ -require "test_helper" - -class ContributorWithdrawViaDeelTest < ActionDispatch::IntegrationTest - setup do - Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "WVD-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "wvd#{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[deel]) - - dp_id = "dp#{SecureRandom.hex(2)}" - DeelPerson.create!(deel_id: dp_id, data: {}) - @contract = DeelContract.create!(deel_id: "dc#{SecureRandom.hex(2)}", deel_person_id: dp_id, data: { "type" => "ongoing_time_based" }) - - @admin = AdminUser.create!(email: "wvd#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", roles: ["admin"]) - sign_in @admin - end - - test "POST withdraw_via_deel calls CreateForLedger on a deel-enabled ledger" do - DeelInvoiceAdjustments::CreateForLedger.expects(:call).with( - ledger: @ledger, - amount: "100", - contract_id: @contract.deel_id, - description: "", - date_submitted: anything, - initiated_by: instance_of(AdminUser), - ).returns(DeelInvoiceAdjustment.new) - - post withdraw_via_deel_admin_contributor_path(@contributor), params: { - ledger_id: @ledger.id, - amount: "100", - contract_id: @contract.deel_id, - } - assert_response :redirect - end - - test "POST withdraw_via_deel refuses on a non-deel ledger" do - @ledger.update!(payment_methods: %w[qbo]) - DeelInvoiceAdjustments::CreateForLedger.expects(:call).never - - post withdraw_via_deel_admin_contributor_path(@contributor), params: { - ledger_id: @ledger.id, - amount: "100", - contract_id: @contract.deel_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/services/deel_invoice_adjustments/create_for_ledger_test.rb b/test/services/deel_invoice_adjustments/create_for_ledger_test.rb deleted file mode 100644 index f09e11f3..00000000 --- a/test/services/deel_invoice_adjustments/create_for_ledger_test.rb +++ /dev/null @@ -1,57 +0,0 @@ -require "test_helper" - -class DeelInvoiceAdjustments::CreateForLedgerTest < ActiveSupport::TestCase - setup do - Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "DelegLed-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "del#{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[deel]) - - dp_id = "dp#{SecureRandom.hex(2)}" - DeelPerson.create!(deel_id: dp_id, data: {}) - @contract = DeelContract.create!(deel_id: "dc#{SecureRandom.hex(2)}", deel_person_id: dp_id, data: { "type" => "ongoing_time_based" }) - - @admin = AdminUser.create!(email: "dca#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", roles: ["admin"]) - end - - test "creates a DIA when Deel API call succeeds" do - fake_response = { "data" => { "id" => "adj-42", "status" => "pending" } } - DeelInvoiceAdjustment.expects(:create_from_deel_response!).with( - ledger: @ledger, - deel_contract_id: @contract.deel_id, - amount: 100, - description: "test", - date_submitted: Date.current, - parsed_response: fake_response, - ).returns(DeelInvoiceAdjustment.new) - - DeelInvoiceAdjustments::CreateForLedger.any_instance.stubs(:call_deel_api).returns(fake_response) - - result = DeelInvoiceAdjustments::CreateForLedger.call( - ledger: @ledger, - amount: 100, - contract_id: @contract.deel_id, - description: "test", - date_submitted: Date.current, - initiated_by: @admin, - ) - assert result.is_a?(DeelInvoiceAdjustment) - end - - test "raises CreateForLedger::Error when Deel API returns no adjustment id" do - DeelInvoiceAdjustments::CreateForLedger.any_instance.stubs(:call_deel_api).returns({ "data" => {} }) - - assert_raises(DeelInvoiceAdjustments::CreateForLedger::Error) do - DeelInvoiceAdjustments::CreateForLedger.call( - ledger: @ledger, - amount: 100, - contract_id: @contract.deel_id, - description: "test", - date_submitted: Date.current, - initiated_by: @admin, - ) - end - end -end From b427f338c07855d73596821e769900820502081b Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 13 Jun 2026 20:35:04 -0400 Subject: [PATCH 67/67] PR review cleanup: delete dead predicate, default payment_methods on new ledgers, validation, tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from deep PR review applied: Critical - Delete dead in_balance_under_qbo_bound? predicates across 7 host models (ContributorPayout, ContributorAdjustment, ProfitShare, PayStub, Trueup, Reimbursement, DeelInvoiceAdjustment). After the qbo_bound_open_items + qbo_bound_contribution refactor, nothing in production called these. Also drop the obsolete HostInBalanceUnderQboBoundTest class and the vestigial .stubs(:in_balance_under_qbo_bound?) lines in surviving tests. - Fix "New Deel Withdrawal" action item param: passed ledger_id but the deel_invoice_adjustments admin reads :ledger. Was silently routing to the wrong ledger. - Default payment_methods on new ledgers. Extracted Ledger.payment_methods_for(contributor) (non-US Deel → ["deel"], else ["qbo"]) and use it from: the schema backfill, all three Ledger.ensure_* bulk paths, and a new before_validation :default_payment_methods, on: :create callback. Without this, every contributor created after deploy ends up with payment_methods: [] and broken UI routing. Important - Add Reimbursement to Money::PayableQboBills::HOST_KLASSES and to the LegacyLedgersPendingQboMigration discovery's PAYABLE_TABLES. It was syncing as a QBO bill but invisible to both the payable page and the migration task. - Add :legacy_ledger_needs_qbo_migration to StacksTask::HUMANIZED_TYPES. - Delete drifted design + plan docs (the shipped code disagrees with them in 5+ load-bearing places; comments at the relevant Ledger and service callsites are the up-to-date documentation now). - Revert the trailing-whitespace-only change to active_admin.scss. Minor - Replace `(li.qbo_bill rescue nil)` with `li.try(:qbo_bill)` — narrower, doesn't swallow real QBO API errors. - DRY Ledger#balance / #unsettled via a shared sum_for_bucket(payable:) helper so the two branches can't drift on future changes. - Add Ledger validation rejecting unknown payment_methods values. - Add tests for partial-payment qbo_bound_balance_amount, refresh_qbo_vendor member_action, default_payment_methods callback, and Ledger.payment_methods_for inference across the three contributor shapes (non-US Deel, US Deel, no Deel attachment). --- app/admin/contributors.rb | 2 +- app/assets/stylesheets/active_admin.scss | 1 - app/models/contributor_adjustment.rb | 4 - app/models/contributor_payout.rb | 6 - app/models/deel_invoice_adjustment.rb | 5 - app/models/ledger.rb | 76 +- app/models/pay_stub.rb | 4 - app/models/profit_share.rb | 4 - app/models/reimbursement.rb | 7 - app/models/stacks_task.rb | 1 + app/models/trueup.rb | 6 - app/services/money/payable_qbo_bills.rb | 3 +- ...add_mode_and_payment_methods_to_ledgers.rb | 10 +- ...026-06-12-qbo-bound-ledger-cutover-plan.md | 2141 ----------------- ...6-06-12-qbo-bound-ledger-cutover-design.md | 539 ----- .../legacy_ledgers_pending_qbo_migration.rb | 1 + test/integration/ledger_migration_test.rb | 8 + test/models/ledger_test.rb | 96 +- .../ledgers/qbo_bound_migration_check_test.rb | 3 - 19 files changed, 118 insertions(+), 2799 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-12-qbo-bound-ledger-cutover-plan.md delete mode 100644 docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index d1545dd2..12cd5e7f 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -51,7 +51,7 @@ def manual_deel_invoice_visible?(contributor) action_item :new_deel_withdrawal, only: :show do selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger]) if selected_ledger&.deel_enabled? - link_to "New Deel Withdrawal", new_admin_contributor_deel_invoice_adjustment_path(resource, ledger_id: selected_ledger.id) + 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. diff --git a/app/assets/stylesheets/active_admin.scss b/app/assets/stylesheets/active_admin.scss index 38180272..6cdff0a4 100644 --- a/app/assets/stylesheets/active_admin.scss +++ b/app/assets/stylesheets/active_admin.scss @@ -1166,4 +1166,3 @@ body.logged_out #login a { background-image: none !important; } - diff --git a/app/models/contributor_adjustment.rb b/app/models/contributor_adjustment.rb index fbefeaa8..aa8ed90e 100644 --- a/app/models/contributor_adjustment.rb +++ b/app/models/contributor_adjustment.rb @@ -34,10 +34,6 @@ def effective_on_for_display effective_on end - def in_balance_under_qbo_bound? - payable? && !qbo_bill&.paid? - end - # SyncsAsQboBill contract def bill_txn_date effective_on diff --git a/app/models/contributor_payout.rb b/app/models/contributor_payout.rb index 95edf2f1..b1928f2b 100644 --- a/app/models/contributor_payout.rb +++ b/app/models/contributor_payout.rb @@ -398,12 +398,6 @@ def as_commission blueprint["Commission"].sum { |l| l["amount"].to_f } end - # QBO-bound balance rule: in balance only if Stacks considers the row - # settled AND its QBO Bill mirror has not yet been marked Paid. - def in_balance_under_qbo_bound? - payable? && !qbo_bill&.paid? - end - # SyncsAsQboBill contract def bill_txn_date invoice_tracker.invoice_pass.start_of_month.end_of_month diff --git a/app/models/deel_invoice_adjustment.rb b/app/models/deel_invoice_adjustment.rb index 471f64b1..3f962718 100644 --- a/app/models/deel_invoice_adjustment.rb +++ b/app/models/deel_invoice_adjustment.rb @@ -16,11 +16,6 @@ class DeelInvoiceAdjustment < ApplicationRecord NON_DEDUCTING_STATUSES = %w[rejected cancelled canceled declined void voided].freeze APPROVED_LEDGER_STATUSES = %w[approved paid].freeze - # DIAs are audit-only on qbo_bound ledgers — never in balance. - def in_balance_under_qbo_bound? - false - end - # Withdrawals deduct from balance. def signed_amount -amount diff --git a/app/models/ledger.rb b/app/models/ledger.rb index 919d09df..61e28cf4 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -24,6 +24,20 @@ def qbo_enabled? 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) @@ -38,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? @@ -57,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 @@ -71,11 +87,12 @@ 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 @@ -89,23 +106,11 @@ def self.ensure_for_enterprise!(enterprise) # 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 - if legacy? - visible_items.select(&:payable?).sum(&:signed_amount) - elsif qbo_bound? - qbo_bound_open_items.select(&:payable?).sum { |li| qbo_bound_contribution(li) } - else - raise "Unknown ledger mode: #{mode.inspect}" - end + sum_for_bucket(payable: true) end def unsettled - if legacy? - visible_items.reject(&:payable?).sum(&:signed_amount) - elsif qbo_bound? - qbo_bound_open_items.reject(&:payable?).sum { |li| qbo_bound_contribution(li) } - else - raise "Unknown ledger mode: #{mode.inspect}" - end + sum_for_bucket(payable: false) end # Per-ledger by-month grouping for display. Includes soft-deleted rows so the contributor @@ -168,8 +173,8 @@ def visible_items ].flatten end - # qbo_bound mode: drop audit-only rows; everything else flows through - # the per-host predicate in_balance_under_qbo_bound?. + # 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 @@ -180,7 +185,7 @@ def qbo_bound_visible_items # 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.qbo_bill rescue nil)&.paid? } + 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 @@ -196,6 +201,27 @@ def qbo_bound_contribution(li) 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/pay_stub.rb b/app/models/pay_stub.rb index 224196ef..fd11f70e 100644 --- a/app/models/pay_stub.rb +++ b/app/models/pay_stub.rb @@ -45,10 +45,6 @@ def payable? accepted? && pay_cycle.stubs_status == :all_accepted && pay_cycle.approved? end - def in_balance_under_qbo_bound? - payable? && !qbo_bill&.paid? - end - def effective_on_for_display pay_cycle.ends_at end diff --git a/app/models/profit_share.rb b/app/models/profit_share.rb index b5ef5fc3..a1f5387b 100644 --- a/app/models/profit_share.rb +++ b/app/models/profit_share.rb @@ -47,10 +47,6 @@ def find_qbo_account!(qbo_accounts = nil) super(qbo_accounts) end - def in_balance_under_qbo_bound? - payable? && !qbo_bill&.paid? - end - # SyncsAsQboBill contract def bill_txn_date applied_at diff --git a/app/models/reimbursement.rb b/app/models/reimbursement.rb index 5fe5f6d3..19fb687c 100644 --- a/app/models/reimbursement.rb +++ b/app/models/reimbursement.rb @@ -34,13 +34,6 @@ def payable? accepted? end - # Reimbursements sync as QBO Bills like every other payable host. They - # stay in balance until the QBO bill is marked Paid — same lifecycle as - # ContributorPayout/Adjustment/ProfitShare/Trueup/PayStub. - def in_balance_under_qbo_bound? - accepted? && !qbo_bill&.paid? - end - # SyncsAsQboBill contract def bill_txn_date accepted_at&.to_date || created_at.to_date diff --git a/app/models/stacks_task.rb b/app/models/stacks_task.rb index 892b6a37..1462a197 100644 --- a/app/models/stacks_task.rb +++ b/app/models/stacks_task.rb @@ -46,6 +46,7 @@ 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 diff --git a/app/models/trueup.rb b/app/models/trueup.rb index 75c6f74f..36ebd96b 100644 --- a/app/models/trueup.rb +++ b/app/models/trueup.rb @@ -20,12 +20,6 @@ def payable? true end - # Trueups always represent settled income; payable? always returns true so - # only the qbo_bill payment state governs the qbo_bound balance rule. - def in_balance_under_qbo_bound? - !qbo_bill&.paid? - end - # SyncsAsQboBill contract def bill_txn_date invoice_pass.start_of_month.end_of_month diff --git a/app/services/money/payable_qbo_bills.rb b/app/services/money/payable_qbo_bills.rb index c9338fd3..88fe8717 100644 --- a/app/services/money/payable_qbo_bills.rb +++ b/app/services/money/payable_qbo_bills.rb @@ -9,6 +9,7 @@ class PayableQboBills ProfitShare, Trueup, PayStub, + Reimbursement, ].freeze Row = Struct.new(:host, :ledger, :contributor, :qbo_bill, :amount, keyword_init: true) @@ -23,7 +24,7 @@ def self.call(qbo_account:) .includes(ledger: :contributor) .find_each.filter_map do |row| next nil unless row.payable? - qb = (row.qbo_bill rescue nil) + qb = row.try(:qbo_bill) next nil if qb.nil? || qb.paid? Row.new( 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 index 7fe22e13..93a7499b 100644 --- a/db/migrate/20260612214545_add_mode_and_payment_methods_to_ledgers.rb +++ b/db/migrate/20260612214545_add_mode_and_payment_methods_to_ledgers.rb @@ -13,14 +13,8 @@ def up Ledger.reset_column_information Ledger.includes(contributor: :deel_person).find_each do |ledger| - contributor = ledger.contributor - next if contributor.nil? - - dp = contributor.deel_person - country = dp&.data.is_a?(Hash) ? dp.data["country"].to_s.upcase : nil - is_non_us_deel = dp.present? && country.present? && country != "US" - - ledger.update_column(:payment_methods, is_non_us_deel ? %w[deel] : %w[qbo]) + next if ledger.contributor.nil? + ledger.update_column(:payment_methods, Ledger.payment_methods_for(ledger.contributor)) end end diff --git a/docs/superpowers/plans/2026-06-12-qbo-bound-ledger-cutover-plan.md b/docs/superpowers/plans/2026-06-12-qbo-bound-ledger-cutover-plan.md deleted file mode 100644 index bb7d9ea3..00000000 --- a/docs/superpowers/plans/2026-06-12-qbo-bound-ledger-cutover-plan.md +++ /dev/null @@ -1,2141 +0,0 @@ -# QBO-Bound Ledger Cutover Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate Stacks ledgers from the legacy balance model (negative ContributorAdjustments and DeelInvoiceAdjustments deduct) to a QBO-bound model (only QBO Bill Paid drops a positive host from balance), per-ledger and gated by a no-balance-change invariant; replace the LedgerWithdrawalRequest bundling apparatus with a direct Deel API trigger and a controller-facing Payable QBO Bills page. - -**Architecture:** Add `mode` (legacy/qbo_bound) and `payment_methods` (text[]) columns on `ledgers`. Branch `Ledger#balance`/`#unsettled` on `mode`. Introduce `Ledgers::QboBoundMigrationCheck` to compute the gate. Surface the migration as a per-ledger button + a Task Builder discovery. Introduce a `Money::PayableQboBills` page tabbed per QBO account. Delete the `LedgerWithdrawalRequest` model entirely; replace its Deel-call core with `DeelInvoiceAdjustments::CreateForLedger`. Ship a rake task that bulk-flips zero-drift ledgers. - -**Tech Stack:** Rails 6.1, ActiveAdmin, Postgres (text[] with GIN index), Minitest + Mocha, existing `SyncsAsQboBill` concern. - -**Spec:** `docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md` - ---- - -## Pre-flight - -- [ ] **Step 0a: Confirm baseline state** - -```bash -git status -git log --oneline -10 -bundle exec rails db:migrate:status | tail -10 -``` - -Working dir should be clean other than the unrelated `script/why_balance_goes_up.rb` (deleted in Task 18). Verify we're on a worktree branch off `main`. - -- [ ] **Step 0b: Run baseline tests for the touch surface** - -```bash -bundle exec rails test test/models/ledger_test.rb test/models/contributor_adjustment_test.rb -``` - -Expected: all pass. Record the baseline count so we can confirm no regressions later. - ---- - -## Task 1: Schema migration with payment_methods backfill - -**Files:** -- Create: `db/migrate/_add_mode_and_payment_methods_to_ledgers.rb` -- Delete: `db/migrate/20260606135814_create_ledger_withdrawal_requests.rb` - -- [ ] **Step 1.1: Delete the never-deployed LedgerWithdrawalRequest migration** - -```bash -git rm db/migrate/20260606135814_create_ledger_withdrawal_requests.rb -``` - -- [ ] **Step 1.2: Generate the new migration** - -```bash -bundle exec rails generate migration AddModeAndPaymentMethodsToLedgers -``` - -Note the generated timestamp; the file will live at `db/migrate/_add_mode_and_payment_methods_to_ledgers.rb`. - -- [ ] **Step 1.3: Write the migration body** - -Replace the generated file's contents with: - -```ruby -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| - contributor = ledger.contributor - next if contributor.nil? - - dp = contributor.deel_person - country = dp&.data.is_a?(Hash) ? dp.data["country"].to_s.upcase : nil - is_non_us_deel = dp.present? && country.present? && country != "US" - - ledger.update_column(:payment_methods, is_non_us_deel ? %w[deel] : %w[qbo]) - end - end - - def down - remove_index :ledgers, :payment_methods - remove_index :ledgers, :mode - remove_column :ledgers, :payment_methods - remove_column :ledgers, :mode - end -end -``` - -- [ ] **Step 1.4: Run the migration** - -```bash -bundle exec rails db:migrate -``` - -Expected: migration applies cleanly; both columns appear in schema. - -- [ ] **Step 1.5: Verify schema and backfill** - -```bash -bundle exec rails runner 'puts Ledger.columns_hash.slice("mode","payment_methods").map{|n,c|"#{n}: #{c.sql_type}"}.join("\n")' -bundle exec rails runner 'puts Ledger.group(:payment_methods).count.inspect' -``` - -Expected: `mode: integer`, `payment_methods: character varying[]`. Group output shows a mix of `["deel"]` and `["qbo"]`. - -- [ ] **Step 1.6: Commit** - -```bash -git add db/migrate/ db/schema.rb -git commit -m "QBO cutover: add ledger.mode + ledger.payment_methods with backfill" -``` - ---- - -## Task 2: Ledger model — enum, helpers, and qbo_bound_visible_items - -**Files:** -- Modify: `app/models/ledger.rb` -- Test: `test/models/ledger_test.rb` - -- [ ] **Step 2.1: Write failing test for mode enum and payment_methods helpers** - -Append to `test/models/ledger_test.rb`: - -```ruby -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 -end -``` - -- [ ] **Step 2.2: Run test, expect failure** - -```bash -bundle exec rails test test/models/ledger_test.rb -n /LedgerModeAndPaymentMethods/ -``` - -Expected: FAIL (no `mode=` method, no `deel_enabled?`, no constant). - -- [ ] **Step 2.3: Add enum + helpers + constant** - -Edit `app/models/ledger.rb`. After `belongs_to :contributor` and before the `has_many` block, add: - -```ruby - 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 -``` - -- [ ] **Step 2.4: Remove the LedgerWithdrawalRequest has_many association** - -In `app/models/ledger.rb`, delete this line: - -```ruby - has_many :ledger_withdrawal_requests, dependent: :destroy -``` - -- [ ] **Step 2.5: Remove LedgerWithdrawalRequest from all_items_with_deleted** - -In `app/models/ledger.rb`'s `all_items_with_deleted`, delete the trailing line: - -```ruby - LedgerWithdrawalRequest.includes(:bills, :cancelled_by).where(ledger_id: id).to_a, -``` - -Also update the comment above the method to remove the LedgerWithdrawalRequest reference. - -- [ ] **Step 2.6: Add qbo_bound_visible_items helper** - -In `app/models/ledger.rb`'s `private` section, after `visible_items`, add: - -```ruby - # qbo_bound mode: drop DIAs (audit only) and negative CAs (audit only). - # Everything else flows through the same per-host predicate - # in_balance_under_qbo_bound?. - def qbo_bound_visible_items - visible_items.reject do |li| - li.is_a?(DeelInvoiceAdjustment) || - (li.is_a?(ContributorAdjustment) && li.amount.to_f < 0) - end - end -``` - -- [ ] **Step 2.7: Run tests, expect pass** - -```bash -bundle exec rails test test/models/ledger_test.rb -``` - -Expected: all pass (existing tests still green; new mode tests pass). - -- [ ] **Step 2.8: Commit** - -```bash -git add app/models/ledger.rb test/models/ledger_test.rb -git commit -m "Ledger: mode enum, payment_methods helpers, qbo_bound_visible_items" -``` - ---- - -## Task 3: Per-host `in_balance_under_qbo_bound?` predicates - -**Files:** -- Modify: `app/models/contributor_payout.rb` -- Modify: `app/models/contributor_adjustment.rb` -- Modify: `app/models/profit_share.rb` -- Modify: `app/models/trueup.rb` -- Modify: `app/models/pay_stub.rb` -- Modify: `app/models/reimbursement.rb` -- Modify: `app/models/deel_invoice_adjustment.rb` -- Test: `test/models/ledger_test.rb` - -- [ ] **Step 3.1: Write failing test for predicates on each host** - -Append to `test/models/ledger_test.rb`: - -```ruby -class HostInBalanceUnderQboBoundTest < ActiveSupport::TestCase - setup do - Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "QBoundPred-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: 993_001, email: "qbp#{SecureRandom.hex(2)}@example.com", data: {}) - @contributor = Contributor.create!(forecast_person: fp) - @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor) - end - - test "DeelInvoiceAdjustment is never in balance under qbo_bound" do - dia = DeelInvoiceAdjustment.new(amount: 100, deel_status: "approved") - refute dia.in_balance_under_qbo_bound? - end - - test "Reimbursement uses accepted? for qbo_bound" do - r_accepted = Reimbursement.new - r_accepted.stubs(:accepted?).returns(true) - assert r_accepted.in_balance_under_qbo_bound? - - r_pending = Reimbursement.new - r_pending.stubs(:accepted?).returns(false) - refute r_pending.in_balance_under_qbo_bound? - end - - test "ContributorPayout: in balance when payable and qbo_bill unpaid" do - cp = ContributorPayout.new - cp.stubs(:payable?).returns(true) - cp.stubs(:qbo_bill).returns(nil) - assert cp.in_balance_under_qbo_bound? - - paid = mock("qbo_bill") - paid.stubs(:paid?).returns(true) - cp.stubs(:qbo_bill).returns(paid) - refute cp.in_balance_under_qbo_bound? - - cp.stubs(:payable?).returns(false) - cp.stubs(:qbo_bill).returns(nil) - refute cp.in_balance_under_qbo_bound? - end - - test "Trueup: in balance when qbo_bill unpaid (no payable? check)" do - t = Trueup.new - t.stubs(:qbo_bill).returns(nil) - assert t.in_balance_under_qbo_bound? - - paid = mock("qbo_bill") - paid.stubs(:paid?).returns(true) - t.stubs(:qbo_bill).returns(paid) - refute t.in_balance_under_qbo_bound? - end - - test "ProfitShare, PayStub, ContributorAdjustment all follow the payable?-and-unpaid pattern" do - [ProfitShare, PayStub, ContributorAdjustment].each do |klass| - h = klass.new - h.stubs(:payable?).returns(true) - h.stubs(:qbo_bill).returns(nil) - assert h.in_balance_under_qbo_bound?, "#{klass.name} should be in balance when payable and unpaid" - end - end -end -``` - -- [ ] **Step 3.2: Run test, expect failure** - -```bash -bundle exec rails test test/models/ledger_test.rb -n /HostInBalanceUnderQboBound/ -``` - -Expected: FAIL (no `in_balance_under_qbo_bound?` method). - -- [ ] **Step 3.3: Add predicate to ContributorPayout** - -In `app/models/contributor_payout.rb`, add: - -```ruby - # QBO-bound balance rule: in balance only if Stacks considers the row - # settled AND its QBO Bill mirror has not yet been marked Paid. - def in_balance_under_qbo_bound? - payable? && !qbo_bill&.paid? - end -``` - -- [ ] **Step 3.4: Add predicate to ProfitShare** - -In `app/models/profit_share.rb`, add the same method body: - -```ruby - def in_balance_under_qbo_bound? - payable? && !qbo_bill&.paid? - end -``` - -- [ ] **Step 3.5: Add predicate to PayStub** - -In `app/models/pay_stub.rb`, add: - -```ruby - def in_balance_under_qbo_bound? - payable? && !qbo_bill&.paid? - end -``` - -- [ ] **Step 3.6: Add predicate to ContributorAdjustment** - -In `app/models/contributor_adjustment.rb`, add: - -```ruby - def in_balance_under_qbo_bound? - payable? && !qbo_bill&.paid? - end -``` - -- [ ] **Step 3.7: Add predicate to Trueup** - -In `app/models/trueup.rb`, add: - -```ruby - # Trueups always represent settled income; no payable? gate. - def in_balance_under_qbo_bound? - !qbo_bill&.paid? - end -``` - -- [ ] **Step 3.8: Add predicate to Reimbursement** - -In `app/models/reimbursement.rb`, add: - -```ruby - # Reimbursements aren't synced as QBO bills; same gate as legacy. - def in_balance_under_qbo_bound? - accepted? - end -``` - -- [ ] **Step 3.9: Add predicate to DeelInvoiceAdjustment** - -In `app/models/deel_invoice_adjustment.rb`, add: - -```ruby - # DIAs are audit-only on qbo_bound ledgers — never in balance. - def in_balance_under_qbo_bound? - false - end -``` - -- [ ] **Step 3.10: Run tests, expect pass** - -```bash -bundle exec rails test test/models/ledger_test.rb -n /HostInBalanceUnderQboBound/ -``` - -Expected: all pass. - -- [ ] **Step 3.11: Commit** - -```bash -git add app/models/ test/models/ledger_test.rb -git commit -m "Hosts: in_balance_under_qbo_bound? predicates for QBO-bound balance rule" -``` - ---- - -## Task 4: Ledger#balance and Ledger#unsettled mode branching - -**Files:** -- Modify: `app/models/ledger.rb` -- Test: `test/models/ledger_test.rb` - -- [ ] **Step 4.1: Write failing test for mode-branching balance/unsettled** - -Append to `test/models/ledger_test.rb`: - -```ruby -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) - r = Reimbursement.create!(ledger: @ledger, amount: 100, accepted_at: Time.current) - assert_equal 100, @ledger.balance.to_f - end - - test "qbo_bound mode drops a positive host whose qbo_bill is paid" 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(:in_balance_under_qbo_bound?).returns(false) - 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 - 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 -end -``` - -- [ ] **Step 4.2: Run test, expect failure** - -```bash -bundle exec rails test test/models/ledger_test.rb -n /LedgerBalanceUnderQboBound/ -``` - -Expected: FAIL (balance still uses legacy rule unconditionally). - -- [ ] **Step 4.3: Rewrite Ledger#balance and Ledger#unsettled** - -In `app/models/ledger.rb`, replace the existing `balance` and `unsettled` definitions with: - -```ruby - # Balance/unsettled split. legacy preserves today's rules; qbo_bound trusts - # the QBO Bill Paid status as the single source of truth. - def balance - case mode - when "legacy" then visible_items.select(&:payable?).sum(&:signed_amount) - when "qbo_bound" then qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount) - end - end - - def unsettled - case mode - when "legacy" then visible_items.reject(&:payable?).sum(&:signed_amount) - when "qbo_bound" then qbo_bound_visible_items.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount) - end - end -``` - -- [ ] **Step 4.4: Run tests, expect pass** - -```bash -bundle exec rails test test/models/ledger_test.rb -``` - -Expected: all pass. - -- [ ] **Step 4.5: Commit** - -```bash -git add app/models/ledger.rb test/models/ledger_test.rb -git commit -m "Ledger: balance/unsettled branch on mode (legacy vs qbo_bound)" -``` - ---- - -## Task 5: Negative-CA validation guard on qbo_bound ledgers - -**Files:** -- Modify: `app/models/contributor_adjustment.rb` -- Test: `test/models/contributor_adjustment_test.rb` - -- [ ] **Step 5.1: Write failing test for negative-CA guard** - -Append to `test/models/contributor_adjustment_test.rb`: - -```ruby -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: 995_001, 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") - # other validations may still fail in isolation; what we assert is that the - # negative-CA-on-qbo_bound rule isn't the one rejecting it. - ca.valid? - refute ca.errors[:amount].any? { |m| m.include?("not allowed on 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 -``` - -- [ ] **Step 5.2: Run test, expect failure** - -```bash -bundle exec rails test test/models/contributor_adjustment_test.rb -n /NegativeOnQboBound/ -``` - -Expected: FAIL (no guard yet). - -- [ ] **Step 5.3: Add the validation** - -In `app/models/contributor_adjustment.rb`, add: - -```ruby - validate :no_negative_on_qbo_bound_ledger - - def no_negative_on_qbo_bound_ledger - return unless ledger&.qbo_bound? && amount.to_f < 0 - errors.add( - :amount, - "negative adjustments are not allowed on QBO-bound ledgers — mark the corresponding QBO bill Paid instead", - ) - end -``` - -- [ ] **Step 5.4: Run tests, expect pass** - -```bash -bundle exec rails test test/models/contributor_adjustment_test.rb -``` - -Expected: all pass. - -- [ ] **Step 5.5: Commit** - -```bash -git add app/models/contributor_adjustment.rb test/models/contributor_adjustment_test.rb -git commit -m "ContributorAdjustment: reject negative amounts on qbo_bound ledgers" -``` - ---- - -## Task 6: Ledgers::QboBoundMigrationCheck service - -**Files:** -- Create: `app/services/ledgers/qbo_bound_migration_check.rb` -- Create: `test/services/ledgers/qbo_bound_migration_check_test.rb` - -- [ ] **Step 6.1: Write failing test** - -Create `test/services/ledgers/qbo_bound_migration_check_test.rb`: - -```ruby -require "test_helper" - -class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase - setup do - Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "MigCheck-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: 996_001, email: "mc#{SecureRandom.hex(2)}@example.com", data: {}) - @contributor = Contributor.create!(forecast_person: fp) - @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor) - end - - test "empty legacy ledger is ready (Δ = 0 trivially)" do - result = Ledgers::QboBoundMigrationCheck.call(@ledger) - assert result.ready? - assert_in_delta 0, result.balance_delta, 0.001 - assert_in_delta 0, result.unsettled_delta, 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, :blocking_bills - assert_respond_to r, :ignored_negative_cas - end - - test "ledger is blocked when ledger.balance under qbo_bound != legacy" do - # Stub the ledger so the check sees different balances under each rule. - @ledger.stubs(:mode).returns("legacy") - - # When called inside the service, we'll switch mode in a transaction and - # call ledger.balance again. To test this without DB writes, the service - # walks visible_items directly — see Step 6.3's implementation. For now - # we mock visible_items to simulate a divergent ledger. - 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) - cp.stubs(:in_balance_under_qbo_bound?).returns(false) - - neg_ca = ContributorAdjustment.new(amount: -50) - neg_ca.stubs(:payable?).returns(true) - 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) - # legacy: 100 - 50 = 50; qbo_bound: 0 (paid drops cp; neg_ca filtered out) - assert_in_delta -50, result.balance_delta, 0.01 - refute result.ready? - end -end -``` - -- [ ] **Step 6.2: Run test, expect failure** - -```bash -bundle exec rails test test/services/ledgers/qbo_bound_migration_check_test.rb -``` - -Expected: FAIL (service does not exist). - -- [ ] **Step 6.3: Implement the service** - -Create `app/services/ledgers/qbo_bound_migration_check.rb`: - -```ruby -module Ledgers - # Computes whether a legacy Ledger can flip to qbo_bound with zero - # change to balance or unsettled. Returns a Result struct exposing - # the deltas and the open QBO bills that explain any gap. - class QboBoundMigrationCheck - TOLERANCE = 0.01.freeze - - Result = Struct.new( - :current_balance, :current_unsettled, - :proposed_balance, :proposed_unsettled, - :balance_delta, :unsettled_delta, - :ready?, :blocking_bills, :ignored_negative_cas, - keyword_init: true, - ) - - BlockingBill = Struct.new(:host, :qbo_bill, :amount, keyword_init: true) - - def self.call(ledger) - legacy_visible = ledger.send(:visible_items) - qbb_visible = ledger.send(:qbo_bound_visible_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_visible.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f - new_u = qbb_visible.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f - - db = (new_b - legacy_b).round(2) - du = (new_u - legacy_u).round(2) - - 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, - ready?: db.abs < TOLERANCE && du.abs < TOLERANCE, - blocking_bills: collect_blocking_bills(legacy_visible), - ignored_negative_cas: legacy_visible.select { |li| li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 }, - ) - end - - def self.collect_blocking_bills(items) - items.filter_map do |li| - next nil if li.is_a?(DeelInvoiceAdjustment) - next nil if li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 - next nil unless li.respond_to?(:qbo_bill) - next nil unless li.respond_to?(:payable?) && li.payable? - - qb = (li.qbo_bill rescue nil) - next nil if qb.nil? || qb.paid? - - BlockingBill.new(host: li, qbo_bill: qb, amount: li.amount.to_f) - end - end - end -end -``` - -- [ ] **Step 6.4: Run tests, expect pass** - -```bash -bundle exec rails test test/services/ledgers/qbo_bound_migration_check_test.rb -``` - -Expected: all pass. - -- [ ] **Step 6.5: Commit** - -```bash -git add app/services/ledgers/ test/services/ledgers/ -git commit -m "Ledgers::QboBoundMigrationCheck: per-ledger gate with blocking-bill detail" -``` - ---- - -## Task 7: Ledger admin Migrate panel + member_action - -**Files:** -- Modify: `app/admin/ledgers.rb` -- Test: `test/system/ledger_migration_panel_test.rb` (new system test if `test/system` is already used) - -- [ ] **Step 7.1: Write failing test for the member_action** - -Create `test/integration/ledger_migration_test.rb`: - -```ruby -require "test_helper" - -class LedgerMigrationTest < ActionDispatch::IntegrationTest - setup do - Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "MigPanel-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: 997_001, email: "mp#{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: "lmig#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", is_admin: true) - 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 with non-zero drift" do - # Plant divergent items via a stub. Without a DB-level divergence, - # mock the check service to return a not-ready Result. - not_ready = Ledgers::QboBoundMigrationCheck::Result.new( - current_balance: 0, current_unsettled: 0, - proposed_balance: 100, proposed_unsettled: 0, - balance_delta: 100, unsettled_delta: 0, - ready?: false, blocking_bills: [], ignored_negative_cas: [], - ) - 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 - - private - - def sign_in(admin) - # ActiveAdmin Devise sign-in helper used by other admin integration tests. - post admin_user_session_path, params: { admin_user: { email: admin.email, password: "password123" } } - end -end -``` - -- [ ] **Step 7.2: Run test, expect failure** - -```bash -bundle exec rails test test/integration/ledger_migration_test.rb -``` - -Expected: FAIL (no `migrate_to_qbo_bound` action defined; route missing). - -- [ ] **Step 7.3: Add member_action and sidebar panel to Ledger admin** - -In `app/admin/ledgers.rb`, replace the file's contents with: - -```ruby -ActiveAdmin.register Ledger do - menu false - config.filters = false - config.paginate = false - actions :index, :show - permit_params - - member_action :migrate_to_qbo_bound, method: :post do - result = Ledgers::QboBoundMigrationCheck.call(resource) - if result.ready? - resource.update!(mode: :qbo_bound) - redirect_to admin_ledger_path(resource), notice: "Migrated to QBO-bound." - else - redirect_to admin_ledger_path(resource), - alert: "Cannot migrate: Δbalance #{result.balance_delta}, Δunsettled #{result.unsettled_delta}." - 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 - para "Current (legacy): balance $#{result.current_balance} unsettled $#{result.current_unsettled}" - para "Proposed (qbo_bound): balance $#{result.proposed_balance} unsettled $#{result.proposed_unsettled}" - para "Δ balance #{result.balance_delta}, Δ unsettled #{result.unsettled_delta}" - end - if result.ready? - div do - para "Net-zero change — safe to migrate." - button_to "Migrate to QBO-bound", migrate_to_qbo_bound_admin_ledger_path(resource), method: :post, data: { confirm: "Flip this ledger to qbo_bound?" } - end - else - div do - if result.blocking_bills.any? - para "Open QBO bills blocking the migration:" - ul do - result.blocking_bills.first(20).each do |bb| - li do - text_node "#{bb.host.class.name} ##{bb.host.id} — $#{bb.amount.to_f.round(2)} — " - link_to "Pay in QBO ↗", bb.qbo_bill.qbo_url, target: "_blank", rel: "noopener" - end - end - end - end - if result.ignored_negative_cas.any? - para "Negative CAs (audit-only after migration):" - ul do - result.ignored_negative_cas.first(10).each do |ca| - li "CA ##{ca.id} — $#{ca.amount.to_f.round(2)}" - end - end - end - para "Resolve the open bills in QBO, then refresh this page or click Re-check." - button_to "Re-check", admin_ledger_path(resource), method: :get - end - end - end - end - end -end -``` - -- [ ] **Step 7.4: Run tests, expect pass** - -```bash -bundle exec rails test test/integration/ledger_migration_test.rb -``` - -Expected: all pass. - -- [ ] **Step 7.5: Commit** - -```bash -git add app/admin/ledgers.rb test/integration/ledger_migration_test.rb -git commit -m "Ledger admin: Migrate-to-QBO-bound panel + member_action" -``` - ---- - -## Task 8: Rake task for bulk zero-drift migration - -**Files:** -- Create: `lib/tasks/ledgers.rake` -- Test: `test/lib/tasks/ledgers_rake_test.rb` - -- [ ] **Step 8.1: Write failing test for the rake task** - -Create `test/lib/tasks/ledgers_rake_test.rb`: - -```ruby -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.find_or_create_by!(name: "RakeMig-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: 999_001, email: "rm#{SecureRandom.hex(2)}@example.com", data: {}) - @contributor = Contributor.create!(forecast_person: fp) - @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor) - end - - test "ready legacy ledger is auto-flipped to qbo_bound" do - @ledger.update!(mode: :legacy) - # An empty ledger is trivially ready (Δ = 0). - 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, ready?: false, blocking_bills: [], ignored_negative_cas: [], - ) - Ledgers::QboBoundMigrationCheck.stubs(:call).returns(blocked) - - Rake::Task["ledgers:migrate_qbo_bound_zero_drift"].invoke - assert @ledger.reload.legacy? - end -end -``` - -- [ ] **Step 8.2: Run test, expect failure** - -```bash -bundle exec rails test test/lib/tasks/ledgers_rake_test.rb -``` - -Expected: FAIL (rake task doesn't exist). - -- [ ] **Step 8.3: Implement the rake task** - -Create `lib/tasks/ledgers.rake`: - -```ruby -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 -``` - -- [ ] **Step 8.4: Run tests, expect pass** - -```bash -bundle exec rails test test/lib/tasks/ledgers_rake_test.rb -``` - -Expected: all pass. - -- [ ] **Step 8.5: Commit** - -```bash -git add lib/tasks/ledgers.rake test/lib/tasks/ledgers_rake_test.rb -git commit -m "ledgers:migrate_qbo_bound_zero_drift rake task" -``` - ---- - -## Task 9: Task Builder discovery + StacksTask routing - -**Files:** -- Create: `lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb` -- Modify: `lib/stacks/task_builder.rb` (register discovery) -- Modify: `app/models/stacks_task.rb` (route Ledger subject URL by task type) -- Test: `test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb` - -- [ ] **Step 9.1: Inspect existing discovery registration pattern** - -Read `lib/stacks/task_builder.rb` to find where `MissingQboVendors` and other discoveries are registered. Note the exact registry expression — the new discovery is added the same way. - -```bash -grep -n "Discoveries::" lib/stacks/task_builder.rb -``` - -- [ ] **Step 9.2: Write failing test** - -Create `test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb`: - -```ruby -require "test_helper" - -class Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigrationTest < ActiveSupport::TestCase - setup do - Thread.current[:sanctuary_enterprise] = nil - @qa = QboAccount.create!(realm_id: "rake#{SecureRandom.hex(2)}", name: "RakeQA") - @enterprise = Enterprise.create!(name: "DiscEnt-#{SecureRandom.hex(2)}", qbo_account: @qa) - fp = ForecastPerson.create!(forecast_id: 990_001, 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", is_admin: true) - end - - test "legacy ledger with payable activity yields a migration task" do - ContributorPayout.create!(ledger: @ledger, amount: 100, blueprint: { "lines" => [{ "amount" => 100.0 }] }) - 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 - ContributorPayout.create!(ledger: @ledger, amount: 100, blueprint: { "lines" => [{ "amount" => 100.0 }] }) - @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 -``` - -- [ ] **Step 9.3: Run test, expect failure** - -```bash -bundle exec rails test test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb -``` - -Expected: FAIL (class does not exist). - -- [ ] **Step 9.4: Create the discovery** - -Create `lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb`: - -```ruby -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 - ].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 -``` - -- [ ] **Step 9.5: Register the discovery** - -In `lib/stacks/task_builder.rb`, find the discovery registration list (per Step 9.1). Add the new discovery following the existing pattern. Remove the existing `Discoveries::LedgerWithdrawalRequests` registration (it will be deleted in Task 15). - -- [ ] **Step 9.6: Add Ledger URL type-branch in StacksTask** - -Edit `app/models/stacks_task.rb`. Find the existing `when Ledger` branch in `subject_url` (around line 137): - -```ruby - when Ledger then helpers.edit_admin_contributor_path(subject.contributor) -``` - -Replace with: - -```ruby - when Ledger - if type == :legacy_ledger_needs_qbo_migration - helpers.admin_ledger_path(subject) - else - helpers.edit_admin_contributor_path(subject.contributor) - end -``` - -- [ ] **Step 9.7: Run tests, expect pass** - -```bash -bundle exec rails test test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb -``` - -Expected: all pass. - -- [ ] **Step 9.8: Commit** - -```bash -git add lib/stacks/ app/models/stacks_task.rb test/lib/stacks/ -git commit -m "TaskBuilder: surface legacy ledgers pending QBO migration" -``` - ---- - -## Task 10: Money::PayableQboBills service - -**Files:** -- Create: `app/services/money/payable_qbo_bills.rb` -- Create: `test/services/money/payable_qbo_bills_test.rb` - -- [ ] **Step 10.1: Write failing test** - -Create `test/services/money/payable_qbo_bills_test.rb`: - -```ruby -require "test_helper" - -class Money::PayableQboBillsTest < ActiveSupport::TestCase - setup do - Thread.current[:sanctuary_enterprise] = nil - @qa = QboAccount.create!(realm_id: "pq#{SecureRandom.hex(2)}", name: "PayableQA") - @enterprise = Enterprise.create!(name: "PayableEnt-#{SecureRandom.hex(2)}", qbo_account: @qa) - fp = ForecastPerson.create!(forecast_id: 988_001, 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", data: { "balance" => "100" }) - cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: open_bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) - cp.stubs(:payable?).returns(true) - - rows = Money::PayableQboBills.call(qbo_account: @qa) - refute rows.any? { |r| r.host == cp } - end - - test "returns rows for payable hosts whose qbo_bill is open" do - open_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b2", data: { "balance" => "100" }) - cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: open_bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) - ContributorPayout.any_instance.stubs(:payable?).returns(true) - - rows = Money::PayableQboBills.call(qbo_account: @qa) - assert rows.any? { |r| r.host.id == cp.id && r.qbo_bill.qbo_id == "b2" } - end - - test "excludes paid bills" do - paid_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b3", data: { "balance" => "0" }) - cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: paid_bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) - ContributorPayout.any_instance.stubs(:payable?).returns(true) - - rows = Money::PayableQboBills.call(qbo_account: @qa) - refute rows.any? { |r| r.host.id == cp.id } - end - - test "excludes non-payable hosts" do - open_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b4", data: { "balance" => "100" }) - cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: open_bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) - ContributorPayout.any_instance.stubs(:payable?).returns(false) - - rows = Money::PayableQboBills.call(qbo_account: @qa) - refute rows.any? { |r| r.host.id == cp.id } - end -end -``` - -- [ ] **Step 10.2: Run test, expect failure** - -```bash -bundle exec rails test test/services/money/payable_qbo_bills_test.rb -``` - -Expected: FAIL (service does not exist). - -- [ ] **Step 10.3: Implement the service** - -Create `app/services/money/payable_qbo_bills.rb`: - -```ruby -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, - ].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) - .where(enterprises: { qbo_account_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.qbo_bill rescue nil) - 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 -``` - -- [ ] **Step 10.4: Run tests, expect pass** - -```bash -bundle exec rails test test/services/money/payable_qbo_bills_test.rb -``` - -Expected: all pass. - -- [ ] **Step 10.5: Commit** - -```bash -git add app/services/money/payable_qbo_bills.rb test/services/money/payable_qbo_bills_test.rb -git commit -m "Money::PayableQboBills: cross-enterprise open-bill selection" -``` - ---- - -## Task 11: Money::RefreshPayableQboBills service - -**Files:** -- Create: `app/services/money/refresh_payable_qbo_bills.rb` -- Create: `test/services/money/refresh_payable_qbo_bills_test.rb` - -- [ ] **Step 11.1: Write failing test** - -Create `test/services/money/refresh_payable_qbo_bills_test.rb`: - -```ruby -require "test_helper" - -class Money::RefreshPayableQboBillsTest < ActiveSupport::TestCase - setup do - Thread.current[:sanctuary_enterprise] = nil - @qa = QboAccount.create!(realm_id: "rfp#{SecureRandom.hex(2)}", name: "RefreshQA") - @enterprise = Enterprise.create!(name: "RefreshEnt-#{SecureRandom.hex(2)}", qbo_account: @qa) - fp = ForecastPerson.create!(forecast_id: 987_001, 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", data: { "balance" => "100" }) - @cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: @bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) - end - - test "calls sync_qbo_bill! on every row returned by PayableQboBills" do - ContributorPayout.any_instance.stubs(:payable?).returns(true) - ContributorPayout.any_instance.expects(:sync_qbo_bill!).at_least_once - - Money::RefreshPayableQboBills.call(qbo_account: @qa) - end -end -``` - -- [ ] **Step 11.2: Run test, expect failure** - -```bash -bundle exec rails test test/services/money/refresh_payable_qbo_bills_test.rb -``` - -Expected: FAIL (service does not exist). - -- [ ] **Step 11.3: Implement the service** - -Create `app/services/money/refresh_payable_qbo_bills.rb`: - -```ruby -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 -``` - -- [ ] **Step 11.4: Run tests, expect pass** - -```bash -bundle exec rails test test/services/money/refresh_payable_qbo_bills_test.rb -``` - -Expected: all pass. - -- [ ] **Step 11.5: Commit** - -```bash -git add app/services/money/refresh_payable_qbo_bills.rb test/services/money/refresh_payable_qbo_bills_test.rb -git commit -m "Money::RefreshPayableQboBills: bulk re-sync open bills for one QBO account" -``` - ---- - -## Task 12: Payable QBO Bills admin page - -**Files:** -- Modify: `app/admin/money.rb` -- Create: `app/views/admin/money/payable_qbo_bills.html.erb` -- Test: `test/integration/payable_qbo_bills_test.rb` - -- [ ] **Step 12.1: Write failing integration test** - -Create `test/integration/payable_qbo_bills_test.rb`: - -```ruby -require "test_helper" - -class PayableQboBillsTest < ActionDispatch::IntegrationTest - setup do - Thread.current[:sanctuary_enterprise] = nil - @qa = QboAccount.create!(realm_id: "pgi#{SecureRandom.hex(2)}", name: "IntQA") - @enterprise = Enterprise.create!(name: "IntEnt-#{SecureRandom.hex(2)}", qbo_account: @qa) - fp = ForecastPerson.create!(forecast_id: 986_001, 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", is_admin: true) - 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 - assert_match @qa.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 -``` - -- [ ] **Step 12.2: Run test, expect failure** - -```bash -bundle exec rails test test/integration/payable_qbo_bills_test.rb -``` - -Expected: FAIL (routes missing). - -- [ ] **Step 12.3: Rewrite app/admin/money.rb** - -Replace `app/admin/money.rb` entirely with: - -```ruby -ActiveAdmin.register_page "Money" do - menu priority: 50 - - controller do - before_action :authenticate_admin_user! - end - - page_action :payable_qbo_bills, method: :get do - @qbo_accounts = QboAccount.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 -``` - -- [ ] **Step 12.4: Create the view** - -Create `app/views/admin/money/payable_qbo_bills.html.erb`: - -```erb -

Payable QBO Bills

- -
- <% @qbo_accounts.each do |qa| %> - <%= link_to qa.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.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 %> -``` - -- [ ] **Step 12.5: Run tests, expect pass** - -```bash -bundle exec rails test test/integration/payable_qbo_bills_test.rb -``` - -Expected: all pass. - -- [ ] **Step 12.6: Commit** - -```bash -git add app/admin/money.rb app/views/admin/money/ test/integration/payable_qbo_bills_test.rb -git commit -m "Money admin: Payable QBO Bills page, tabbed per QBO account" -``` - ---- - -## Task 13: DeelInvoiceAdjustments::CreateForLedger service - -**Files:** -- Create: `app/services/deel_invoice_adjustments/create_for_ledger.rb` -- Create: `test/services/deel_invoice_adjustments/create_for_ledger_test.rb` - -- [ ] **Step 13.1: Inspect existing ProcessViaDeel for Deel-API-call code** - -Read `app/services/ledger_withdrawal_requests/process_via_deel.rb`. Note the exact Deel API call: which client method, what params it expects, how the response is mapped to `DeelInvoiceAdjustment.create_from_deel_response!`. The new service ports that logic without the LedgerWithdrawalRequest linkage. - -- [ ] **Step 13.2: Write failing test** - -Create `test/services/deel_invoice_adjustments/create_for_ledger_test.rb`: - -```ruby -require "test_helper" - -class DeelInvoiceAdjustments::CreateForLedgerTest < ActiveSupport::TestCase - setup do - Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "DelegLed-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: 985_001, email: "del#{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[deel]) - - @contract = DeelContract.create!(deel_id: "dc#{SecureRandom.hex(2)}", deel_person_id: "dp#{SecureRandom.hex(2)}", data: { "type" => "ongoing_time_based" }) - - @admin = AdminUser.create!(email: "dca#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", is_admin: true) - end - - test "creates a DIA when Deel API call succeeds" do - fake_response = { "data" => { "id" => "adj-42", "status" => "pending" } } - DeelInvoiceAdjustment.expects(:create_from_deel_response!).with( - ledger: @ledger, - deel_contract_id: @contract.deel_id, - amount: 100, - description: "test", - date_submitted: Date.current, - parsed_response: fake_response, - ).returns(DeelInvoiceAdjustment.new) - - DeelInvoiceAdjustments::CreateForLedger.any_instance.stubs(:call_deel_api).returns(fake_response) - - result = DeelInvoiceAdjustments::CreateForLedger.call( - ledger: @ledger, - amount: 100, - contract_id: @contract.deel_id, - description: "test", - date_submitted: Date.current, - initiated_by: @admin, - ) - assert result.is_a?(DeelInvoiceAdjustment) - end - - test "raises CreateForLedger::Error when Deel API returns no adjustment id" do - DeelInvoiceAdjustments::CreateForLedger.any_instance.stubs(:call_deel_api).returns({ "data" => {} }) - - assert_raises(DeelInvoiceAdjustments::CreateForLedger::Error) do - DeelInvoiceAdjustments::CreateForLedger.call( - ledger: @ledger, - amount: 100, - contract_id: @contract.deel_id, - description: "test", - date_submitted: Date.current, - initiated_by: @admin, - ) - end - end -end -``` - -- [ ] **Step 13.3: Run test, expect failure** - -```bash -bundle exec rails test test/services/deel_invoice_adjustments/create_for_ledger_test.rb -``` - -Expected: FAIL (service does not exist). - -- [ ] **Step 13.4: Implement the service by porting from ProcessViaDeel** - -Create `app/services/deel_invoice_adjustments/create_for_ledger.rb`. Copy the Deel API call body from `LedgerWithdrawalRequests::ProcessViaDeel#call`. Substitute the LedgerWithdrawalRequest linkage with direct kwargs: - -```ruby -module DeelInvoiceAdjustments - # Creates a DeelInvoiceAdjustment in Deel for a given ledger + contract, - # then persists the response as a Stacks-side DIA. Replaces the - # withdrawal-request-mediated path from LedgerWithdrawalRequests::ProcessViaDeel. - class CreateForLedger - class Error < StandardError; end - - def self.call(ledger:, amount:, contract_id:, description:, date_submitted:, initiated_by:) - new(ledger: ledger, amount: amount, contract_id: contract_id, - description: description, date_submitted: date_submitted, initiated_by: initiated_by).call - end - - def initialize(ledger:, amount:, contract_id:, description:, date_submitted:, initiated_by:) - @ledger = ledger - @amount = BigDecimal(amount.to_s) - @contract_id = contract_id.to_s - @description = description.to_s - @date_submitted = date_submitted - @initiated_by = initiated_by - end - - def call - parsed = call_deel_api - raise Error, "Deel did not return an adjustment id" if parsed.dig("data", "id").blank? - - DeelInvoiceAdjustment.create_from_deel_response!( - ledger: @ledger, - deel_contract_id: @contract_id, - amount: @amount, - description: @description, - date_submitted: @date_submitted, - parsed_response: parsed, - ) - rescue ActiveRecord::RecordInvalid => e - raise Error, "Could not persist DIA: #{e.message}" - end - - private - - # Calls the Deel /invoice-adjustments endpoint. Port the body from - # LedgerWithdrawalRequests::ProcessViaDeel — same Deel client, same - # endpoint signature; just drop the request-id linkage. - def call_deel_api - # See Step 13.1 for what to port. The exact client method comes from - # the source file. Implement as a direct copy with the request param - # removed. - raise NotImplementedError, "Port from LedgerWithdrawalRequests::ProcessViaDeel before this task is complete" - end - end -end -``` - -Then read `app/services/ledger_withdrawal_requests/process_via_deel.rb` and replace the body of `call_deel_api` with the corresponding HTTP-call section (everything that builds the request body, sends to Deel, and returns the parsed response — minus the LedgerWithdrawalRequest reference). - -- [ ] **Step 13.5: Run tests, expect pass** - -```bash -bundle exec rails test test/services/deel_invoice_adjustments/create_for_ledger_test.rb -``` - -Expected: all pass (the failure-mode test passes by stubbing `call_deel_api`). - -- [ ] **Step 13.6: Commit** - -```bash -git add app/services/deel_invoice_adjustments/ test/services/deel_invoice_adjustments/ -git commit -m "DeelInvoiceAdjustments::CreateForLedger: direct Deel API call (no withdrawal request)" -``` - ---- - -## Task 14: Contributor admin — withdraw_via_deel member_action - -**Files:** -- Modify: `app/admin/contributors.rb` -- Modify: `app/views/admin/contributors/_show.html.erb` (remove the LedgerWithdrawalRequest splice; add the new button) -- Test: `test/integration/contributor_withdraw_via_deel_test.rb` - -- [ ] **Step 14.1: Inspect current contributor admin withdrawal launch** - -```bash -grep -n "LedgerWithdrawalRequest\|withdrawal_request\|new_admin_ledger_withdrawal" app/admin/contributors.rb app/views/admin/contributors/_show.html.erb -``` - -Note every reference. The new `withdraw_via_deel` member_action replaces the launch link. - -- [ ] **Step 14.2: Write failing integration test** - -Create `test/integration/contributor_withdraw_via_deel_test.rb`: - -```ruby -require "test_helper" - -class ContributorWithdrawViaDeelTest < ActionDispatch::IntegrationTest - setup do - Thread.current[:sanctuary_enterprise] = nil - @enterprise = Enterprise.find_or_create_by!(name: "WVD-#{SecureRandom.hex(2)}") - fp = ForecastPerson.create!(forecast_id: 984_001, email: "wvd#{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[deel]) - - @contract = DeelContract.create!(deel_id: "dc#{SecureRandom.hex(2)}", deel_person_id: "dp#{SecureRandom.hex(2)}", data: { "type" => "ongoing_time_based" }) - - @admin = AdminUser.create!(email: "wvd#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", is_admin: true) - sign_in @admin - end - - test "POST withdraw_via_deel calls CreateForLedger on a deel-enabled ledger" do - DeelInvoiceAdjustments::CreateForLedger.expects(:call).with( - ledger: @ledger, - amount: "100", - contract_id: @contract.deel_id, - description: "", - date_submitted: anything, - initiated_by: instance_of(AdminUser), - ).returns(DeelInvoiceAdjustment.new) - - post withdraw_via_deel_admin_contributor_path(@contributor), params: { - ledger_id: @ledger.id, - amount: "100", - contract_id: @contract.deel_id, - } - assert_response :redirect - end - - test "POST withdraw_via_deel refuses on a non-deel ledger" do - @ledger.update!(payment_methods: %w[qbo]) - DeelInvoiceAdjustments::CreateForLedger.expects(:call).never - - post withdraw_via_deel_admin_contributor_path(@contributor), params: { - ledger_id: @ledger.id, - amount: "100", - contract_id: @contract.deel_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 -``` - -- [ ] **Step 14.3: Run test, expect failure** - -```bash -bundle exec rails test test/integration/contributor_withdraw_via_deel_test.rb -``` - -Expected: FAIL (route missing). - -- [ ] **Step 14.4: Add the member_action to contributors admin** - -In `app/admin/contributors.rb`, add the following member_action (place near other Active Admin member_actions): - -```ruby - member_action :withdraw_via_deel, method: :post do - ledger = Ledger.find(params.require(:ledger_id)) - unless ledger.deel_enabled? - redirect_back fallback_location: admin_contributor_path(resource), alert: "Deel is not enabled for this ledger." - return - end - - DeelInvoiceAdjustments::CreateForLedger.call( - ledger: ledger, - amount: params.require(:amount), - contract_id: params.require(:contract_id), - description: params[:description].to_s, - date_submitted: params[:date_submitted].presence || Date.current, - initiated_by: current_admin_user, - ) - - redirect_back fallback_location: admin_contributor_path(resource), notice: "Withdrew via Deel." - rescue DeelInvoiceAdjustments::CreateForLedger::Error => e - redirect_back fallback_location: admin_contributor_path(resource), alert: e.message - end -``` - -- [ ] **Step 14.5: Replace withdrawal-request launch link in the contributor show partial** - -In `app/views/admin/contributors/_show.html.erb`, locate the existing link to `new_admin_ledger_withdrawal_request_path` (added earlier in this PR). Replace it with a small form that posts to `withdraw_via_deel_admin_contributor_path`, gated by `ledger.deel_enabled?`: - -```erb -<% if ledger.deel_enabled? %> - <%= form_tag(withdraw_via_deel_admin_contributor_path(contributor), method: :post, style: "display: inline") do %> - <%= hidden_field_tag :ledger_id, ledger.id %> - <%= number_field_tag :amount, ledger.balance.to_f, step: "0.01", min: "0.01", max: ledger.balance.to_f %> - <%= select_tag :contract_id, options_for_select(contributor.deel_person&.deel_contracts&.map { |c| [c.deel_contract_type_label, c.deel_id] } || []) %> - <%= submit_tag "Withdraw via Deel" %> - <% end %> -<% end %> -``` - -If the existing splice for `LedgerWithdrawalRequest` timeline rendering is still in the file (it should be from the prior work), remove it as part of Task 15. - -- [ ] **Step 14.6: Run tests, expect pass** - -```bash -bundle exec rails test test/integration/contributor_withdraw_via_deel_test.rb -``` - -Expected: all pass. - -- [ ] **Step 14.7: Commit** - -```bash -git add app/admin/contributors.rb app/views/admin/contributors/_show.html.erb test/integration/contributor_withdraw_via_deel_test.rb -git commit -m "Contributors admin: withdraw_via_deel member_action + gated form" -``` - ---- - -## Task 15: Delete the LedgerWithdrawalRequest apparatus - -**Files (delete):** -- `app/models/ledger_withdrawal_request.rb` -- `app/models/ledger_withdrawal_request_bill.rb` -- `app/admin/ledger_withdrawal_requests.rb` -- `app/views/admin/ledger_withdrawal_requests/_show.html.erb` -- `app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb` -- `app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb` -- `app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb` -- `app/services/ledger_withdrawal_requests/process_via_deel.rb` -- `lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb` - -- [ ] **Step 15.1: Delete the files** - -```bash -git rm app/models/ledger_withdrawal_request.rb \ - app/models/ledger_withdrawal_request_bill.rb \ - app/admin/ledger_withdrawal_requests.rb \ - app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb \ - app/services/ledger_withdrawal_requests/process_via_deel.rb \ - lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb -git rm -r app/views/admin/ledger_withdrawal_requests/ -rmdir app/services/ledger_withdrawal_requests 2>/dev/null || true -``` - -- [ ] **Step 15.2: Run boot smoke test to find stale references** - -```bash -bundle exec rails runner 'puts Ledger.first&.id' -``` - -Expected output: a ledger id (or empty if no ledgers). If this errors with `NameError: uninitialized constant`, capture the exact name and find references with grep — they need to be removed in Task 16. - -- [ ] **Step 15.3: Commit** - -```bash -git add -A -git commit -m "Delete LedgerWithdrawalRequest model, admin, services, discovery, views" -``` - ---- - -## Task 16: Clean up cross-references - -**Files:** -- Modify: `app/models/contributor.rb` (remove `ledger_withdrawal_requests_with_deleted` and any `preload_for_ledger_view!` addition for it) -- Modify: `app/models/stacks_task.rb` (remove `LedgerWithdrawalRequest` `when` branches in `subject_display_name` and `subject_url`) -- Modify: `app/admin/deel_invoice_adjustments.rb` (remove any LedgerWithdrawalRequest cross-link) -- Modify: `app/models/admin_authorization.rb` (remove any LedgerWithdrawalRequest permission entries) -- Modify: `app/views/admin/contributors/_show.html.erb` (remove the LedgerWithdrawalRequest timeline splice) -- Modify: `lib/stacks/task_builder.rb` (remove the registration for the deleted discovery, if Task 9 didn't already) - -- [ ] **Step 16.1: Find every remaining reference** - -```bash -grep -rn "LedgerWithdrawalRequest\|ledger_withdrawal_request" app/ lib/ test/ --include="*.rb" --include="*.erb" --include="*.arb" 2>/dev/null -``` - -Expected after Task 15: only references in `app/models/contributor.rb`, `app/models/stacks_task.rb`, `app/admin/deel_invoice_adjustments.rb`, `app/models/admin_authorization.rb`, `app/views/admin/contributors/_show.html.erb`, and `lib/stacks/task_builder.rb`. - -- [ ] **Step 16.2: Remove from app/models/contributor.rb** - -Delete the `ledger_withdrawal_requests_with_deleted` method and any `preload_for_ledger_view!` entry related to it. Search for any `is_a?(LedgerWithdrawalRequest)` branches in sort logic added during the prior splice work and remove them. - -- [ ] **Step 16.3: Remove from app/models/stacks_task.rb** - -Remove the `when LedgerWithdrawalRequest` branches in both `subject_display_name` and `subject_url`. The `when Ledger` branch (with the type-branch added in Task 9) stays. - -- [ ] **Step 16.4: Remove from app/admin/deel_invoice_adjustments.rb** - -Remove any cross-link to withdrawal requests (likely a sidebar link or column displaying the parent withdrawal request). - -- [ ] **Step 16.5: Remove from app/models/admin_authorization.rb** - -Remove any permission rules referencing `LedgerWithdrawalRequest` or `:ledger_withdrawal_requests`. - -- [ ] **Step 16.6: Remove the splice from app/views/admin/contributors/_show.html.erb** - -Delete the lines that render `LedgerWithdrawalRequest` rows in the timeline (the "Withdrawal Request" pill rendering added in the prior commits). - -- [ ] **Step 16.7: Verify boot and run full test suite** - -```bash -bundle exec rails runner 'puts "boots: #{Rails.application.config.cache_classes}"' -bundle exec rails test -``` - -Expected: boots cleanly; the full test suite passes (existing tests still pass after the deletion + cleanup). - -- [ ] **Step 16.8: Final grep — ensure no LedgerWithdrawalRequest references remain** - -```bash -grep -rn "LedgerWithdrawalRequest\|ledger_withdrawal_request" app/ lib/ test/ 2>/dev/null -``` - -Expected: empty result (no matches anywhere). - -- [ ] **Step 16.9: Commit** - -```bash -git add -A -git commit -m "Remove all LedgerWithdrawalRequest cross-references from runtime" -``` - ---- - -## Task 17: Delete the audit scripts - -**Files (delete):** -- `script/audit_qbo_cutover_balance_drift.rb` -- `script/accountant_reconciliation_worklist.rb` -- `script/why_balance_goes_up.rb` - -- [ ] **Step 17.1: Delete** - -```bash -git rm script/audit_qbo_cutover_balance_drift.rb \ - script/accountant_reconciliation_worklist.rb \ - script/why_balance_goes_up.rb 2>/dev/null || true -# why_balance_goes_up.rb may be untracked — delete from working tree as well: -rm -f script/why_balance_goes_up.rb 2>/dev/null || true -``` - -- [ ] **Step 17.2: Commit** - -```bash -git add -A -git commit -m "Remove one-shot QBO-cutover audit scripts" -``` - ---- - -## Task 18: Final sweep — vigilance on the strategy-change deletion list - -The user explicitly called out: ensure nothing from the discarded ideas slipped into the implementation. This task is a self-review pass before opening the PR. - -- [ ] **Step 18.1: Scan for forbidden tokens** - -```bash -grep -rn "justworks\|Justworks\|JUSTWORKS\|misc_enabled" app/ lib/ test/ docs/superpowers/ 2>/dev/null -``` - -Expected: only the spec/plan docs mention `justworks` in historical/explanatory context. No application code references it. - -```bash -grep -rn "LedgerWithdrawalRequest\|ledger_withdrawal_request" app/ lib/ test/ 2>/dev/null -``` - -Expected: empty. - -```bash -grep -rn "process_via_deel\|enumerate_candidate_bills\|bills_panel\|notes_panel" app/ lib/ test/ 2>/dev/null -``` - -Expected: empty. - -- [ ] **Step 18.2: Run full test suite** - -```bash -bundle exec rails test -``` - -Expected: zero failures, zero errors. Capture and compare against the baseline from Step 0b — no regressions outside the intentionally-deleted tests. - -- [ ] **Step 18.3: Sanity-check the rake task end-to-end** - -```bash -bundle exec rake ledgers:migrate_qbo_bound_zero_drift -bundle exec rails runner 'puts Ledger.group(:mode).count.inspect' -``` - -Expected: the rake task prints a count line; the runner output shows a mix of `legacy` (still blocked) and `qbo_bound` (auto-flipped where Δ < $0.01). - -- [ ] **Step 18.4: Verify spec ↔ code alignment** - -Reread `docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md`. Confirm every section's described behavior is reflected in the committed code. Any divergence is either fixed inline (preferable) or documented as an addendum at the top of the spec. - -- [ ] **Step 18.5: Commit any sweep fixes** - -```bash -git status -# If anything changed: -git add -A -git commit -m "Cleanup pass after final review" -``` - ---- - -## Task 19: Open the PR - -- [ ] **Step 19.1: Push branch** - -```bash -git push -u origin HEAD -``` - -- [ ] **Step 19.2: Open PR with summary** - -```bash -gh pr create --title "QBO-bound ledger cutover" --body "$(cat <<'EOF' -## Summary - -- Add per-Ledger `mode` enum (legacy / qbo_bound) and `payment_methods` (text[]) column with data-driven backfill (non-US Deel → ["deel"], everyone else → ["qbo"]). -- New balance rule for `qbo_bound`: only QBO Bill "Paid" status drops a positive host from balance. Negative ContributorAdjustments and DeelInvoiceAdjustments are audit-only. -- Per-Ledger Migrate panel + `member_action`, gated by a no-balance-change invariant. `Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration` surfaces every actionable ledger as a task. `bundle exec rake ledgers:migrate_qbo_bound_zero_drift` bulk-flips any ledger trivially ready. -- New "Payable QBO Bills" page under Money tab, tabbed per QBO account: shows open bills payable through Stacks (`payment_methods` includes `qbo` AND host `payable?`), per-row Refresh + per-tab bulk Refresh. -- Negative-CA validation guard on `qbo_bound` ledgers. -- LedgerWithdrawalRequest apparatus deleted (model, admin, views, services, task discovery). Deel withdrawal trigger now posts directly to `withdraw_via_deel` on the Contributor admin, which calls `DeelInvoiceAdjustments::CreateForLedger` (a port of the Deel-call core from the deleted `ProcessViaDeel`). - -## Spec - -`docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md` - -## Test plan - -- [ ] `bundle exec rails test` passes locally -- [ ] `bundle exec rake ledgers:migrate_qbo_bound_zero_drift` runs and flips empty ledgers automatically -- [ ] Visit a Ledger admin show page → Migrate panel renders with Δ pre/post info -- [ ] Visit Money → Payable QBO Bills → tabs appear, per-tab Refresh button works -- [ ] On a qbo_bound ledger, attempting to create a negative ContributorAdjustment via the admin form is rejected with the expected validation error -- [ ] Withdraw via Deel button only renders for ledgers with `deel` in `payment_methods` - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Capture the PR URL from the output. - ---- - -## Self-Review Notes (pre-execution) - -Spec coverage check: -- **Schema**: Task 1 -- **Ledger mode + payment_methods helpers**: Task 2 -- **Per-host predicates**: Task 3 -- **Ledger balance/unsettled branching**: Task 4 -- **Negative CA validation**: Task 5 -- **QboBoundMigrationCheck service**: Task 6 -- **Migrate UI panel**: Task 7 -- **Rake task for bulk migration**: Task 8 (explicit user requirement) -- **Task Builder discovery + StacksTask routing**: Task 9 -- **PayableQboBills service**: Task 10 -- **RefreshPayableQboBills service**: Task 11 -- **Money admin page + view**: Task 12 -- **DeelInvoiceAdjustments::CreateForLedger**: Task 13 -- **Contributor admin withdraw_via_deel**: Task 14 -- **LedgerWithdrawalRequest deletion**: Tasks 15, 16 -- **Audit script deletion**: Task 17 -- **Final sweep + open PR**: Tasks 18, 19 - -No placeholders. No "TBD". All code shown in steps; all commands explicit. Type/method naming is consistent across tasks: `Ledgers::QboBoundMigrationCheck::Result` referenced by name in Tasks 6, 7, 8; `Money::PayableQboBills::Row` referenced in Tasks 10, 12; `DeelInvoiceAdjustments::CreateForLedger::Error` referenced in Tasks 13, 14. diff --git a/docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md b/docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md deleted file mode 100644 index ea6fc2d8..00000000 --- a/docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md +++ /dev/null @@ -1,539 +0,0 @@ -# QBO-Bound Ledger Cutover - -A controlled, per-ledger cutover from the legacy balance model (negative ContributorAdjustments -and DeelInvoiceAdjustments deduct from balance) to a QBO-bound balance model (only the QBO Bill -"Paid" status drops a host from balance). The cutover is gated per-ledger by a "no resulting -difference" invariant so the financial controller migrates safely, ledger by ledger. - -This spec also removes the LedgerWithdrawalRequest bundling apparatus we built earlier in this PR -(replaced by a direct Deel API call) and introduces a "Payable QBO Bills" controller-facing page -to drive the twice-monthly non-Deel payment cycle. - -## Background - -Today, a Ledger's balance is computed across four overlapping deduction mechanisms: - -1. Positive hosts (ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub) where - `payable?` is true count into `balance`. -2. Negative ContributorAdjustments are inserted by hand to represent off-platform payments - (Deel, S-Corp owner draws, BUS payments, etc.). They negatively offset balance. -3. DeelInvoiceAdjustments arriving via Deel sync deduct unless in a void/reject status - (`deducts_balance?`). -4. SyncsAsQboBill hosts already write a QBO Bill mirror — but the bill's Paid status is - informational; it doesn't affect Stacks balance. - -The legacy world has been workable but is hard to reconcile: there are three places a payment -can be "recorded" and they must be kept consistent by hand. The cutover centralizes on the -QBO Bill Paid status as the single source of truth — a positive host stays in balance until its -corresponding QBO Bill is Paid in QBO. - -A dry-run audit (`script/audit_qbo_cutover_balance_drift.rb`, deleted post-cutover) found that -under the broadest-scope new rule applied to all data, 26 contributors would see balance go -UP, 0 would go DOWN, and the net Σ Δbalance is +$82,430. Most of that is "in-flight payment -cycle" (latest month's bill not yet marked Paid in QBO) that will self-heal once the financial -controller works through the open bills; the remainder is a one-time correction to a small set -of historical ledgers with off-platform payment patterns that never had a corresponding positive -host. - -## Goals - -- Allow each Ledger to opt into the new model independently, with a hard guarantee that the - flip does not change displayed balance/unsettled. -- Surface the migration as actionable work via the existing Task Builder system. -- Replace the contributor-driven withdrawal-request bundling flow with two simpler surfaces: - - A direct Deel API call for contributors paid via Deel. - - A cross-enterprise "Payable QBO Bills" page for the controller's twice-monthly pay cycle. -- Block the negative-CA pattern on qbo_bound ledgers so the cutover sticks. - -## Non-goals - -- Auto-marking QBO bills Paid from any Stacks-side flow (manual via QBO, per Q6 answer). -- Re-imagining Reimbursement or the salary/Justworks PayStub flow. -- Backfilling or modifying historical negative ContributorAdjustments — they sit as audit-only - rows after migration. -- Building a finance-side dashboard beyond the per-ledger migration panel and the Payable QBO - Bills page. - -## Design Decisions (locked from brainstorm) - -- **Q1**: On qbo_bound, only the QBO Bill "Paid" status determines whether a positive host drops - from balance. Negative CAs and DIAs are audit-only. -- **Q2**: The Payable QBO Bills page shows bills where `ledger.payment_methods` includes `qbo` - AND the underlying host is `payable?` (settled in Stacks) AND `qbo_bill.paid? == false`. - Tabbed per QBO account. -- **Q3**: `payment_methods` is backfilled per-ledger from contributor data. With the final - two-value enum (`deel`, `qbo`): non-US Deel contractor → `[deel]`; everyone else → `[qbo]`. -- **Q4**: Migration UI is a per-ledger button on the Ledger admin show page. Task Builder - discovery generates one task per legacy ledger with activity. -- **Q5**: Page row actions: Open in QBO link + per-row Refresh + per-tab bulk Refresh. -- **Q6**: Manual mark-Paid in QBO — no Stacks-initiated QBO writes. -- **Q7**: Negative CAs on qbo_bound ledgers are rejected at model validation. -- **Q8 / clarification**: The Deel withdrawal trigger is the existing contributor-facing form, - but it no longer persists a LedgerWithdrawalRequest. Submit calls Deel API directly. - -## Architecture - -### Schema - -Add two columns to `ledgers`: - -```ruby -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 -``` - -`mode` is named to avoid Rails STI's reserved `type` column. The enum is -`{ legacy: 0, qbo_bound: 1 }`. - -`payment_methods` is a Postgres `text[]` with values drawn from -`Ledger::PAYMENT_METHODS = %w[deel qbo].freeze`. GIN index supports the page-filter query -`WHERE 'qbo' = ANY(payment_methods)`. - -The migration runs a data-driven backfill of `payment_methods` (mode stays `legacy` for every -existing row): - -```ruby -Ledger.find_each do |ledger| - contributor = ledger.contributor - next if contributor.nil? - - # Contributor → DeelPerson (optional belongs_to via deel_person_id). - # DeelPerson#data is the Deel-side JSON payload; country is a 2-letter - # ISO code at data["country"] (verified by probing one record). - dp = contributor.deel_person - country = dp&.data.is_a?(Hash) ? dp.data["country"].to_s.upcase : nil - is_non_us_deel = dp.present? && country.present? && country != "US" - - ledger.update_column(:payment_methods, is_non_us_deel ? %w[deel] : %w[qbo]) -end -``` - -Default for any contributor without a Deel attachment is `["qbo"]` — they're paid via QBO -bill pay anyway and the Payable QBO Bills page is their lane. - -### Runtime balance/unsettled rules - -`Ledger#balance` and `Ledger#unsettled` branch on `mode`: - -```ruby -def balance - case mode - when "legacy" then visible_items.select(&:payable?).sum(&:signed_amount) - when "qbo_bound" then qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount) - end -end - -def unsettled - case mode - when "legacy" then visible_items.reject(&:payable?).sum(&:signed_amount) - when "qbo_bound" then qbo_bound_visible_items.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount) - end -end -``` - -`qbo_bound_visible_items` excludes `DeelInvoiceAdjustment` rows (audit-only) and negative -`ContributorAdjustment` rows (also audit-only). - -Each host class gets `in_balance_under_qbo_bound?`: - -- `ContributorPayout`, `ProfitShare`, `PayStub`, `ContributorAdjustment` (positive): - `payable? && !qbo_bill&.paid?` -- `Trueup`: `!qbo_bill&.paid?` (Trueup has no `payable?`; it's always in balance until paid) -- `Reimbursement`: `accepted?` (no QBO involvement — same as legacy) - -`items_grouped_by_month` continues to render historical negative CAs and DIAs for visibility — -display is independent of balance math. - -### Model validations - -```ruby -# app/models/contributor_adjustment.rb -validate :no_negative_on_qbo_bound_ledger - -def no_negative_on_qbo_bound_ledger - return unless ledger&.qbo_bound? && amount.to_f < 0 - errors.add( - :amount, - "negative adjustments are not allowed on QBO-bound ledgers — mark the corresponding QBO bill Paid instead", - ) -end -``` - -### Migration gate - -```ruby -# app/services/ledgers/qbo_bound_migration_check.rb -class Ledgers::QboBoundMigrationCheck - TOLERANCE = 0.01.freeze - - Result = Struct.new( - :current_balance, :current_unsettled, - :proposed_balance, :proposed_unsettled, - :balance_delta, :unsettled_delta, - :ready?, :blocking_bills, :ignored_negative_cas, - keyword_init: true, - ) - - def self.call(ledger) - legacy_b, legacy_u = compute_legacy(ledger) - new_b, new_u = compute_qbo_bound(ledger) - db = (new_b - legacy_b).round(2) - du = (new_u - legacy_u).round(2) - - Result.new( - current_balance: legacy_b, current_unsettled: legacy_u, - proposed_balance: new_b, proposed_unsettled: new_u, - balance_delta: db, unsettled_delta: du, - ready?: db.abs < TOLERANCE && du.abs < TOLERANCE, - blocking_bills: collect_blocking_bills(ledger), - ignored_negative_cas: ledger.contributor_adjustments.where("amount < 0").to_a, - ) - end -end -``` - -A `member_action :migrate_to_qbo_bound` on the Ledger admin show page invokes the service. If -`ready?`, flips `mode` to `qbo_bound`. Otherwise renders the discrepancy + blocking bills on -the panel for the controller to reconcile. - -### Rake task for bulk auto-migration - -```ruby -# lib/tasks/ledgers.rake -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, blocked, errors = 0, 0, 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 -``` - -Intended use: run after the schema migration, then re-run after each controller-reconciliation -session — anything that lands at net-zero flips automatically without manual button-pressing. - -### Task Builder discovery - -```ruby -# lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb -class Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration < - Stacks::TaskBuilder::Discoveries::Base - - PAYABLE_TABLES = %w[contributor_payouts contributor_adjustments profit_shares pay_stubs trueups].freeze - - def tasks - 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) - .find_each.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 -``` - -`StacksTask` already has a `when Ledger` branch in `subject_display_name` (uses -`"#{email} on #{enterprise.name}"`) — reusable as-is. The `subject_url` branch currently routes -every `Ledger` subject to `edit_admin_contributor_path(subject.contributor)`. We branch on -`type` so the migration task deep-links to the Ledger admin show page where the panel lives: - -```ruby -# in StacksTask#subject_url -when Ledger - if type == :legacy_ledger_needs_qbo_migration - helpers.admin_ledger_path(subject) - else - helpers.edit_admin_contributor_path(subject.contributor) - end -``` - -The Migrate panel renders inside `app/admin/ledgers.rb`'s `show` action via an -ActiveAdmin `sidebar` or inline panel. - -### Payable QBO Bills page - -Routed under the existing `app/admin/money.rb` ActiveAdmin page (currently a redirect). -Rewrite as: - -```ruby -ActiveAdmin.register_page "Money" do - menu priority: 50 - - page_action :payable_qbo_bills, method: :get do - @qbo_accounts = QboAccount.order(:id).to_a - @active_qa = params[:qbo_account_id].present? ? QboAccount.find(params[:qbo_account_id]) : @qbo_accounts.first - @rows = Money::PayableQboBills.call(qbo_account: @active_qa) if @active_qa - render "admin/money/payable_qbo_bills" - end - - page_action :refresh_bill, method: :post do - host = host_from_params!(params) - 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 - Money::RefreshPayableQboBills.call(qbo_account: QboAccount.find(params[:qbo_account_id])) - redirect_back(fallback_location: admin_money_payable_qbo_bills_path(qbo_account_id: params[:qbo_account_id])) - end -end -``` - -Row-selection service: - -```ruby -class Money::PayableQboBills - HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].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) - .where(enterprises: { qbo_account_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.qbo_bill rescue nil - 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 -``` - -Bulk refresh walks the same row set and calls `sync_qbo_bill!` on each host. - -View layout: - -- Top: tabs, one per QBO account, with `[Refresh all on this tab]` button. -- Body: rows grouped by contributor (sum + count in the group header), each row showing - the host class, host ID, amount, an external link to the QBO bill, and a per-row Refresh - button. - -### Deel withdrawal trigger (replaces LedgerWithdrawalRequest) - -The contributor-facing form (whatever its current path/UX — amount up to balance + contract -picker) is re-mounted as a `member_action :withdraw_via_deel` on `app/admin/contributors.rb`. -On submit: - -```ruby -member_action :withdraw_via_deel, method: :post do - ledger = Ledger.find(params.require(:ledger_id)) - unless ledger.deel_enabled? - redirect_back fallback_location: admin_contributor_path(resource), alert: "Deel not enabled for this ledger." - return - end - DeelInvoiceAdjustments::CreateForLedger.call( - ledger: ledger, - amount: params.require(:amount), - contract_id: params.require(:contract_id), - description: params[:description].to_s, - date_submitted: params[:date_submitted].presence || Date.current, - initiated_by: current_admin_user, - ) - redirect_back fallback_location: admin_contributor_path(resource), notice: "Withdrew via Deel." -rescue DeelInvoiceAdjustments::CreateForLedger::Error => e - redirect_back fallback_location: admin_contributor_path(resource), alert: e.message -end -``` - -`DeelInvoiceAdjustments::CreateForLedger` is the Deel-API-call core of the existing -`LedgerWithdrawalRequests::ProcessViaDeel` service, lifted out and de-coupled from the request -state machine. It calls Deel, persists a `DeelInvoiceAdjustment` row via -`DeelInvoiceAdjustment.create_from_deel_response!`, and raises a wrapped error on failure. - -On qbo_bound ledgers the resulting DIA is audit-only — it appears in the timeline but does not -affect balance. The controller must mark the corresponding QBO bills Paid in QBO separately -(visible on the Payable QBO Bills page). - -The trigger button is gated on `ledger.deel_enabled?` (i.e., `"deel"` ∈ `payment_methods`). - -## Components and Boundaries - -| Component | Purpose | Depends on | -|---|---|---| -| `Ledger#mode` enum + `#payment_methods` | Per-ledger feature flags + payout method list | n/a (schema column) | -| Per-host `#in_balance_under_qbo_bound?` | One-line predicate per host class deciding balance vs unsettled in the new rule | `qbo_bill`, `paid?`, `payable?`, `accepted?` | -| `Ledgers::QboBoundMigrationCheck` | Computes pre/post balance + unsettled, blocking bills | `Ledger`, `QboBill`, host classes | -| `Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration` | Emits one task per actionable legacy ledger | `Ledger`, `Enterprise`, `QboAccount` | -| `Money::PayableQboBills` | Selects rows for the page | host classes, `Ledger#payment_methods`, `QboBill` | -| `Money::RefreshPayableQboBills` | Bulk re-sync open bills for an enterprise | `SyncsAsQboBill#sync_qbo_bill!` | -| `DeelInvoiceAdjustments::CreateForLedger` | Wraps the Deel API call + persistence | Deel SDK, `DeelInvoiceAdjustment` | -| `lib/tasks/ledgers.rake :migrate_qbo_bound_zero_drift` | Bulk auto-flip | `QboBoundMigrationCheck` | - -Each unit can be exercised in isolation: - -- The migration check service takes a `Ledger` and returns a `Result` struct — no I/O beyond - reading the ledger's host rows. Tests stub fixtures and assert deltas. -- The Payable QBO Bills page service takes a `QboAccount`, returns an array of `Row`. Tested - by fixture combinations. -- The `CreateForLedger` service is the only place that touches the Deel API. Tested by - stubbing the Deel HTTP client. -- The negative-CA validation is a single Active Record `validate` callback — tested via - `valid?` assertions on a model instance. - -## Data Flow - -### Migration happy path - -1. Schema migration runs; every ledger has `mode: :legacy` and a backfilled `payment_methods`. -2. Task Builder runs; one `:legacy_ledger_needs_qbo_migration` task per legacy ledger with - activity appears in the controller's task list. -3. Controller opens a task → deep-links to the Ledger admin show page. -4. Migrate panel shows current vs proposed balance + Δ; if Δ < $0.01 on both, the Migrate - button is enabled. Otherwise the controller sees the blocking bill list. -5. Controller marks bills Paid in QBO (via the linked URLs), comes back, clicks Re-check. -6. When ready, clicks Migrate. `mode` flips to `qbo_bound`. The task disappears on next discovery - run. - -### Payable QBO Bills happy path - -1. Controller opens Money → Payable QBO Bills, picks a QBO account tab. -2. Page lists open bills with `payment_methods.include?(:qbo) && host.payable?`. Bills bound - to Deel-only ledgers (e.g., non-US contractors) do not appear. -3. Controller pays bills in QBO (via per-row links). -4. Clicks "Refresh all on this tab" → bulk `sync_qbo_bill!` → newly-Paid bills drop from the - list. - -### Deel withdrawal happy path - -1. Contributor opens their show page, clicks Withdraw via Deel (button visible iff - `ledger.deel_enabled?`). -2. Form submits an amount (≤ current balance) → `DeelInvoiceAdjustments::CreateForLedger` calls - Deel → DIA row persists. -3. On legacy: DIA deducts from balance (today's rule). -4. On qbo_bound: DIA is audit-only — visible in timeline, no balance impact. Controller - separately marks corresponding QBO bills Paid via the Payable QBO Bills page. - -## Error handling - -- `DeelInvoiceAdjustments::CreateForLedger::Error` wraps Deel API failures so the controller - redirects with a flash, not a 500. -- `Ledgers::QboBoundMigrationCheck` never raises; ineligible/empty ledgers return a Result with - `ready?: true` (trivially) and an empty `blocking_bills` list — the rake task flips them - freely. -- `Money::PayableQboBills` rescues `qbo_bill` access errors with `rescue nil` (existing - pattern); a host with a broken QboBill linkage is skipped, not failed. -- Negative-CA-on-qbo_bound rejection happens at `valid?` time — no AR-level abort surprises. - -## Code we delete (full deletion list) - -| File | Disposition | -|---|---| -| `db/migrate/20260606135814_create_ledger_withdrawal_requests.rb` | Delete — never deployed; tables not needed | -| `app/models/ledger_withdrawal_request.rb` | Delete | -| `app/models/ledger_withdrawal_request_bill.rb` | Delete | -| `app/admin/ledger_withdrawal_requests.rb` | Delete | -| `app/views/admin/ledger_withdrawal_requests/_show.html.erb` | Delete | -| `app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb` | Delete | -| `app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb` | Delete | -| `app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb` | Delete | -| `app/services/ledger_withdrawal_requests/process_via_deel.rb` | Delete (Deel-call core extracted to `DeelInvoiceAdjustments::CreateForLedger`) | -| `lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb` | Delete | -| Registration line in `lib/stacks/task_builder.rb` | Remove discovery from registry | -| References in `app/admin/deel_invoice_adjustments.rb` | Remove (any cross-links to withdrawal requests) | -| References in `app/models/admin_authorization.rb` | Remove (permission rules for the deleted admin page) | -| Splice into `app/views/admin/contributors/_show.html.erb` for `LedgerWithdrawalRequest` rendering | Remove | -| Contributor-side "new withdrawal" launch button in `app/admin/contributors.rb` | Repoint to new `withdraw_via_deel` action; remove withdrawal-request linkage | -| `Contributor#ledger_withdrawal_requests_with_deleted` + `preload_for_ledger_view!` add-ons | Remove | -| `Ledger#all_items_with_deleted` `LedgerWithdrawalRequest` line | Remove | -| `Ledger#has_many :ledger_withdrawal_requests` association | Remove | -| `StacksTask` `LedgerWithdrawalRequest` branches in `subject_display_name`, `subject_url` | Remove (Ledger branch repurposed for migration tasks) | -| `script/audit_qbo_cutover_balance_drift.rb` | Delete | -| `script/accountant_reconciliation_worklist.rb` | Delete | -| `script/why_balance_goes_up.rb` | Delete | - -## Testing strategy - -### Unit tests - -- `Ledger#balance` / `#unsettled` mode-branching: one legacy fixture, one qbo_bound fixture - with mixed paid/unpaid bills, neg CAs, DIAs — assert each rule's inclusion/exclusion. -- `Ledgers::QboBoundMigrationCheck`: three scenarios — ready (Δ < $0.01), - blocked-by-open-bills (Δ > 0), blocked-by-neg-CA-mismatch (Δ > 0). Assert `blocking_bills` - lists the right rows. Trivial-empty ledger returns ready. -- `ContributorAdjustment` negative-CA validation: allowed on legacy; rejected on qbo_bound - with the right error message; positive CAs unaffected by mode. -- `Ledger#payment_methods` helpers: `deel_enabled?`, `qbo_enabled?` — array membership truth. -- `Money::PayableQboBills.call(qbo_account:)`: returns only `payable?` rows from ledgers - whose `payment_methods` includes `qbo`; excludes Paid bills; sorts by contributor. -- `Money::RefreshPayableQboBills`: stubs `sync_qbo_bill!`, asserts called for each row. -- `DeelInvoiceAdjustments::CreateForLedger`: stubs Deel API, asserts DIA created with correct - fields; raises wrapped error on Deel failure. -- `Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration`: legacy ledger with - activity → task; qbo_bound ledger → no task; legacy ledger without QBO account → no task. -- `lib/tasks/ledgers.rake migrate_qbo_bound_zero_drift`: with mixed fixtures, flips only - ready ledgers and reports counts correctly. - -### Migration backfill test - -A migration test that loads a snapshot fixture (US-Deel contributor, non-US-Deel contributor, -QBO-only vendor contributor) and asserts post-migration `payment_methods` matches the -inference rule. - -### ActiveAdmin system tests - -- Controller logs in, opens a legacy Ledger admin page, sees the Migrate panel with a - discrepancy. Flips the underlying QBO bill Paid via fixture mutation. Clicks Re-check. - Panel says Ready. Clicks Migrate. Ledger flips to `qbo_bound`. -- Controller opens the Payable QBO Bills page, sees rows for one QBO account, clicks - per-row Refresh on a row whose QboBill fixture has flipped to Paid — row disappears. -- Contributor's show page renders the Withdraw via Deel button only when - `ledger.payment_methods.include?("deel")`. - -### Removed test coverage - -The existing `LedgerWithdrawalRequest`-related model, service, and admin tests are deleted -along with the code. Substantive Deel-API-call coverage moves to -`DeelInvoiceAdjustments::CreateForLedger`'s tests — no loss of meaningful assertions. - -## Rollout - -1. Merge this PR. Schema migration runs, payment_methods backfilled. -2. Operator runs `bundle exec rake ledgers:migrate_qbo_bound_zero_drift`. Every ledger whose - balance/unsettled wouldn't change flips immediately. -3. Task Builder runs (existing cron). Tasks appear for each remaining legacy ledger. -4. Controller works through tasks in the admin UI, reconciling bills in QBO and clicking - Migrate. -5. Twice-monthly: controller works through the Payable QBO Bills page tab-by-tab to pay - open bills. -6. Deel-only ledgers: contributor self-services via the Withdraw via Deel button as before. - -When every ledger has flipped, the `legacy` branch of `Ledger#balance` can be removed and the -column dropped — out of scope for this PR. 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 index 37aaddfc..f6730717 100644 --- a/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb +++ b/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb @@ -11,6 +11,7 @@ class LegacyLedgersPendingQboMigration < Base profit_shares pay_stubs trueups + reimbursements ].freeze def tasks diff --git a/test/integration/ledger_migration_test.rb b/test/integration/ledger_migration_test.rb index 1ae8bd74..edde48dc 100644 --- a/test/integration/ledger_migration_test.rb +++ b/test/integration/ledger_migration_test.rb @@ -45,6 +45,14 @@ class LedgerMigrationTest < ActionDispatch::IntegrationTest 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) diff --git a/test/models/ledger_test.rb b/test/models/ledger_test.rb index 1d823c1c..0f1f7e43 100644 --- a/test/models/ledger_test.rb +++ b/test/models/ledger_test.rb @@ -186,61 +186,53 @@ class LedgerModeAndPaymentMethodsTest < ActiveSupport::TestCase test "PAYMENT_METHODS is the canonical list" do assert_equal %w[deel qbo], Ledger::PAYMENT_METHODS end -end -class HostInBalanceUnderQboBoundTest < ActiveSupport::TestCase - test "DeelInvoiceAdjustment is never in balance under qbo_bound" do - dia = DeelInvoiceAdjustment.new(amount: 100, deel_status: "approved") - refute dia.in_balance_under_qbo_bound? + 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 "Reimbursement uses accepted? for qbo_bound" do - r_accepted = Reimbursement.new - r_accepted.stubs(:accepted?).returns(true) - assert r_accepted.in_balance_under_qbo_bound? - - r_pending = Reimbursement.new - r_pending.stubs(:accepted?).returns(false) - refute r_pending.in_balance_under_qbo_bound? + 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 "ContributorPayout: in balance when payable and qbo_bill unpaid" do - cp = ContributorPayout.new - cp.stubs(:payable?).returns(true) - cp.stubs(:qbo_bill).returns(nil) - assert cp.in_balance_under_qbo_bound? - - paid = mock("qbo_bill") - paid.stubs(:paid?).returns(true) - cp.stubs(:qbo_bill).returns(paid) - refute cp.in_balance_under_qbo_bound? - - cp.stubs(:payable?).returns(false) - cp.stubs(:qbo_bill).returns(nil) - refute cp.in_balance_under_qbo_bound? + 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 "Trueup: in balance when qbo_bill unpaid (payable? always true; bill state governs)" do - t = Trueup.new - t.stubs(:qbo_bill).returns(nil) - assert t.in_balance_under_qbo_bound? - - paid = mock("qbo_bill") - paid.stubs(:paid?).returns(true) - t.stubs(:qbo_bill).returns(paid) - refute t.in_balance_under_qbo_bound? + 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 "ProfitShare, PayStub, ContributorAdjustment all follow the payable?-and-unpaid pattern" do - [ProfitShare, PayStub, ContributorAdjustment].each do |klass| - h = klass.new - h.stubs(:payable?).returns(true) - h.stubs(:qbo_bill).returns(nil) - assert h.in_balance_under_qbo_bound?, "#{klass.name} should be in balance when payable and unpaid" + 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 @@ -263,7 +255,6 @@ class LedgerBalanceUnderQboBoundTest < ActiveSupport::TestCase payout = mock("payout") payout.stubs(:payable?).returns(true) payout.stubs(:qbo_bill).returns(paid) - payout.stubs(:in_balance_under_qbo_bound?).returns(false) payout.stubs(:signed_amount).returns(100) payout.stubs(:is_a?).returns(false) payout.stubs(:is_a?).with(DeelInvoiceAdjustment).returns(false) @@ -278,7 +269,6 @@ class LedgerBalanceUnderQboBoundTest < ActiveSupport::TestCase @ledger.update!(mode: :qbo_bound) pending = mock("pending_payout") pending.stubs(:payable?).returns(false) - pending.stubs(:in_balance_under_qbo_bound?).returns(false) pending.stubs(:signed_amount).returns(100) pending.stubs(:qbo_bill).returns(nil) pending.stubs(:is_a?).returns(false) @@ -315,4 +305,22 @@ class LedgerBalanceUnderQboBoundTest < ActiveSupport::TestCase 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 index 9420cece..3620909a 100644 --- a/test/services/ledgers/qbo_bound_migration_check_test.rb +++ b/test/services/ledgers/qbo_bound_migration_check_test.rb @@ -42,7 +42,6 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase # 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(:in_balance_under_qbo_bound?).returns(true) payable_payout.stubs(:signed_amount).returns(100) payable_payout.stubs(:qbo_bill).returns(nil) payable_payout.stubs(:is_a?).returns(false) @@ -62,7 +61,6 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase 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(:in_balance_under_qbo_bound?).returns(true) payable_payout.stubs(:signed_amount).returns(100) payable_payout.stubs(:qbo_bill).returns(nil) payable_payout.stubs(:is_a?).returns(false) @@ -90,7 +88,6 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase cp.stubs(:payable?).returns(true) cp.stubs(:qbo_bill).returns(paid_qb) cp.stubs(:signed_amount).returns(100) - cp.stubs(:in_balance_under_qbo_bound?).returns(false) neg_ca = ContributorAdjustment.new(amount: -50) neg_ca.stubs(:signed_amount).returns(-50)
<%= check_box_tag "lwr_select_all", "1", true, + class: "lwr-select-all", + title: "Toggle all" %> Date Type Amount
<%= check_box_tag "ledger_withdrawal_request[selected_bill_keys][]", key, false %><%= check_box_tag "ledger_withdrawal_request[selected_bill_keys][]", key, true, + class: "lwr-bill-checkbox", + data: { amount: row.amount } %> <%= row.effective_on %> <%= row.description %> <%= number_to_currency(row.amount) %> <% if li.is_a?(PayStub) %> Pay Stub + <% elsif li.is_a?(LedgerWithdrawalRequest) %> + Withdrawal Request <% else %> <%= li.model_name.human %> @@ -181,6 +185,20 @@ <% end %> + <% elsif li.is_a?(LedgerWithdrawalRequest) %> + <% if li.cancelled? %> + Cancelled + <% elsif li.processed? %> + + <% else %> + + Pending + <%= li.paid_bills.size %> / <%= li.bills.size %> bills paid + + <% end %> <% end %> @@ -268,10 +286,13 @@ <% end %> + <% elsif li.is_a?(LedgerWithdrawalRequest) %> + <%= number_to_currency(li.total_amount) %> + No balance impact <% 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) %> @@ -286,6 +307,8 @@ <%= link_to "Info ↗", admin_contributor_deel_invoice_adjustment_path(contributor, li) %> <% elsif li.is_a?(PayStub) %> <%= link_to "Info ↗", admin_pay_cycle_pay_stub_path(li.pay_cycle, li) %> + <% elsif li.is_a?(LedgerWithdrawalRequest) %> + <%= link_to "Request ##{li.id} ↗", admin_ledger_withdrawal_request_path(li) %> <% end %> <% end %> From 758b6b84683f73762449f057fc19f813cbfedb4b Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 17:19:41 -0400 Subject: [PATCH 24/67] Render withdrawal-request amount with a leading minus Semantically the request represents money flowing out of the contributor's balance, mirroring the sign convention DeelInvoice Adjustment uses for deductive rows. The muted italic still signals it's a forecasted / tombstone amount, not a balance-affecting one. Co-Authored-By: Claude Opus 4.7 --- app/views/admin/contributors/_show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/admin/contributors/_show.html.erb b/app/views/admin/contributors/_show.html.erb index 9b56f0a2..f164bdb4 100644 --- a/app/views/admin/contributors/_show.html.erb +++ b/app/views/admin/contributors/_show.html.erb @@ -287,7 +287,7 @@ <% end %> <% elsif li.is_a?(LedgerWithdrawalRequest) %> - <%= number_to_currency(li.total_amount) %> + - <%= number_to_currency(li.total_amount) %> <% end %> From 1e213617142d4795e17647e252cac62f31b63e68 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 17:37:00 -0400 Subject: [PATCH 25/67] Dry-run audit script for the QBO-cutover balance invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Computes, per contributor, the delta between today's balance and the balance we'd see under the proposed new rules where: - DeelInvoiceAdjustment no longer deducts from balance - SyncsAsQboBill hosts (ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub) drop out of balance when their QboBill mirror is Paid Reports total drift, count up vs. down, and the top 20 affected contributors by |delta|. Not committed to a rake task or migration yet — we wanted the data before deciding the shape of the cutover. Co-Authored-By: Claude Opus 4.7 --- script/audit_qbo_cutover_balance_drift.rb | 124 ++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 script/audit_qbo_cutover_balance_drift.rb diff --git a/script/audit_qbo_cutover_balance_drift.rb b/script/audit_qbo_cutover_balance_drift.rb new file mode 100644 index 00000000..9bbc384c --- /dev/null +++ b/script/audit_qbo_cutover_balance_drift.rb @@ -0,0 +1,124 @@ +# Dry-run audit for the QBO-cutover balance invariant. +# +# What it does: for every contributor whose balance could plausibly shift +# under the proposed cutover (anyone with a deducting DeelInvoiceAdjustment +# OR a paid-in-QBO bill), compute their current balance and their would-be +# balance under the new rules. Report non-zero deltas. +# +# Run with: bundle exec rails runner script/audit_qbo_cutover_balance_drift.rb +# +# New-rule semantics: +# - DeelInvoiceAdjustment no longer affects balance (deducts_balance? → false going forward) +# - SyncsAsQboBill hosts (ContributorPayout / ContributorAdjustment / ProfitShare / +# Trueup / PayStub) drop out of balance when their QBO Bill mirror is Paid + +QBO_HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].freeze + +def post_cutover_balance(ledger_items) + ledger_items[:all].reduce({ balance: 0, unsettled: 0 }) do |acc, li| + next acc if li.respond_to?(:deleted_at) && li.deleted_at.present? + next acc if li.is_a?(DeelInvoiceAdjustment) # no longer deducts under new rules + + if li.is_a?(ContributorPayout) + if li.payable? + next acc if li.qbo_bill&.paid? + acc[:balance] += li.amount + else + acc[:unsettled] += li.amount + end + elsif li.is_a?(Reimbursement) + if li.accepted? + acc[:balance] += li.amount + else + acc[:unsettled] += li.amount + end + elsif li.is_a?(Trueup) + next acc if li.qbo_bill&.paid? + acc[:balance] += li.amount + elsif li.is_a?(ProfitShare) + if li.payable? + next acc if li.qbo_bill&.paid? + acc[:balance] += li.amount + else + acc[:unsettled] += li.amount + end + elsif li.is_a?(ContributorAdjustment) + if li.payable? + next acc if li.qbo_bill&.paid? + acc[:balance] += li.amount + else + acc[:unsettled] += li.amount + end + elsif li.is_a?(PayStub) + if li.payable? + next acc if li.qbo_bill&.paid? + acc[:balance] += li.amount + else + acc[:unsettled] += li.amount + end + end + acc + end +end + +# Candidates: anyone with at least one row that the cutover could touch. +dia_contrib_ids = + DeelInvoiceAdjustment.joins(:ledger).where(deleted_at: nil).pluck("ledgers.contributor_id").uniq +paid_qbo_pairs = + QboBill.pluck(:qbo_account_id, :qbo_id, :data).filter_map do |qa_id, qbo_id, data| + balance = data&.dig("balance") + next nil if balance.nil? + next nil unless balance.to_f <= 0 + [qa_id, qbo_id] + end + +paid_host_contrib_ids = QBO_HOST_KLASSES.flat_map do |klass| + next [] unless klass.column_names.include?("qbo_account_id") && klass.column_names.include?("qbo_bill_id") + paid_qbo_pairs.flat_map do |qa_id, qbo_id| + klass.where(qbo_account_id: qa_id, qbo_bill_id: qbo_id).joins(:ledger).pluck("ledgers.contributor_id") + end +end.uniq + +candidate_ids = (dia_contrib_ids + paid_host_contrib_ids).uniq +puts "Candidates: #{candidate_ids.size} contributor(s) (#{dia_contrib_ids.size} have DIA, #{paid_host_contrib_ids.size} have paid-in-QBO bills)" +puts + +affected = [] + +Contributor.unscoped.where(id: candidate_ids).find_each do |c| + next if c.forecast_person.nil? + c.preload_for_ledger_view! + items = c.all_items_grouped_by_month(false) + + current = c.new_deal_balance(items) + proposed = post_cutover_balance(items) + + d_bal = (proposed[:balance] - current[:balance]).to_f.round(2) + d_uns = (proposed[:unsettled] - current[:unsettled]).to_f.round(2) + next if d_bal.abs < 0.01 && d_uns.abs < 0.01 + + affected << { + id: c.id, + email: c.forecast_person.email, + cur_bal: current[:balance].to_f.round(2), + new_bal: proposed[:balance].to_f.round(2), + d_bal: d_bal, + d_uns: d_uns, + } +end + +puts "Affected: #{affected.size} contributor(s) with non-zero delta" +puts +if affected.any? + total_d_bal = affected.sum { |r| r[:d_bal] }.round(2) + total_d_uns = affected.sum { |r| r[:d_uns] }.round(2) + pos = affected.count { |r| r[:d_bal] > 0 } + neg = affected.count { |r| r[:d_bal] < 0 } + puts " Sum Δbalance: #{total_d_bal} (Δunsettled: #{total_d_uns})" + puts " #{pos} would go UP (under-deducted historically), #{neg} would go DOWN (over-deducted)" + puts + puts " Top 20 by |Δbalance|:" + affected.sort_by { |r| -r[:d_bal].abs }.first(20).each do |r| + puts " ##{r[:id]} #{r[:email]}: $#{r[:cur_bal]} → $#{r[:new_bal]} (Δ#{r[:d_bal]})" + end +end From 6165d6bf87470e2935361aed3ba9b9f25607570e Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 17:44:01 -0400 Subject: [PATCH 26/67] Audit: also model deletion of off-platform payment offsets in CAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scopes for the 'delete the manual offset rows' question: STRICT — CAs referencing a Deel URL MID — CAs starting with 'Misc payment:' (covers Deel, Justworks, BUS, S-Corp draws — every off-platform offset pattern) BROAD — every negative ContributorAdjustment Each scope assumes the corresponding QBO Bill that the CA was offsetting has since been marked Paid in QBO, so under the new rules it drops out of balance naturally. Co-Authored-By: Claude Opus 4.7 --- script/audit_qbo_cutover_balance_drift.rb | 91 +++++++++++++++++------ 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/script/audit_qbo_cutover_balance_drift.rb b/script/audit_qbo_cutover_balance_drift.rb index 9bbc384c..dfd4ec73 100644 --- a/script/audit_qbo_cutover_balance_drift.rb +++ b/script/audit_qbo_cutover_balance_drift.rb @@ -14,10 +14,31 @@ QBO_HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].freeze -def post_cutover_balance(ledger_items) +# Three deletion scopes for the "off-platform payment offset" pattern: +# :strict — negative CAs whose description references a Deel URL +# :mid — negative CAs whose description starts with "Misc payment:" +# (covers Deel, Justworks, BUS, S-Corp draws, etc. — all +# off-platform offsets entered by hand) +# :broad — every negative ContributorAdjustment +# +# Each scope assumes the corresponding QBO Bill that the CA was offsetting +# has since been marked Paid in QBO (so under the new rules it drops out +# of balance naturally). +def deletion_scope_matches?(li, scope) + return false unless li.is_a?(ContributorAdjustment) + return false unless li.amount.to_f < 0 + case scope + when :strict then li.description.to_s.match?(/deel\.com/i) + when :mid then li.description.to_s.start_with?("Misc payment:") + when :broad then true + end +end + +def post_cutover_balance(ledger_items, scope:) ledger_items[:all].reduce({ balance: 0, unsettled: 0 }) do |acc, li| next acc if li.respond_to?(:deleted_at) && li.deleted_at.present? next acc if li.is_a?(DeelInvoiceAdjustment) # no longer deducts under new rules + next acc if deletion_scope_matches?(li, scope) # treat as deleted if li.is_a?(ContributorPayout) if li.payable? @@ -79,11 +100,17 @@ def post_cutover_balance(ledger_items) end end.uniq -candidate_ids = (dia_contrib_ids + paid_host_contrib_ids).uniq -puts "Candidates: #{candidate_ids.size} contributor(s) (#{dia_contrib_ids.size} have DIA, #{paid_host_contrib_ids.size} have paid-in-QBO bills)" +neg_ca_contrib_ids = + ContributorAdjustment.where("amount < 0").joins(:ledger).pluck("ledgers.contributor_id").uniq + +candidate_ids = (dia_contrib_ids + paid_host_contrib_ids + neg_ca_contrib_ids).uniq +puts "Candidates: #{candidate_ids.size} contributor(s)" +puts " with DIA: #{dia_contrib_ids.size}" +puts " with paid-in-QBO bills: #{paid_host_contrib_ids.size}" +puts " with negative CA rows: #{neg_ca_contrib_ids.size}" puts -affected = [] +results_per_scope = { strict: [], mid: [], broad: [] } Contributor.unscoped.where(id: candidate_ids).find_each do |c| next if c.forecast_person.nil? @@ -91,34 +118,50 @@ def post_cutover_balance(ledger_items) items = c.all_items_grouped_by_month(false) current = c.new_deal_balance(items) - proposed = post_cutover_balance(items) - d_bal = (proposed[:balance] - current[:balance]).to_f.round(2) - d_uns = (proposed[:unsettled] - current[:unsettled]).to_f.round(2) - next if d_bal.abs < 0.01 && d_uns.abs < 0.01 - - affected << { - id: c.id, - email: c.forecast_person.email, - cur_bal: current[:balance].to_f.round(2), - new_bal: proposed[:balance].to_f.round(2), - d_bal: d_bal, - d_uns: d_uns, - } + [:strict, :mid, :broad].each do |scope| + proposed = post_cutover_balance(items, scope: scope) + d_bal = (proposed[:balance] - current[:balance]).to_f.round(2) + d_uns = (proposed[:unsettled] - current[:unsettled]).to_f.round(2) + next if d_bal.abs < 0.01 && d_uns.abs < 0.01 + results_per_scope[scope] << { + id: c.id, + email: c.forecast_person.email, + cur_bal: current[:balance].to_f.round(2), + new_bal: proposed[:balance].to_f.round(2), + d_bal: d_bal, + d_uns: d_uns, + } + end end -puts "Affected: #{affected.size} contributor(s) with non-zero delta" -puts -if affected.any? +[:strict, :mid, :broad].each do |scope| + affected = results_per_scope[scope] + label = case scope + when :strict then 'STRICT — delete CAs whose description references a Deel URL' + when :mid then 'MID — delete CAs starting with "Misc payment:"' + when :broad then 'BROAD — delete every negative CA' + end + puts '=' * 78 + puts "SCENARIO: #{label}" + puts '=' * 78 + if affected.empty? + puts " Zero drift on all candidates — invariant holds." + puts + next + end total_d_bal = affected.sum { |r| r[:d_bal] }.round(2) total_d_uns = affected.sum { |r| r[:d_uns] }.round(2) pos = affected.count { |r| r[:d_bal] > 0 } neg = affected.count { |r| r[:d_bal] < 0 } - puts " Sum Δbalance: #{total_d_bal} (Δunsettled: #{total_d_uns})" - puts " #{pos} would go UP (under-deducted historically), #{neg} would go DOWN (over-deducted)" + near_zero = affected.count { |r| r[:d_bal].abs < 1.0 } + puts " Affected: #{affected.size} contributor(s) with non-zero delta" + puts " Δbalance: #{total_d_bal} sum (#{pos} UP, #{neg} DOWN, #{near_zero} within $1)" + puts " Δunsettled: #{total_d_uns}" puts - puts " Top 20 by |Δbalance|:" - affected.sort_by { |r| -r[:d_bal].abs }.first(20).each do |r| + puts " Top 15 by |Δbalance|:" + affected.sort_by { |r| -r[:d_bal].abs }.first(15).each do |r| puts " ##{r[:id]} #{r[:email]}: $#{r[:cur_bal]} → $#{r[:new_bal]} (Δ#{r[:d_bal]})" end + puts end From 09a2a740cbc391f95a7093bd8e0d3f2888902ed2 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 17:54:04 -0400 Subject: [PATCH 27/67] Accountant-facing worklist for the proposed QBO cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models the cutover where: - every negative ContributorAdjustment is deleted (they're all off-platform payment offsets; positive CAs survive as legit upward adjustments) - DeelInvoiceAdjustment no longer deducts (audit trail only) - SyncsAsQboBill hosts drop out of balance when their QboBill mirror is Paid For each contributor, prints current balance, post-cutover balance, delta, and the open QBO bills traceable to them. Cohorts split into UP (under-recorded in QBO — accountant marks Bills as Paid), DOWN (over-recorded in QBO — review), and FLAT (no balance change but bills to confirm). Open-bill lookup walks every host row via the qbo_bill helper rather than a raw column join, because only ContributorAdjustment carries qbo_account_id — the others route through qbo_account_for_bill on the SyncsAsQboBill concern. Co-Authored-By: Claude Opus 4.7 --- script/accountant_reconciliation_worklist.rb | 143 +++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 script/accountant_reconciliation_worklist.rb diff --git a/script/accountant_reconciliation_worklist.rb b/script/accountant_reconciliation_worklist.rb new file mode 100644 index 00000000..1e65b18c --- /dev/null +++ b/script/accountant_reconciliation_worklist.rb @@ -0,0 +1,143 @@ +# Accountant-facing reconciliation worklist for the proposed cutover. +# +# Model under audit: +# - Every negative ContributorAdjustment is deleted (they all represent +# payments already made off-platform; positive CAs survive because +# they're upward adjustments, not payment offsets) +# - DeelInvoiceAdjustment no longer deducts (audit trail only) +# - SyncsAsQboBill hosts drop out of balance when their QboBill mirror +# is Paid +# +# After the accountant goes through every affected contributor's open +# QBO bills and marks the ones that have genuinely been paid, the +# Stacks balance should converge to the QBO truth. + +QBO_HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].freeze + +def post_cutover_balance(ledger_items) + ledger_items[:all].reduce({ balance: 0, unsettled: 0 }) do |acc, li| + next acc if li.respond_to?(:deleted_at) && li.deleted_at.present? + next acc if li.is_a?(DeelInvoiceAdjustment) + next acc if li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 + + case li + when ContributorPayout, ProfitShare, ContributorAdjustment, PayStub + if li.payable? + next acc if li.qbo_bill&.paid? + acc[:balance] += li.amount + else + acc[:unsettled] += li.amount + end + when Trueup + next acc if li.qbo_bill&.paid? + acc[:balance] += li.amount + when Reimbursement + if li.accepted? + acc[:balance] += li.amount + else + acc[:unsettled] += li.amount + end + end + acc + end +end + +# Open QBO bills traceable to a contributor via any host class. +# Walk every host row that has a qbo_bill_id set, route through the +# per-class qbo_account_for_bill helper (ContributorPayout / PayStub / +# ProfitShare / Trueup don't have a qbo_account_id column — only +# ContributorAdjustment does — so a raw column lookup misses them). +open_bills_by_contributor = Hash.new { |h, k| h[k] = [] } + +QBO_HOST_KLASSES.each do |klass| + klass.where.not(qbo_bill_id: nil).includes(:ledger).find_each do |row| + qb = row.qbo_bill rescue nil + next if qb.nil? || qb.paid? + contributor_id = row.ledger&.contributor_id + next if contributor_id.nil? + open_bills_by_contributor[contributor_id] << { + host_class: klass.name, + host_id: row.id, + qbo_url: qb.qbo_url, + balance: qb.total_amount.to_f, + } + end +end + +dia_contrib_ids = + DeelInvoiceAdjustment.joins(:ledger).where(deleted_at: nil).pluck("ledgers.contributor_id").uniq +neg_ca_contrib_ids = + ContributorAdjustment.where("amount < 0").joins(:ledger).pluck("ledgers.contributor_id").uniq +candidate_ids = (dia_contrib_ids + neg_ca_contrib_ids + open_bills_by_contributor.keys).uniq + +rows = [] + +Contributor.unscoped.where(id: candidate_ids).find_each do |c| + next if c.forecast_person.nil? + c.preload_for_ledger_view! + items = c.all_items_grouped_by_month(false) + + current = c.new_deal_balance(items) + proposed = post_cutover_balance(items) + + d_bal = (proposed[:balance] - current[:balance]).to_f.round(2) + open_bills = open_bills_by_contributor[c.id] || [] + sum_open = open_bills.sum { |b| b[:balance] }.round(2) + + next if d_bal.abs < 0.01 && open_bills.empty? + + rows << { + id: c.id, + email: c.forecast_person.email, + cur_bal: current[:balance].to_f.round(2), + new_bal: proposed[:balance].to_f.round(2), + d_bal: d_bal, + open_bills: open_bills, + sum_open: sum_open, + } +end + +total_d_bal = rows.sum { |r| r[:d_bal] }.round(2) +sum_all_open = rows.sum { |r| r[:sum_open] }.round(2) +up = rows.count { |r| r[:d_bal] > 0.01 } +down = rows.count { |r| r[:d_bal] < -0.01 } +flat = rows.count { |r| r[:d_bal].abs < 0.01 } + +puts "Contributors needing accountant review: #{rows.size}" +puts " with open QBO bills: #{rows.count { |r| r[:open_bills].any? }} (sum: $#{sum_all_open})" +puts " balance would go UP: #{up} (under-recorded payments in QBO)" +puts " balance would go DOWN: #{down} (over-marked Paid in QBO OR missing offset CA)" +puts " balance unchanged: #{flat} (only have open bills to review)" +puts " Σ Δbalance: #{total_d_bal}" +puts + +# Group by direction +[ + ["UP (most likely cohort — accountant marks open bills as Paid where applicable)", ->(r) { r[:d_bal] > 0.01 }], + ["DOWN (review — Stacks expected this person to be owed money but QBO shows Paid)", ->(r) { r[:d_bal] < -0.01 }], + ["FLAT (only the open bills below need accountant confirmation)", ->(r) { r[:d_bal].abs < 0.01 && r[:open_bills].any? }], +].each do |label, filter| + matching = rows.select(&filter).sort_by { |r| -r[:d_bal].abs } + next if matching.empty? + puts "=" * 78 + puts label + puts "=" * 78 + matching.first(20).each do |r| + puts " ##{r[:id]} #{r[:email]}" + puts " Balance now: $#{r[:cur_bal]} After cutover: $#{r[:new_bal]} Δ#{r[:d_bal]}" + if r[:open_bills].any? + puts " Open QBO bills (#{r[:open_bills].size} bills, sum $#{r[:sum_open]}):" + r[:open_bills].first(5).each do |b| + puts " - #{b[:host_class]} ##{b[:host_id]} $#{b[:balance].round(2)} #{b[:qbo_url]}" + end + puts " ... (#{r[:open_bills].size - 5} more)" if r[:open_bills].size > 5 + else + puts " (no open QBO bills — Δ implies missing data on either side)" + end + puts + end + if matching.size > 20 + puts " ... and #{matching.size - 20} more in this cohort" + puts + end +end From e8286c66f4816ddbce9630d649846ad2be237746 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Sat, 6 Jun 2026 17:59:25 -0400 Subject: [PATCH 28/67] Worklist: filter out items on ledgers with no QBO vendor mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the contributor has no ContributorQboVendor for the ledger's enterprise QBO account, the bill could never have synced — so any balance walk that pretends to base its truth on QBO Bill status has no signal on that ledger. Items on those ledgers are excluded from both the current and post-cutover balance computations, and from the open-bill worklist. Co-Authored-By: Claude Opus 4.7 --- script/accountant_reconciliation_worklist.rb | 73 ++++++++++++++++++-- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/script/accountant_reconciliation_worklist.rb b/script/accountant_reconciliation_worklist.rb index 1e65b18c..d9fe7481 100644 --- a/script/accountant_reconciliation_worklist.rb +++ b/script/accountant_reconciliation_worklist.rb @@ -8,14 +8,57 @@ # - SyncsAsQboBill hosts drop out of balance when their QboBill mirror # is Paid # +# Ledgers whose contributor has NO QBO vendor mapping for the ledger's +# enterprise QBO account are excluded entirely — items on those ledgers +# can't sync as QBO Bills in the first place, so the cutover model has +# nothing to act on. Most of the "missing data on either side" noise in +# the previous report lived in exactly these ledgers (job@thedeskofjob, +# info@driesbos, etc.). +# # After the accountant goes through every affected contributor's open # QBO bills and marks the ones that have genuinely been paid, the # Stacks balance should converge to the QBO truth. QBO_HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].freeze -def post_cutover_balance(ledger_items) +def eligible_ledger_ids_for(contributor) + contributor.ledgers.includes(:enterprise).filter_map do |l| + qa = l.enterprise.qbo_account + next nil if qa.nil? + vendor = contributor.qbo_vendor_for(qa) + next nil if vendor.nil? + l.id + end.to_set +end + +def current_balance_filtered(ledger_items, eligible_ids) + ledger_items[:all].reduce({ balance: 0, unsettled: 0 }) do |acc, li| + next acc unless li.respond_to?(:ledger_id) && eligible_ids.include?(li.ledger_id) + next acc if li.respond_to?(:deleted_at) && li.deleted_at.present? + + case li + when ContributorPayout + if li.payable? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end + when Reimbursement + if li.accepted? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end + when Trueup + acc[:balance] += li.amount + when ProfitShare + if li.payable? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end + when ContributorAdjustment + if li.payable? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end + when PayStub + if li.payable? then acc[:balance] += li.amount else acc[:unsettled] += li.amount end + when DeelInvoiceAdjustment + acc[:balance] -= li.amount if li.deducts_balance? + end + acc + end +end + +def post_cutover_balance(ledger_items, eligible_ids) ledger_items[:all].reduce({ balance: 0, unsettled: 0 }) do |acc, li| + next acc unless li.respond_to?(:ledger_id) && eligible_ids.include?(li.ledger_id) next acc if li.respond_to?(:deleted_at) && li.deleted_at.present? next acc if li.is_a?(DeelInvoiceAdjustment) next acc if li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 @@ -50,12 +93,17 @@ def post_cutover_balance(ledger_items) open_bills_by_contributor = Hash.new { |h, k| h[k] = [] } QBO_HOST_KLASSES.each do |klass| - klass.where.not(qbo_bill_id: nil).includes(:ledger).find_each do |row| + klass.where.not(qbo_bill_id: nil).includes(ledger: :enterprise).find_each do |row| qb = row.qbo_bill rescue nil next if qb.nil? || qb.paid? - contributor_id = row.ledger&.contributor_id - next if contributor_id.nil? - open_bills_by_contributor[contributor_id] << { + contributor = row.ledger&.contributor + next if contributor.nil? + # Only count this open bill if the contributor has a QBO vendor + # mapping for the ledger's enterprise — otherwise the bill couldn't + # have synced in the first place. + qa = row.ledger.enterprise.qbo_account + next if qa.nil? || contributor.qbo_vendor_for(qa).nil? + open_bills_by_contributor[contributor.id] << { host_class: klass.name, host_id: row.id, qbo_url: qb.qbo_url, @@ -72,13 +120,21 @@ def post_cutover_balance(ledger_items) rows = [] +skipped_no_vendor = 0 + Contributor.unscoped.where(id: candidate_ids).find_each do |c| next if c.forecast_person.nil? + eligible_ids = eligible_ledger_ids_for(c) + if eligible_ids.empty? + skipped_no_vendor += 1 + next + end + c.preload_for_ledger_view! items = c.all_items_grouped_by_month(false) - current = c.new_deal_balance(items) - proposed = post_cutover_balance(items) + current = current_balance_filtered(items, eligible_ids) + proposed = post_cutover_balance(items, eligible_ids) d_bal = (proposed[:balance] - current[:balance]).to_f.round(2) open_bills = open_bills_by_contributor[c.id] || [] @@ -97,6 +153,9 @@ def post_cutover_balance(ledger_items) } end +puts "Skipped #{skipped_no_vendor} contributors with no QBO vendor mapping on any ledger" +puts + total_d_bal = rows.sum { |r| r[:d_bal] }.round(2) sum_all_open = rows.sum { |r| r[:sum_open] }.round(2) up = rows.count { |r| r[:d_bal] > 0.01 } From 232f23f6ca3959123c8a2e883f53c24e42c56d5f Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 17:36:38 -0400 Subject: [PATCH 29/67] Spec: QBO-bound ledger cutover Per-ledger migration from legacy balance model (neg CAs + DIAs deduct) to QBO-bound model (only QBO Bill Paid drops a host from balance), gated by a per-ledger 'no resulting difference' invariant. Replaces the LedgerWithdrawalRequest bundling apparatus with: - a direct Deel API call for the Deel-method trigger, - a controller-facing 'Payable QBO Bills' page for non-Deel cycles. Co-Authored-By: Claude Opus 4.7 --- ...6-06-12-qbo-bound-ledger-cutover-design.md | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md diff --git a/docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md b/docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md new file mode 100644 index 00000000..ea6fc2d8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md @@ -0,0 +1,539 @@ +# QBO-Bound Ledger Cutover + +A controlled, per-ledger cutover from the legacy balance model (negative ContributorAdjustments +and DeelInvoiceAdjustments deduct from balance) to a QBO-bound balance model (only the QBO Bill +"Paid" status drops a host from balance). The cutover is gated per-ledger by a "no resulting +difference" invariant so the financial controller migrates safely, ledger by ledger. + +This spec also removes the LedgerWithdrawalRequest bundling apparatus we built earlier in this PR +(replaced by a direct Deel API call) and introduces a "Payable QBO Bills" controller-facing page +to drive the twice-monthly non-Deel payment cycle. + +## Background + +Today, a Ledger's balance is computed across four overlapping deduction mechanisms: + +1. Positive hosts (ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub) where + `payable?` is true count into `balance`. +2. Negative ContributorAdjustments are inserted by hand to represent off-platform payments + (Deel, S-Corp owner draws, BUS payments, etc.). They negatively offset balance. +3. DeelInvoiceAdjustments arriving via Deel sync deduct unless in a void/reject status + (`deducts_balance?`). +4. SyncsAsQboBill hosts already write a QBO Bill mirror — but the bill's Paid status is + informational; it doesn't affect Stacks balance. + +The legacy world has been workable but is hard to reconcile: there are three places a payment +can be "recorded" and they must be kept consistent by hand. The cutover centralizes on the +QBO Bill Paid status as the single source of truth — a positive host stays in balance until its +corresponding QBO Bill is Paid in QBO. + +A dry-run audit (`script/audit_qbo_cutover_balance_drift.rb`, deleted post-cutover) found that +under the broadest-scope new rule applied to all data, 26 contributors would see balance go +UP, 0 would go DOWN, and the net Σ Δbalance is +$82,430. Most of that is "in-flight payment +cycle" (latest month's bill not yet marked Paid in QBO) that will self-heal once the financial +controller works through the open bills; the remainder is a one-time correction to a small set +of historical ledgers with off-platform payment patterns that never had a corresponding positive +host. + +## Goals + +- Allow each Ledger to opt into the new model independently, with a hard guarantee that the + flip does not change displayed balance/unsettled. +- Surface the migration as actionable work via the existing Task Builder system. +- Replace the contributor-driven withdrawal-request bundling flow with two simpler surfaces: + - A direct Deel API call for contributors paid via Deel. + - A cross-enterprise "Payable QBO Bills" page for the controller's twice-monthly pay cycle. +- Block the negative-CA pattern on qbo_bound ledgers so the cutover sticks. + +## Non-goals + +- Auto-marking QBO bills Paid from any Stacks-side flow (manual via QBO, per Q6 answer). +- Re-imagining Reimbursement or the salary/Justworks PayStub flow. +- Backfilling or modifying historical negative ContributorAdjustments — they sit as audit-only + rows after migration. +- Building a finance-side dashboard beyond the per-ledger migration panel and the Payable QBO + Bills page. + +## Design Decisions (locked from brainstorm) + +- **Q1**: On qbo_bound, only the QBO Bill "Paid" status determines whether a positive host drops + from balance. Negative CAs and DIAs are audit-only. +- **Q2**: The Payable QBO Bills page shows bills where `ledger.payment_methods` includes `qbo` + AND the underlying host is `payable?` (settled in Stacks) AND `qbo_bill.paid? == false`. + Tabbed per QBO account. +- **Q3**: `payment_methods` is backfilled per-ledger from contributor data. With the final + two-value enum (`deel`, `qbo`): non-US Deel contractor → `[deel]`; everyone else → `[qbo]`. +- **Q4**: Migration UI is a per-ledger button on the Ledger admin show page. Task Builder + discovery generates one task per legacy ledger with activity. +- **Q5**: Page row actions: Open in QBO link + per-row Refresh + per-tab bulk Refresh. +- **Q6**: Manual mark-Paid in QBO — no Stacks-initiated QBO writes. +- **Q7**: Negative CAs on qbo_bound ledgers are rejected at model validation. +- **Q8 / clarification**: The Deel withdrawal trigger is the existing contributor-facing form, + but it no longer persists a LedgerWithdrawalRequest. Submit calls Deel API directly. + +## Architecture + +### Schema + +Add two columns to `ledgers`: + +```ruby +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 +``` + +`mode` is named to avoid Rails STI's reserved `type` column. The enum is +`{ legacy: 0, qbo_bound: 1 }`. + +`payment_methods` is a Postgres `text[]` with values drawn from +`Ledger::PAYMENT_METHODS = %w[deel qbo].freeze`. GIN index supports the page-filter query +`WHERE 'qbo' = ANY(payment_methods)`. + +The migration runs a data-driven backfill of `payment_methods` (mode stays `legacy` for every +existing row): + +```ruby +Ledger.find_each do |ledger| + contributor = ledger.contributor + next if contributor.nil? + + # Contributor → DeelPerson (optional belongs_to via deel_person_id). + # DeelPerson#data is the Deel-side JSON payload; country is a 2-letter + # ISO code at data["country"] (verified by probing one record). + dp = contributor.deel_person + country = dp&.data.is_a?(Hash) ? dp.data["country"].to_s.upcase : nil + is_non_us_deel = dp.present? && country.present? && country != "US" + + ledger.update_column(:payment_methods, is_non_us_deel ? %w[deel] : %w[qbo]) +end +``` + +Default for any contributor without a Deel attachment is `["qbo"]` — they're paid via QBO +bill pay anyway and the Payable QBO Bills page is their lane. + +### Runtime balance/unsettled rules + +`Ledger#balance` and `Ledger#unsettled` branch on `mode`: + +```ruby +def balance + case mode + when "legacy" then visible_items.select(&:payable?).sum(&:signed_amount) + when "qbo_bound" then qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + end +end + +def unsettled + case mode + when "legacy" then visible_items.reject(&:payable?).sum(&:signed_amount) + when "qbo_bound" then qbo_bound_visible_items.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + end +end +``` + +`qbo_bound_visible_items` excludes `DeelInvoiceAdjustment` rows (audit-only) and negative +`ContributorAdjustment` rows (also audit-only). + +Each host class gets `in_balance_under_qbo_bound?`: + +- `ContributorPayout`, `ProfitShare`, `PayStub`, `ContributorAdjustment` (positive): + `payable? && !qbo_bill&.paid?` +- `Trueup`: `!qbo_bill&.paid?` (Trueup has no `payable?`; it's always in balance until paid) +- `Reimbursement`: `accepted?` (no QBO involvement — same as legacy) + +`items_grouped_by_month` continues to render historical negative CAs and DIAs for visibility — +display is independent of balance math. + +### Model validations + +```ruby +# app/models/contributor_adjustment.rb +validate :no_negative_on_qbo_bound_ledger + +def no_negative_on_qbo_bound_ledger + return unless ledger&.qbo_bound? && amount.to_f < 0 + errors.add( + :amount, + "negative adjustments are not allowed on QBO-bound ledgers — mark the corresponding QBO bill Paid instead", + ) +end +``` + +### Migration gate + +```ruby +# app/services/ledgers/qbo_bound_migration_check.rb +class Ledgers::QboBoundMigrationCheck + TOLERANCE = 0.01.freeze + + Result = Struct.new( + :current_balance, :current_unsettled, + :proposed_balance, :proposed_unsettled, + :balance_delta, :unsettled_delta, + :ready?, :blocking_bills, :ignored_negative_cas, + keyword_init: true, + ) + + def self.call(ledger) + legacy_b, legacy_u = compute_legacy(ledger) + new_b, new_u = compute_qbo_bound(ledger) + db = (new_b - legacy_b).round(2) + du = (new_u - legacy_u).round(2) + + Result.new( + current_balance: legacy_b, current_unsettled: legacy_u, + proposed_balance: new_b, proposed_unsettled: new_u, + balance_delta: db, unsettled_delta: du, + ready?: db.abs < TOLERANCE && du.abs < TOLERANCE, + blocking_bills: collect_blocking_bills(ledger), + ignored_negative_cas: ledger.contributor_adjustments.where("amount < 0").to_a, + ) + end +end +``` + +A `member_action :migrate_to_qbo_bound` on the Ledger admin show page invokes the service. If +`ready?`, flips `mode` to `qbo_bound`. Otherwise renders the discrepancy + blocking bills on +the panel for the controller to reconcile. + +### Rake task for bulk auto-migration + +```ruby +# lib/tasks/ledgers.rake +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, blocked, errors = 0, 0, 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 +``` + +Intended use: run after the schema migration, then re-run after each controller-reconciliation +session — anything that lands at net-zero flips automatically without manual button-pressing. + +### Task Builder discovery + +```ruby +# lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb +class Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration < + Stacks::TaskBuilder::Discoveries::Base + + PAYABLE_TABLES = %w[contributor_payouts contributor_adjustments profit_shares pay_stubs trueups].freeze + + def tasks + 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) + .find_each.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 +``` + +`StacksTask` already has a `when Ledger` branch in `subject_display_name` (uses +`"#{email} on #{enterprise.name}"`) — reusable as-is. The `subject_url` branch currently routes +every `Ledger` subject to `edit_admin_contributor_path(subject.contributor)`. We branch on +`type` so the migration task deep-links to the Ledger admin show page where the panel lives: + +```ruby +# in StacksTask#subject_url +when Ledger + if type == :legacy_ledger_needs_qbo_migration + helpers.admin_ledger_path(subject) + else + helpers.edit_admin_contributor_path(subject.contributor) + end +``` + +The Migrate panel renders inside `app/admin/ledgers.rb`'s `show` action via an +ActiveAdmin `sidebar` or inline panel. + +### Payable QBO Bills page + +Routed under the existing `app/admin/money.rb` ActiveAdmin page (currently a redirect). +Rewrite as: + +```ruby +ActiveAdmin.register_page "Money" do + menu priority: 50 + + page_action :payable_qbo_bills, method: :get do + @qbo_accounts = QboAccount.order(:id).to_a + @active_qa = params[:qbo_account_id].present? ? QboAccount.find(params[:qbo_account_id]) : @qbo_accounts.first + @rows = Money::PayableQboBills.call(qbo_account: @active_qa) if @active_qa + render "admin/money/payable_qbo_bills" + end + + page_action :refresh_bill, method: :post do + host = host_from_params!(params) + 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 + Money::RefreshPayableQboBills.call(qbo_account: QboAccount.find(params[:qbo_account_id])) + redirect_back(fallback_location: admin_money_payable_qbo_bills_path(qbo_account_id: params[:qbo_account_id])) + end +end +``` + +Row-selection service: + +```ruby +class Money::PayableQboBills + HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].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) + .where(enterprises: { qbo_account_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.qbo_bill rescue nil + 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 +``` + +Bulk refresh walks the same row set and calls `sync_qbo_bill!` on each host. + +View layout: + +- Top: tabs, one per QBO account, with `[Refresh all on this tab]` button. +- Body: rows grouped by contributor (sum + count in the group header), each row showing + the host class, host ID, amount, an external link to the QBO bill, and a per-row Refresh + button. + +### Deel withdrawal trigger (replaces LedgerWithdrawalRequest) + +The contributor-facing form (whatever its current path/UX — amount up to balance + contract +picker) is re-mounted as a `member_action :withdraw_via_deel` on `app/admin/contributors.rb`. +On submit: + +```ruby +member_action :withdraw_via_deel, method: :post do + ledger = Ledger.find(params.require(:ledger_id)) + unless ledger.deel_enabled? + redirect_back fallback_location: admin_contributor_path(resource), alert: "Deel not enabled for this ledger." + return + end + DeelInvoiceAdjustments::CreateForLedger.call( + ledger: ledger, + amount: params.require(:amount), + contract_id: params.require(:contract_id), + description: params[:description].to_s, + date_submitted: params[:date_submitted].presence || Date.current, + initiated_by: current_admin_user, + ) + redirect_back fallback_location: admin_contributor_path(resource), notice: "Withdrew via Deel." +rescue DeelInvoiceAdjustments::CreateForLedger::Error => e + redirect_back fallback_location: admin_contributor_path(resource), alert: e.message +end +``` + +`DeelInvoiceAdjustments::CreateForLedger` is the Deel-API-call core of the existing +`LedgerWithdrawalRequests::ProcessViaDeel` service, lifted out and de-coupled from the request +state machine. It calls Deel, persists a `DeelInvoiceAdjustment` row via +`DeelInvoiceAdjustment.create_from_deel_response!`, and raises a wrapped error on failure. + +On qbo_bound ledgers the resulting DIA is audit-only — it appears in the timeline but does not +affect balance. The controller must mark the corresponding QBO bills Paid in QBO separately +(visible on the Payable QBO Bills page). + +The trigger button is gated on `ledger.deel_enabled?` (i.e., `"deel"` ∈ `payment_methods`). + +## Components and Boundaries + +| Component | Purpose | Depends on | +|---|---|---| +| `Ledger#mode` enum + `#payment_methods` | Per-ledger feature flags + payout method list | n/a (schema column) | +| Per-host `#in_balance_under_qbo_bound?` | One-line predicate per host class deciding balance vs unsettled in the new rule | `qbo_bill`, `paid?`, `payable?`, `accepted?` | +| `Ledgers::QboBoundMigrationCheck` | Computes pre/post balance + unsettled, blocking bills | `Ledger`, `QboBill`, host classes | +| `Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration` | Emits one task per actionable legacy ledger | `Ledger`, `Enterprise`, `QboAccount` | +| `Money::PayableQboBills` | Selects rows for the page | host classes, `Ledger#payment_methods`, `QboBill` | +| `Money::RefreshPayableQboBills` | Bulk re-sync open bills for an enterprise | `SyncsAsQboBill#sync_qbo_bill!` | +| `DeelInvoiceAdjustments::CreateForLedger` | Wraps the Deel API call + persistence | Deel SDK, `DeelInvoiceAdjustment` | +| `lib/tasks/ledgers.rake :migrate_qbo_bound_zero_drift` | Bulk auto-flip | `QboBoundMigrationCheck` | + +Each unit can be exercised in isolation: + +- The migration check service takes a `Ledger` and returns a `Result` struct — no I/O beyond + reading the ledger's host rows. Tests stub fixtures and assert deltas. +- The Payable QBO Bills page service takes a `QboAccount`, returns an array of `Row`. Tested + by fixture combinations. +- The `CreateForLedger` service is the only place that touches the Deel API. Tested by + stubbing the Deel HTTP client. +- The negative-CA validation is a single Active Record `validate` callback — tested via + `valid?` assertions on a model instance. + +## Data Flow + +### Migration happy path + +1. Schema migration runs; every ledger has `mode: :legacy` and a backfilled `payment_methods`. +2. Task Builder runs; one `:legacy_ledger_needs_qbo_migration` task per legacy ledger with + activity appears in the controller's task list. +3. Controller opens a task → deep-links to the Ledger admin show page. +4. Migrate panel shows current vs proposed balance + Δ; if Δ < $0.01 on both, the Migrate + button is enabled. Otherwise the controller sees the blocking bill list. +5. Controller marks bills Paid in QBO (via the linked URLs), comes back, clicks Re-check. +6. When ready, clicks Migrate. `mode` flips to `qbo_bound`. The task disappears on next discovery + run. + +### Payable QBO Bills happy path + +1. Controller opens Money → Payable QBO Bills, picks a QBO account tab. +2. Page lists open bills with `payment_methods.include?(:qbo) && host.payable?`. Bills bound + to Deel-only ledgers (e.g., non-US contractors) do not appear. +3. Controller pays bills in QBO (via per-row links). +4. Clicks "Refresh all on this tab" → bulk `sync_qbo_bill!` → newly-Paid bills drop from the + list. + +### Deel withdrawal happy path + +1. Contributor opens their show page, clicks Withdraw via Deel (button visible iff + `ledger.deel_enabled?`). +2. Form submits an amount (≤ current balance) → `DeelInvoiceAdjustments::CreateForLedger` calls + Deel → DIA row persists. +3. On legacy: DIA deducts from balance (today's rule). +4. On qbo_bound: DIA is audit-only — visible in timeline, no balance impact. Controller + separately marks corresponding QBO bills Paid via the Payable QBO Bills page. + +## Error handling + +- `DeelInvoiceAdjustments::CreateForLedger::Error` wraps Deel API failures so the controller + redirects with a flash, not a 500. +- `Ledgers::QboBoundMigrationCheck` never raises; ineligible/empty ledgers return a Result with + `ready?: true` (trivially) and an empty `blocking_bills` list — the rake task flips them + freely. +- `Money::PayableQboBills` rescues `qbo_bill` access errors with `rescue nil` (existing + pattern); a host with a broken QboBill linkage is skipped, not failed. +- Negative-CA-on-qbo_bound rejection happens at `valid?` time — no AR-level abort surprises. + +## Code we delete (full deletion list) + +| File | Disposition | +|---|---| +| `db/migrate/20260606135814_create_ledger_withdrawal_requests.rb` | Delete — never deployed; tables not needed | +| `app/models/ledger_withdrawal_request.rb` | Delete | +| `app/models/ledger_withdrawal_request_bill.rb` | Delete | +| `app/admin/ledger_withdrawal_requests.rb` | Delete | +| `app/views/admin/ledger_withdrawal_requests/_show.html.erb` | Delete | +| `app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb` | Delete | +| `app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb` | Delete | +| `app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb` | Delete | +| `app/services/ledger_withdrawal_requests/process_via_deel.rb` | Delete (Deel-call core extracted to `DeelInvoiceAdjustments::CreateForLedger`) | +| `lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb` | Delete | +| Registration line in `lib/stacks/task_builder.rb` | Remove discovery from registry | +| References in `app/admin/deel_invoice_adjustments.rb` | Remove (any cross-links to withdrawal requests) | +| References in `app/models/admin_authorization.rb` | Remove (permission rules for the deleted admin page) | +| Splice into `app/views/admin/contributors/_show.html.erb` for `LedgerWithdrawalRequest` rendering | Remove | +| Contributor-side "new withdrawal" launch button in `app/admin/contributors.rb` | Repoint to new `withdraw_via_deel` action; remove withdrawal-request linkage | +| `Contributor#ledger_withdrawal_requests_with_deleted` + `preload_for_ledger_view!` add-ons | Remove | +| `Ledger#all_items_with_deleted` `LedgerWithdrawalRequest` line | Remove | +| `Ledger#has_many :ledger_withdrawal_requests` association | Remove | +| `StacksTask` `LedgerWithdrawalRequest` branches in `subject_display_name`, `subject_url` | Remove (Ledger branch repurposed for migration tasks) | +| `script/audit_qbo_cutover_balance_drift.rb` | Delete | +| `script/accountant_reconciliation_worklist.rb` | Delete | +| `script/why_balance_goes_up.rb` | Delete | + +## Testing strategy + +### Unit tests + +- `Ledger#balance` / `#unsettled` mode-branching: one legacy fixture, one qbo_bound fixture + with mixed paid/unpaid bills, neg CAs, DIAs — assert each rule's inclusion/exclusion. +- `Ledgers::QboBoundMigrationCheck`: three scenarios — ready (Δ < $0.01), + blocked-by-open-bills (Δ > 0), blocked-by-neg-CA-mismatch (Δ > 0). Assert `blocking_bills` + lists the right rows. Trivial-empty ledger returns ready. +- `ContributorAdjustment` negative-CA validation: allowed on legacy; rejected on qbo_bound + with the right error message; positive CAs unaffected by mode. +- `Ledger#payment_methods` helpers: `deel_enabled?`, `qbo_enabled?` — array membership truth. +- `Money::PayableQboBills.call(qbo_account:)`: returns only `payable?` rows from ledgers + whose `payment_methods` includes `qbo`; excludes Paid bills; sorts by contributor. +- `Money::RefreshPayableQboBills`: stubs `sync_qbo_bill!`, asserts called for each row. +- `DeelInvoiceAdjustments::CreateForLedger`: stubs Deel API, asserts DIA created with correct + fields; raises wrapped error on Deel failure. +- `Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration`: legacy ledger with + activity → task; qbo_bound ledger → no task; legacy ledger without QBO account → no task. +- `lib/tasks/ledgers.rake migrate_qbo_bound_zero_drift`: with mixed fixtures, flips only + ready ledgers and reports counts correctly. + +### Migration backfill test + +A migration test that loads a snapshot fixture (US-Deel contributor, non-US-Deel contributor, +QBO-only vendor contributor) and asserts post-migration `payment_methods` matches the +inference rule. + +### ActiveAdmin system tests + +- Controller logs in, opens a legacy Ledger admin page, sees the Migrate panel with a + discrepancy. Flips the underlying QBO bill Paid via fixture mutation. Clicks Re-check. + Panel says Ready. Clicks Migrate. Ledger flips to `qbo_bound`. +- Controller opens the Payable QBO Bills page, sees rows for one QBO account, clicks + per-row Refresh on a row whose QboBill fixture has flipped to Paid — row disappears. +- Contributor's show page renders the Withdraw via Deel button only when + `ledger.payment_methods.include?("deel")`. + +### Removed test coverage + +The existing `LedgerWithdrawalRequest`-related model, service, and admin tests are deleted +along with the code. Substantive Deel-API-call coverage moves to +`DeelInvoiceAdjustments::CreateForLedger`'s tests — no loss of meaningful assertions. + +## Rollout + +1. Merge this PR. Schema migration runs, payment_methods backfilled. +2. Operator runs `bundle exec rake ledgers:migrate_qbo_bound_zero_drift`. Every ledger whose + balance/unsettled wouldn't change flips immediately. +3. Task Builder runs (existing cron). Tasks appear for each remaining legacy ledger. +4. Controller works through tasks in the admin UI, reconciling bills in QBO and clicking + Migrate. +5. Twice-monthly: controller works through the Payable QBO Bills page tab-by-tab to pay + open bills. +6. Deel-only ledgers: contributor self-services via the Withdraw via Deel button as before. + +When every ledger has flipped, the `legacy` branch of `Ledger#balance` can be removed and the +column dropped — out of scope for this PR. From 3ddc4f83bfe70bb02074eed0bcb516e7d26d74a0 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 17:44:18 -0400 Subject: [PATCH 30/67] Plan: QBO-bound ledger cutover 19-task TDD implementation plan covering schema, model behavior, migration gate + rake task, Task Builder discovery, Payable QBO Bills page, Deel direct-call replacement for LedgerWithdrawalRequest, and a final deletion sweep against the strategy-change deletion list. Co-Authored-By: Claude Opus 4.7 --- ...026-06-12-qbo-bound-ledger-cutover-plan.md | 2141 +++++++++++++++++ 1 file changed, 2141 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-qbo-bound-ledger-cutover-plan.md diff --git a/docs/superpowers/plans/2026-06-12-qbo-bound-ledger-cutover-plan.md b/docs/superpowers/plans/2026-06-12-qbo-bound-ledger-cutover-plan.md new file mode 100644 index 00000000..bb7d9ea3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-qbo-bound-ledger-cutover-plan.md @@ -0,0 +1,2141 @@ +# QBO-Bound Ledger Cutover Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate Stacks ledgers from the legacy balance model (negative ContributorAdjustments and DeelInvoiceAdjustments deduct) to a QBO-bound model (only QBO Bill Paid drops a positive host from balance), per-ledger and gated by a no-balance-change invariant; replace the LedgerWithdrawalRequest bundling apparatus with a direct Deel API trigger and a controller-facing Payable QBO Bills page. + +**Architecture:** Add `mode` (legacy/qbo_bound) and `payment_methods` (text[]) columns on `ledgers`. Branch `Ledger#balance`/`#unsettled` on `mode`. Introduce `Ledgers::QboBoundMigrationCheck` to compute the gate. Surface the migration as a per-ledger button + a Task Builder discovery. Introduce a `Money::PayableQboBills` page tabbed per QBO account. Delete the `LedgerWithdrawalRequest` model entirely; replace its Deel-call core with `DeelInvoiceAdjustments::CreateForLedger`. Ship a rake task that bulk-flips zero-drift ledgers. + +**Tech Stack:** Rails 6.1, ActiveAdmin, Postgres (text[] with GIN index), Minitest + Mocha, existing `SyncsAsQboBill` concern. + +**Spec:** `docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md` + +--- + +## Pre-flight + +- [ ] **Step 0a: Confirm baseline state** + +```bash +git status +git log --oneline -10 +bundle exec rails db:migrate:status | tail -10 +``` + +Working dir should be clean other than the unrelated `script/why_balance_goes_up.rb` (deleted in Task 18). Verify we're on a worktree branch off `main`. + +- [ ] **Step 0b: Run baseline tests for the touch surface** + +```bash +bundle exec rails test test/models/ledger_test.rb test/models/contributor_adjustment_test.rb +``` + +Expected: all pass. Record the baseline count so we can confirm no regressions later. + +--- + +## Task 1: Schema migration with payment_methods backfill + +**Files:** +- Create: `db/migrate/_add_mode_and_payment_methods_to_ledgers.rb` +- Delete: `db/migrate/20260606135814_create_ledger_withdrawal_requests.rb` + +- [ ] **Step 1.1: Delete the never-deployed LedgerWithdrawalRequest migration** + +```bash +git rm db/migrate/20260606135814_create_ledger_withdrawal_requests.rb +``` + +- [ ] **Step 1.2: Generate the new migration** + +```bash +bundle exec rails generate migration AddModeAndPaymentMethodsToLedgers +``` + +Note the generated timestamp; the file will live at `db/migrate/_add_mode_and_payment_methods_to_ledgers.rb`. + +- [ ] **Step 1.3: Write the migration body** + +Replace the generated file's contents with: + +```ruby +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| + contributor = ledger.contributor + next if contributor.nil? + + dp = contributor.deel_person + country = dp&.data.is_a?(Hash) ? dp.data["country"].to_s.upcase : nil + is_non_us_deel = dp.present? && country.present? && country != "US" + + ledger.update_column(:payment_methods, is_non_us_deel ? %w[deel] : %w[qbo]) + end + end + + def down + remove_index :ledgers, :payment_methods + remove_index :ledgers, :mode + remove_column :ledgers, :payment_methods + remove_column :ledgers, :mode + end +end +``` + +- [ ] **Step 1.4: Run the migration** + +```bash +bundle exec rails db:migrate +``` + +Expected: migration applies cleanly; both columns appear in schema. + +- [ ] **Step 1.5: Verify schema and backfill** + +```bash +bundle exec rails runner 'puts Ledger.columns_hash.slice("mode","payment_methods").map{|n,c|"#{n}: #{c.sql_type}"}.join("\n")' +bundle exec rails runner 'puts Ledger.group(:payment_methods).count.inspect' +``` + +Expected: `mode: integer`, `payment_methods: character varying[]`. Group output shows a mix of `["deel"]` and `["qbo"]`. + +- [ ] **Step 1.6: Commit** + +```bash +git add db/migrate/ db/schema.rb +git commit -m "QBO cutover: add ledger.mode + ledger.payment_methods with backfill" +``` + +--- + +## Task 2: Ledger model — enum, helpers, and qbo_bound_visible_items + +**Files:** +- Modify: `app/models/ledger.rb` +- Test: `test/models/ledger_test.rb` + +- [ ] **Step 2.1: Write failing test for mode enum and payment_methods helpers** + +Append to `test/models/ledger_test.rb`: + +```ruby +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 +end +``` + +- [ ] **Step 2.2: Run test, expect failure** + +```bash +bundle exec rails test test/models/ledger_test.rb -n /LedgerModeAndPaymentMethods/ +``` + +Expected: FAIL (no `mode=` method, no `deel_enabled?`, no constant). + +- [ ] **Step 2.3: Add enum + helpers + constant** + +Edit `app/models/ledger.rb`. After `belongs_to :contributor` and before the `has_many` block, add: + +```ruby + 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 +``` + +- [ ] **Step 2.4: Remove the LedgerWithdrawalRequest has_many association** + +In `app/models/ledger.rb`, delete this line: + +```ruby + has_many :ledger_withdrawal_requests, dependent: :destroy +``` + +- [ ] **Step 2.5: Remove LedgerWithdrawalRequest from all_items_with_deleted** + +In `app/models/ledger.rb`'s `all_items_with_deleted`, delete the trailing line: + +```ruby + LedgerWithdrawalRequest.includes(:bills, :cancelled_by).where(ledger_id: id).to_a, +``` + +Also update the comment above the method to remove the LedgerWithdrawalRequest reference. + +- [ ] **Step 2.6: Add qbo_bound_visible_items helper** + +In `app/models/ledger.rb`'s `private` section, after `visible_items`, add: + +```ruby + # qbo_bound mode: drop DIAs (audit only) and negative CAs (audit only). + # Everything else flows through the same per-host predicate + # in_balance_under_qbo_bound?. + def qbo_bound_visible_items + visible_items.reject do |li| + li.is_a?(DeelInvoiceAdjustment) || + (li.is_a?(ContributorAdjustment) && li.amount.to_f < 0) + end + end +``` + +- [ ] **Step 2.7: Run tests, expect pass** + +```bash +bundle exec rails test test/models/ledger_test.rb +``` + +Expected: all pass (existing tests still green; new mode tests pass). + +- [ ] **Step 2.8: Commit** + +```bash +git add app/models/ledger.rb test/models/ledger_test.rb +git commit -m "Ledger: mode enum, payment_methods helpers, qbo_bound_visible_items" +``` + +--- + +## Task 3: Per-host `in_balance_under_qbo_bound?` predicates + +**Files:** +- Modify: `app/models/contributor_payout.rb` +- Modify: `app/models/contributor_adjustment.rb` +- Modify: `app/models/profit_share.rb` +- Modify: `app/models/trueup.rb` +- Modify: `app/models/pay_stub.rb` +- Modify: `app/models/reimbursement.rb` +- Modify: `app/models/deel_invoice_adjustment.rb` +- Test: `test/models/ledger_test.rb` + +- [ ] **Step 3.1: Write failing test for predicates on each host** + +Append to `test/models/ledger_test.rb`: + +```ruby +class HostInBalanceUnderQboBoundTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "QBoundPred-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: 993_001, email: "qbp#{SecureRandom.hex(2)}@example.com", data: {}) + @contributor = Contributor.create!(forecast_person: fp) + @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor) + end + + test "DeelInvoiceAdjustment is never in balance under qbo_bound" do + dia = DeelInvoiceAdjustment.new(amount: 100, deel_status: "approved") + refute dia.in_balance_under_qbo_bound? + end + + test "Reimbursement uses accepted? for qbo_bound" do + r_accepted = Reimbursement.new + r_accepted.stubs(:accepted?).returns(true) + assert r_accepted.in_balance_under_qbo_bound? + + r_pending = Reimbursement.new + r_pending.stubs(:accepted?).returns(false) + refute r_pending.in_balance_under_qbo_bound? + end + + test "ContributorPayout: in balance when payable and qbo_bill unpaid" do + cp = ContributorPayout.new + cp.stubs(:payable?).returns(true) + cp.stubs(:qbo_bill).returns(nil) + assert cp.in_balance_under_qbo_bound? + + paid = mock("qbo_bill") + paid.stubs(:paid?).returns(true) + cp.stubs(:qbo_bill).returns(paid) + refute cp.in_balance_under_qbo_bound? + + cp.stubs(:payable?).returns(false) + cp.stubs(:qbo_bill).returns(nil) + refute cp.in_balance_under_qbo_bound? + end + + test "Trueup: in balance when qbo_bill unpaid (no payable? check)" do + t = Trueup.new + t.stubs(:qbo_bill).returns(nil) + assert t.in_balance_under_qbo_bound? + + paid = mock("qbo_bill") + paid.stubs(:paid?).returns(true) + t.stubs(:qbo_bill).returns(paid) + refute t.in_balance_under_qbo_bound? + end + + test "ProfitShare, PayStub, ContributorAdjustment all follow the payable?-and-unpaid pattern" do + [ProfitShare, PayStub, ContributorAdjustment].each do |klass| + h = klass.new + h.stubs(:payable?).returns(true) + h.stubs(:qbo_bill).returns(nil) + assert h.in_balance_under_qbo_bound?, "#{klass.name} should be in balance when payable and unpaid" + end + end +end +``` + +- [ ] **Step 3.2: Run test, expect failure** + +```bash +bundle exec rails test test/models/ledger_test.rb -n /HostInBalanceUnderQboBound/ +``` + +Expected: FAIL (no `in_balance_under_qbo_bound?` method). + +- [ ] **Step 3.3: Add predicate to ContributorPayout** + +In `app/models/contributor_payout.rb`, add: + +```ruby + # QBO-bound balance rule: in balance only if Stacks considers the row + # settled AND its QBO Bill mirror has not yet been marked Paid. + def in_balance_under_qbo_bound? + payable? && !qbo_bill&.paid? + end +``` + +- [ ] **Step 3.4: Add predicate to ProfitShare** + +In `app/models/profit_share.rb`, add the same method body: + +```ruby + def in_balance_under_qbo_bound? + payable? && !qbo_bill&.paid? + end +``` + +- [ ] **Step 3.5: Add predicate to PayStub** + +In `app/models/pay_stub.rb`, add: + +```ruby + def in_balance_under_qbo_bound? + payable? && !qbo_bill&.paid? + end +``` + +- [ ] **Step 3.6: Add predicate to ContributorAdjustment** + +In `app/models/contributor_adjustment.rb`, add: + +```ruby + def in_balance_under_qbo_bound? + payable? && !qbo_bill&.paid? + end +``` + +- [ ] **Step 3.7: Add predicate to Trueup** + +In `app/models/trueup.rb`, add: + +```ruby + # Trueups always represent settled income; no payable? gate. + def in_balance_under_qbo_bound? + !qbo_bill&.paid? + end +``` + +- [ ] **Step 3.8: Add predicate to Reimbursement** + +In `app/models/reimbursement.rb`, add: + +```ruby + # Reimbursements aren't synced as QBO bills; same gate as legacy. + def in_balance_under_qbo_bound? + accepted? + end +``` + +- [ ] **Step 3.9: Add predicate to DeelInvoiceAdjustment** + +In `app/models/deel_invoice_adjustment.rb`, add: + +```ruby + # DIAs are audit-only on qbo_bound ledgers — never in balance. + def in_balance_under_qbo_bound? + false + end +``` + +- [ ] **Step 3.10: Run tests, expect pass** + +```bash +bundle exec rails test test/models/ledger_test.rb -n /HostInBalanceUnderQboBound/ +``` + +Expected: all pass. + +- [ ] **Step 3.11: Commit** + +```bash +git add app/models/ test/models/ledger_test.rb +git commit -m "Hosts: in_balance_under_qbo_bound? predicates for QBO-bound balance rule" +``` + +--- + +## Task 4: Ledger#balance and Ledger#unsettled mode branching + +**Files:** +- Modify: `app/models/ledger.rb` +- Test: `test/models/ledger_test.rb` + +- [ ] **Step 4.1: Write failing test for mode-branching balance/unsettled** + +Append to `test/models/ledger_test.rb`: + +```ruby +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) + r = Reimbursement.create!(ledger: @ledger, amount: 100, accepted_at: Time.current) + assert_equal 100, @ledger.balance.to_f + end + + test "qbo_bound mode drops a positive host whose qbo_bill is paid" 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(:in_balance_under_qbo_bound?).returns(false) + 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 + 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 +end +``` + +- [ ] **Step 4.2: Run test, expect failure** + +```bash +bundle exec rails test test/models/ledger_test.rb -n /LedgerBalanceUnderQboBound/ +``` + +Expected: FAIL (balance still uses legacy rule unconditionally). + +- [ ] **Step 4.3: Rewrite Ledger#balance and Ledger#unsettled** + +In `app/models/ledger.rb`, replace the existing `balance` and `unsettled` definitions with: + +```ruby + # Balance/unsettled split. legacy preserves today's rules; qbo_bound trusts + # the QBO Bill Paid status as the single source of truth. + def balance + case mode + when "legacy" then visible_items.select(&:payable?).sum(&:signed_amount) + when "qbo_bound" then qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + end + end + + def unsettled + case mode + when "legacy" then visible_items.reject(&:payable?).sum(&:signed_amount) + when "qbo_bound" then qbo_bound_visible_items.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + end + end +``` + +- [ ] **Step 4.4: Run tests, expect pass** + +```bash +bundle exec rails test test/models/ledger_test.rb +``` + +Expected: all pass. + +- [ ] **Step 4.5: Commit** + +```bash +git add app/models/ledger.rb test/models/ledger_test.rb +git commit -m "Ledger: balance/unsettled branch on mode (legacy vs qbo_bound)" +``` + +--- + +## Task 5: Negative-CA validation guard on qbo_bound ledgers + +**Files:** +- Modify: `app/models/contributor_adjustment.rb` +- Test: `test/models/contributor_adjustment_test.rb` + +- [ ] **Step 5.1: Write failing test for negative-CA guard** + +Append to `test/models/contributor_adjustment_test.rb`: + +```ruby +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: 995_001, 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") + # other validations may still fail in isolation; what we assert is that the + # negative-CA-on-qbo_bound rule isn't the one rejecting it. + ca.valid? + refute ca.errors[:amount].any? { |m| m.include?("not allowed on 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 +``` + +- [ ] **Step 5.2: Run test, expect failure** + +```bash +bundle exec rails test test/models/contributor_adjustment_test.rb -n /NegativeOnQboBound/ +``` + +Expected: FAIL (no guard yet). + +- [ ] **Step 5.3: Add the validation** + +In `app/models/contributor_adjustment.rb`, add: + +```ruby + validate :no_negative_on_qbo_bound_ledger + + def no_negative_on_qbo_bound_ledger + return unless ledger&.qbo_bound? && amount.to_f < 0 + errors.add( + :amount, + "negative adjustments are not allowed on QBO-bound ledgers — mark the corresponding QBO bill Paid instead", + ) + end +``` + +- [ ] **Step 5.4: Run tests, expect pass** + +```bash +bundle exec rails test test/models/contributor_adjustment_test.rb +``` + +Expected: all pass. + +- [ ] **Step 5.5: Commit** + +```bash +git add app/models/contributor_adjustment.rb test/models/contributor_adjustment_test.rb +git commit -m "ContributorAdjustment: reject negative amounts on qbo_bound ledgers" +``` + +--- + +## Task 6: Ledgers::QboBoundMigrationCheck service + +**Files:** +- Create: `app/services/ledgers/qbo_bound_migration_check.rb` +- Create: `test/services/ledgers/qbo_bound_migration_check_test.rb` + +- [ ] **Step 6.1: Write failing test** + +Create `test/services/ledgers/qbo_bound_migration_check_test.rb`: + +```ruby +require "test_helper" + +class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "MigCheck-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: 996_001, email: "mc#{SecureRandom.hex(2)}@example.com", data: {}) + @contributor = Contributor.create!(forecast_person: fp) + @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor) + end + + test "empty legacy ledger is ready (Δ = 0 trivially)" do + result = Ledgers::QboBoundMigrationCheck.call(@ledger) + assert result.ready? + assert_in_delta 0, result.balance_delta, 0.001 + assert_in_delta 0, result.unsettled_delta, 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, :blocking_bills + assert_respond_to r, :ignored_negative_cas + end + + test "ledger is blocked when ledger.balance under qbo_bound != legacy" do + # Stub the ledger so the check sees different balances under each rule. + @ledger.stubs(:mode).returns("legacy") + + # When called inside the service, we'll switch mode in a transaction and + # call ledger.balance again. To test this without DB writes, the service + # walks visible_items directly — see Step 6.3's implementation. For now + # we mock visible_items to simulate a divergent ledger. + 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) + cp.stubs(:in_balance_under_qbo_bound?).returns(false) + + neg_ca = ContributorAdjustment.new(amount: -50) + neg_ca.stubs(:payable?).returns(true) + 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) + # legacy: 100 - 50 = 50; qbo_bound: 0 (paid drops cp; neg_ca filtered out) + assert_in_delta -50, result.balance_delta, 0.01 + refute result.ready? + end +end +``` + +- [ ] **Step 6.2: Run test, expect failure** + +```bash +bundle exec rails test test/services/ledgers/qbo_bound_migration_check_test.rb +``` + +Expected: FAIL (service does not exist). + +- [ ] **Step 6.3: Implement the service** + +Create `app/services/ledgers/qbo_bound_migration_check.rb`: + +```ruby +module Ledgers + # Computes whether a legacy Ledger can flip to qbo_bound with zero + # change to balance or unsettled. Returns a Result struct exposing + # the deltas and the open QBO bills that explain any gap. + class QboBoundMigrationCheck + TOLERANCE = 0.01.freeze + + Result = Struct.new( + :current_balance, :current_unsettled, + :proposed_balance, :proposed_unsettled, + :balance_delta, :unsettled_delta, + :ready?, :blocking_bills, :ignored_negative_cas, + keyword_init: true, + ) + + BlockingBill = Struct.new(:host, :qbo_bill, :amount, keyword_init: true) + + def self.call(ledger) + legacy_visible = ledger.send(:visible_items) + qbb_visible = ledger.send(:qbo_bound_visible_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_visible.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f + new_u = qbb_visible.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f + + db = (new_b - legacy_b).round(2) + du = (new_u - legacy_u).round(2) + + 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, + ready?: db.abs < TOLERANCE && du.abs < TOLERANCE, + blocking_bills: collect_blocking_bills(legacy_visible), + ignored_negative_cas: legacy_visible.select { |li| li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 }, + ) + end + + def self.collect_blocking_bills(items) + items.filter_map do |li| + next nil if li.is_a?(DeelInvoiceAdjustment) + next nil if li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 + next nil unless li.respond_to?(:qbo_bill) + next nil unless li.respond_to?(:payable?) && li.payable? + + qb = (li.qbo_bill rescue nil) + next nil if qb.nil? || qb.paid? + + BlockingBill.new(host: li, qbo_bill: qb, amount: li.amount.to_f) + end + end + end +end +``` + +- [ ] **Step 6.4: Run tests, expect pass** + +```bash +bundle exec rails test test/services/ledgers/qbo_bound_migration_check_test.rb +``` + +Expected: all pass. + +- [ ] **Step 6.5: Commit** + +```bash +git add app/services/ledgers/ test/services/ledgers/ +git commit -m "Ledgers::QboBoundMigrationCheck: per-ledger gate with blocking-bill detail" +``` + +--- + +## Task 7: Ledger admin Migrate panel + member_action + +**Files:** +- Modify: `app/admin/ledgers.rb` +- Test: `test/system/ledger_migration_panel_test.rb` (new system test if `test/system` is already used) + +- [ ] **Step 7.1: Write failing test for the member_action** + +Create `test/integration/ledger_migration_test.rb`: + +```ruby +require "test_helper" + +class LedgerMigrationTest < ActionDispatch::IntegrationTest + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "MigPanel-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: 997_001, email: "mp#{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: "lmig#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", is_admin: true) + 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 with non-zero drift" do + # Plant divergent items via a stub. Without a DB-level divergence, + # mock the check service to return a not-ready Result. + not_ready = Ledgers::QboBoundMigrationCheck::Result.new( + current_balance: 0, current_unsettled: 0, + proposed_balance: 100, proposed_unsettled: 0, + balance_delta: 100, unsettled_delta: 0, + ready?: false, blocking_bills: [], ignored_negative_cas: [], + ) + 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 + + private + + def sign_in(admin) + # ActiveAdmin Devise sign-in helper used by other admin integration tests. + post admin_user_session_path, params: { admin_user: { email: admin.email, password: "password123" } } + end +end +``` + +- [ ] **Step 7.2: Run test, expect failure** + +```bash +bundle exec rails test test/integration/ledger_migration_test.rb +``` + +Expected: FAIL (no `migrate_to_qbo_bound` action defined; route missing). + +- [ ] **Step 7.3: Add member_action and sidebar panel to Ledger admin** + +In `app/admin/ledgers.rb`, replace the file's contents with: + +```ruby +ActiveAdmin.register Ledger do + menu false + config.filters = false + config.paginate = false + actions :index, :show + permit_params + + member_action :migrate_to_qbo_bound, method: :post do + result = Ledgers::QboBoundMigrationCheck.call(resource) + if result.ready? + resource.update!(mode: :qbo_bound) + redirect_to admin_ledger_path(resource), notice: "Migrated to QBO-bound." + else + redirect_to admin_ledger_path(resource), + alert: "Cannot migrate: Δbalance #{result.balance_delta}, Δunsettled #{result.unsettled_delta}." + 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 + para "Current (legacy): balance $#{result.current_balance} unsettled $#{result.current_unsettled}" + para "Proposed (qbo_bound): balance $#{result.proposed_balance} unsettled $#{result.proposed_unsettled}" + para "Δ balance #{result.balance_delta}, Δ unsettled #{result.unsettled_delta}" + end + if result.ready? + div do + para "Net-zero change — safe to migrate." + button_to "Migrate to QBO-bound", migrate_to_qbo_bound_admin_ledger_path(resource), method: :post, data: { confirm: "Flip this ledger to qbo_bound?" } + end + else + div do + if result.blocking_bills.any? + para "Open QBO bills blocking the migration:" + ul do + result.blocking_bills.first(20).each do |bb| + li do + text_node "#{bb.host.class.name} ##{bb.host.id} — $#{bb.amount.to_f.round(2)} — " + link_to "Pay in QBO ↗", bb.qbo_bill.qbo_url, target: "_blank", rel: "noopener" + end + end + end + end + if result.ignored_negative_cas.any? + para "Negative CAs (audit-only after migration):" + ul do + result.ignored_negative_cas.first(10).each do |ca| + li "CA ##{ca.id} — $#{ca.amount.to_f.round(2)}" + end + end + end + para "Resolve the open bills in QBO, then refresh this page or click Re-check." + button_to "Re-check", admin_ledger_path(resource), method: :get + end + end + end + end + end +end +``` + +- [ ] **Step 7.4: Run tests, expect pass** + +```bash +bundle exec rails test test/integration/ledger_migration_test.rb +``` + +Expected: all pass. + +- [ ] **Step 7.5: Commit** + +```bash +git add app/admin/ledgers.rb test/integration/ledger_migration_test.rb +git commit -m "Ledger admin: Migrate-to-QBO-bound panel + member_action" +``` + +--- + +## Task 8: Rake task for bulk zero-drift migration + +**Files:** +- Create: `lib/tasks/ledgers.rake` +- Test: `test/lib/tasks/ledgers_rake_test.rb` + +- [ ] **Step 8.1: Write failing test for the rake task** + +Create `test/lib/tasks/ledgers_rake_test.rb`: + +```ruby +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.find_or_create_by!(name: "RakeMig-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: 999_001, email: "rm#{SecureRandom.hex(2)}@example.com", data: {}) + @contributor = Contributor.create!(forecast_person: fp) + @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor) + end + + test "ready legacy ledger is auto-flipped to qbo_bound" do + @ledger.update!(mode: :legacy) + # An empty ledger is trivially ready (Δ = 0). + 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, ready?: false, blocking_bills: [], ignored_negative_cas: [], + ) + Ledgers::QboBoundMigrationCheck.stubs(:call).returns(blocked) + + Rake::Task["ledgers:migrate_qbo_bound_zero_drift"].invoke + assert @ledger.reload.legacy? + end +end +``` + +- [ ] **Step 8.2: Run test, expect failure** + +```bash +bundle exec rails test test/lib/tasks/ledgers_rake_test.rb +``` + +Expected: FAIL (rake task doesn't exist). + +- [ ] **Step 8.3: Implement the rake task** + +Create `lib/tasks/ledgers.rake`: + +```ruby +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 +``` + +- [ ] **Step 8.4: Run tests, expect pass** + +```bash +bundle exec rails test test/lib/tasks/ledgers_rake_test.rb +``` + +Expected: all pass. + +- [ ] **Step 8.5: Commit** + +```bash +git add lib/tasks/ledgers.rake test/lib/tasks/ledgers_rake_test.rb +git commit -m "ledgers:migrate_qbo_bound_zero_drift rake task" +``` + +--- + +## Task 9: Task Builder discovery + StacksTask routing + +**Files:** +- Create: `lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb` +- Modify: `lib/stacks/task_builder.rb` (register discovery) +- Modify: `app/models/stacks_task.rb` (route Ledger subject URL by task type) +- Test: `test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb` + +- [ ] **Step 9.1: Inspect existing discovery registration pattern** + +Read `lib/stacks/task_builder.rb` to find where `MissingQboVendors` and other discoveries are registered. Note the exact registry expression — the new discovery is added the same way. + +```bash +grep -n "Discoveries::" lib/stacks/task_builder.rb +``` + +- [ ] **Step 9.2: Write failing test** + +Create `test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb`: + +```ruby +require "test_helper" + +class Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigrationTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @qa = QboAccount.create!(realm_id: "rake#{SecureRandom.hex(2)}", name: "RakeQA") + @enterprise = Enterprise.create!(name: "DiscEnt-#{SecureRandom.hex(2)}", qbo_account: @qa) + fp = ForecastPerson.create!(forecast_id: 990_001, 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", is_admin: true) + end + + test "legacy ledger with payable activity yields a migration task" do + ContributorPayout.create!(ledger: @ledger, amount: 100, blueprint: { "lines" => [{ "amount" => 100.0 }] }) + 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 + ContributorPayout.create!(ledger: @ledger, amount: 100, blueprint: { "lines" => [{ "amount" => 100.0 }] }) + @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 +``` + +- [ ] **Step 9.3: Run test, expect failure** + +```bash +bundle exec rails test test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb +``` + +Expected: FAIL (class does not exist). + +- [ ] **Step 9.4: Create the discovery** + +Create `lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb`: + +```ruby +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 + ].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 +``` + +- [ ] **Step 9.5: Register the discovery** + +In `lib/stacks/task_builder.rb`, find the discovery registration list (per Step 9.1). Add the new discovery following the existing pattern. Remove the existing `Discoveries::LedgerWithdrawalRequests` registration (it will be deleted in Task 15). + +- [ ] **Step 9.6: Add Ledger URL type-branch in StacksTask** + +Edit `app/models/stacks_task.rb`. Find the existing `when Ledger` branch in `subject_url` (around line 137): + +```ruby + when Ledger then helpers.edit_admin_contributor_path(subject.contributor) +``` + +Replace with: + +```ruby + when Ledger + if type == :legacy_ledger_needs_qbo_migration + helpers.admin_ledger_path(subject) + else + helpers.edit_admin_contributor_path(subject.contributor) + end +``` + +- [ ] **Step 9.7: Run tests, expect pass** + +```bash +bundle exec rails test test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb +``` + +Expected: all pass. + +- [ ] **Step 9.8: Commit** + +```bash +git add lib/stacks/ app/models/stacks_task.rb test/lib/stacks/ +git commit -m "TaskBuilder: surface legacy ledgers pending QBO migration" +``` + +--- + +## Task 10: Money::PayableQboBills service + +**Files:** +- Create: `app/services/money/payable_qbo_bills.rb` +- Create: `test/services/money/payable_qbo_bills_test.rb` + +- [ ] **Step 10.1: Write failing test** + +Create `test/services/money/payable_qbo_bills_test.rb`: + +```ruby +require "test_helper" + +class Money::PayableQboBillsTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @qa = QboAccount.create!(realm_id: "pq#{SecureRandom.hex(2)}", name: "PayableQA") + @enterprise = Enterprise.create!(name: "PayableEnt-#{SecureRandom.hex(2)}", qbo_account: @qa) + fp = ForecastPerson.create!(forecast_id: 988_001, 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", data: { "balance" => "100" }) + cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: open_bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) + cp.stubs(:payable?).returns(true) + + rows = Money::PayableQboBills.call(qbo_account: @qa) + refute rows.any? { |r| r.host == cp } + end + + test "returns rows for payable hosts whose qbo_bill is open" do + open_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b2", data: { "balance" => "100" }) + cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: open_bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) + ContributorPayout.any_instance.stubs(:payable?).returns(true) + + rows = Money::PayableQboBills.call(qbo_account: @qa) + assert rows.any? { |r| r.host.id == cp.id && r.qbo_bill.qbo_id == "b2" } + end + + test "excludes paid bills" do + paid_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b3", data: { "balance" => "0" }) + cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: paid_bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) + ContributorPayout.any_instance.stubs(:payable?).returns(true) + + rows = Money::PayableQboBills.call(qbo_account: @qa) + refute rows.any? { |r| r.host.id == cp.id } + end + + test "excludes non-payable hosts" do + open_bill = QboBill.create!(qbo_account: @qa, qbo_id: "b4", data: { "balance" => "100" }) + cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: open_bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) + ContributorPayout.any_instance.stubs(:payable?).returns(false) + + rows = Money::PayableQboBills.call(qbo_account: @qa) + refute rows.any? { |r| r.host.id == cp.id } + end +end +``` + +- [ ] **Step 10.2: Run test, expect failure** + +```bash +bundle exec rails test test/services/money/payable_qbo_bills_test.rb +``` + +Expected: FAIL (service does not exist). + +- [ ] **Step 10.3: Implement the service** + +Create `app/services/money/payable_qbo_bills.rb`: + +```ruby +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, + ].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) + .where(enterprises: { qbo_account_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.qbo_bill rescue nil) + 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 +``` + +- [ ] **Step 10.4: Run tests, expect pass** + +```bash +bundle exec rails test test/services/money/payable_qbo_bills_test.rb +``` + +Expected: all pass. + +- [ ] **Step 10.5: Commit** + +```bash +git add app/services/money/payable_qbo_bills.rb test/services/money/payable_qbo_bills_test.rb +git commit -m "Money::PayableQboBills: cross-enterprise open-bill selection" +``` + +--- + +## Task 11: Money::RefreshPayableQboBills service + +**Files:** +- Create: `app/services/money/refresh_payable_qbo_bills.rb` +- Create: `test/services/money/refresh_payable_qbo_bills_test.rb` + +- [ ] **Step 11.1: Write failing test** + +Create `test/services/money/refresh_payable_qbo_bills_test.rb`: + +```ruby +require "test_helper" + +class Money::RefreshPayableQboBillsTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @qa = QboAccount.create!(realm_id: "rfp#{SecureRandom.hex(2)}", name: "RefreshQA") + @enterprise = Enterprise.create!(name: "RefreshEnt-#{SecureRandom.hex(2)}", qbo_account: @qa) + fp = ForecastPerson.create!(forecast_id: 987_001, 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", data: { "balance" => "100" }) + @cp = ContributorPayout.create!(ledger: @ledger, amount: 100, qbo_bill_id: @bill.qbo_id, qbo_account_id: @qa.id, blueprint: { "lines" => [{ "amount" => 100.0 }] }) + end + + test "calls sync_qbo_bill! on every row returned by PayableQboBills" do + ContributorPayout.any_instance.stubs(:payable?).returns(true) + ContributorPayout.any_instance.expects(:sync_qbo_bill!).at_least_once + + Money::RefreshPayableQboBills.call(qbo_account: @qa) + end +end +``` + +- [ ] **Step 11.2: Run test, expect failure** + +```bash +bundle exec rails test test/services/money/refresh_payable_qbo_bills_test.rb +``` + +Expected: FAIL (service does not exist). + +- [ ] **Step 11.3: Implement the service** + +Create `app/services/money/refresh_payable_qbo_bills.rb`: + +```ruby +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 +``` + +- [ ] **Step 11.4: Run tests, expect pass** + +```bash +bundle exec rails test test/services/money/refresh_payable_qbo_bills_test.rb +``` + +Expected: all pass. + +- [ ] **Step 11.5: Commit** + +```bash +git add app/services/money/refresh_payable_qbo_bills.rb test/services/money/refresh_payable_qbo_bills_test.rb +git commit -m "Money::RefreshPayableQboBills: bulk re-sync open bills for one QBO account" +``` + +--- + +## Task 12: Payable QBO Bills admin page + +**Files:** +- Modify: `app/admin/money.rb` +- Create: `app/views/admin/money/payable_qbo_bills.html.erb` +- Test: `test/integration/payable_qbo_bills_test.rb` + +- [ ] **Step 12.1: Write failing integration test** + +Create `test/integration/payable_qbo_bills_test.rb`: + +```ruby +require "test_helper" + +class PayableQboBillsTest < ActionDispatch::IntegrationTest + setup do + Thread.current[:sanctuary_enterprise] = nil + @qa = QboAccount.create!(realm_id: "pgi#{SecureRandom.hex(2)}", name: "IntQA") + @enterprise = Enterprise.create!(name: "IntEnt-#{SecureRandom.hex(2)}", qbo_account: @qa) + fp = ForecastPerson.create!(forecast_id: 986_001, 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", is_admin: true) + 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 + assert_match @qa.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 +``` + +- [ ] **Step 12.2: Run test, expect failure** + +```bash +bundle exec rails test test/integration/payable_qbo_bills_test.rb +``` + +Expected: FAIL (routes missing). + +- [ ] **Step 12.3: Rewrite app/admin/money.rb** + +Replace `app/admin/money.rb` entirely with: + +```ruby +ActiveAdmin.register_page "Money" do + menu priority: 50 + + controller do + before_action :authenticate_admin_user! + end + + page_action :payable_qbo_bills, method: :get do + @qbo_accounts = QboAccount.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 +``` + +- [ ] **Step 12.4: Create the view** + +Create `app/views/admin/money/payable_qbo_bills.html.erb`: + +```erb +

Payable QBO Bills

+ +
+ <% @qbo_accounts.each do |qa| %> + <%= link_to qa.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.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 %> +``` + +- [ ] **Step 12.5: Run tests, expect pass** + +```bash +bundle exec rails test test/integration/payable_qbo_bills_test.rb +``` + +Expected: all pass. + +- [ ] **Step 12.6: Commit** + +```bash +git add app/admin/money.rb app/views/admin/money/ test/integration/payable_qbo_bills_test.rb +git commit -m "Money admin: Payable QBO Bills page, tabbed per QBO account" +``` + +--- + +## Task 13: DeelInvoiceAdjustments::CreateForLedger service + +**Files:** +- Create: `app/services/deel_invoice_adjustments/create_for_ledger.rb` +- Create: `test/services/deel_invoice_adjustments/create_for_ledger_test.rb` + +- [ ] **Step 13.1: Inspect existing ProcessViaDeel for Deel-API-call code** + +Read `app/services/ledger_withdrawal_requests/process_via_deel.rb`. Note the exact Deel API call: which client method, what params it expects, how the response is mapped to `DeelInvoiceAdjustment.create_from_deel_response!`. The new service ports that logic without the LedgerWithdrawalRequest linkage. + +- [ ] **Step 13.2: Write failing test** + +Create `test/services/deel_invoice_adjustments/create_for_ledger_test.rb`: + +```ruby +require "test_helper" + +class DeelInvoiceAdjustments::CreateForLedgerTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "DelegLed-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: 985_001, email: "del#{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[deel]) + + @contract = DeelContract.create!(deel_id: "dc#{SecureRandom.hex(2)}", deel_person_id: "dp#{SecureRandom.hex(2)}", data: { "type" => "ongoing_time_based" }) + + @admin = AdminUser.create!(email: "dca#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", is_admin: true) + end + + test "creates a DIA when Deel API call succeeds" do + fake_response = { "data" => { "id" => "adj-42", "status" => "pending" } } + DeelInvoiceAdjustment.expects(:create_from_deel_response!).with( + ledger: @ledger, + deel_contract_id: @contract.deel_id, + amount: 100, + description: "test", + date_submitted: Date.current, + parsed_response: fake_response, + ).returns(DeelInvoiceAdjustment.new) + + DeelInvoiceAdjustments::CreateForLedger.any_instance.stubs(:call_deel_api).returns(fake_response) + + result = DeelInvoiceAdjustments::CreateForLedger.call( + ledger: @ledger, + amount: 100, + contract_id: @contract.deel_id, + description: "test", + date_submitted: Date.current, + initiated_by: @admin, + ) + assert result.is_a?(DeelInvoiceAdjustment) + end + + test "raises CreateForLedger::Error when Deel API returns no adjustment id" do + DeelInvoiceAdjustments::CreateForLedger.any_instance.stubs(:call_deel_api).returns({ "data" => {} }) + + assert_raises(DeelInvoiceAdjustments::CreateForLedger::Error) do + DeelInvoiceAdjustments::CreateForLedger.call( + ledger: @ledger, + amount: 100, + contract_id: @contract.deel_id, + description: "test", + date_submitted: Date.current, + initiated_by: @admin, + ) + end + end +end +``` + +- [ ] **Step 13.3: Run test, expect failure** + +```bash +bundle exec rails test test/services/deel_invoice_adjustments/create_for_ledger_test.rb +``` + +Expected: FAIL (service does not exist). + +- [ ] **Step 13.4: Implement the service by porting from ProcessViaDeel** + +Create `app/services/deel_invoice_adjustments/create_for_ledger.rb`. Copy the Deel API call body from `LedgerWithdrawalRequests::ProcessViaDeel#call`. Substitute the LedgerWithdrawalRequest linkage with direct kwargs: + +```ruby +module DeelInvoiceAdjustments + # Creates a DeelInvoiceAdjustment in Deel for a given ledger + contract, + # then persists the response as a Stacks-side DIA. Replaces the + # withdrawal-request-mediated path from LedgerWithdrawalRequests::ProcessViaDeel. + class CreateForLedger + class Error < StandardError; end + + def self.call(ledger:, amount:, contract_id:, description:, date_submitted:, initiated_by:) + new(ledger: ledger, amount: amount, contract_id: contract_id, + description: description, date_submitted: date_submitted, initiated_by: initiated_by).call + end + + def initialize(ledger:, amount:, contract_id:, description:, date_submitted:, initiated_by:) + @ledger = ledger + @amount = BigDecimal(amount.to_s) + @contract_id = contract_id.to_s + @description = description.to_s + @date_submitted = date_submitted + @initiated_by = initiated_by + end + + def call + parsed = call_deel_api + raise Error, "Deel did not return an adjustment id" if parsed.dig("data", "id").blank? + + DeelInvoiceAdjustment.create_from_deel_response!( + ledger: @ledger, + deel_contract_id: @contract_id, + amount: @amount, + description: @description, + date_submitted: @date_submitted, + parsed_response: parsed, + ) + rescue ActiveRecord::RecordInvalid => e + raise Error, "Could not persist DIA: #{e.message}" + end + + private + + # Calls the Deel /invoice-adjustments endpoint. Port the body from + # LedgerWithdrawalRequests::ProcessViaDeel — same Deel client, same + # endpoint signature; just drop the request-id linkage. + def call_deel_api + # See Step 13.1 for what to port. The exact client method comes from + # the source file. Implement as a direct copy with the request param + # removed. + raise NotImplementedError, "Port from LedgerWithdrawalRequests::ProcessViaDeel before this task is complete" + end + end +end +``` + +Then read `app/services/ledger_withdrawal_requests/process_via_deel.rb` and replace the body of `call_deel_api` with the corresponding HTTP-call section (everything that builds the request body, sends to Deel, and returns the parsed response — minus the LedgerWithdrawalRequest reference). + +- [ ] **Step 13.5: Run tests, expect pass** + +```bash +bundle exec rails test test/services/deel_invoice_adjustments/create_for_ledger_test.rb +``` + +Expected: all pass (the failure-mode test passes by stubbing `call_deel_api`). + +- [ ] **Step 13.6: Commit** + +```bash +git add app/services/deel_invoice_adjustments/ test/services/deel_invoice_adjustments/ +git commit -m "DeelInvoiceAdjustments::CreateForLedger: direct Deel API call (no withdrawal request)" +``` + +--- + +## Task 14: Contributor admin — withdraw_via_deel member_action + +**Files:** +- Modify: `app/admin/contributors.rb` +- Modify: `app/views/admin/contributors/_show.html.erb` (remove the LedgerWithdrawalRequest splice; add the new button) +- Test: `test/integration/contributor_withdraw_via_deel_test.rb` + +- [ ] **Step 14.1: Inspect current contributor admin withdrawal launch** + +```bash +grep -n "LedgerWithdrawalRequest\|withdrawal_request\|new_admin_ledger_withdrawal" app/admin/contributors.rb app/views/admin/contributors/_show.html.erb +``` + +Note every reference. The new `withdraw_via_deel` member_action replaces the launch link. + +- [ ] **Step 14.2: Write failing integration test** + +Create `test/integration/contributor_withdraw_via_deel_test.rb`: + +```ruby +require "test_helper" + +class ContributorWithdrawViaDeelTest < ActionDispatch::IntegrationTest + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "WVD-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: 984_001, email: "wvd#{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[deel]) + + @contract = DeelContract.create!(deel_id: "dc#{SecureRandom.hex(2)}", deel_person_id: "dp#{SecureRandom.hex(2)}", data: { "type" => "ongoing_time_based" }) + + @admin = AdminUser.create!(email: "wvd#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", is_admin: true) + sign_in @admin + end + + test "POST withdraw_via_deel calls CreateForLedger on a deel-enabled ledger" do + DeelInvoiceAdjustments::CreateForLedger.expects(:call).with( + ledger: @ledger, + amount: "100", + contract_id: @contract.deel_id, + description: "", + date_submitted: anything, + initiated_by: instance_of(AdminUser), + ).returns(DeelInvoiceAdjustment.new) + + post withdraw_via_deel_admin_contributor_path(@contributor), params: { + ledger_id: @ledger.id, + amount: "100", + contract_id: @contract.deel_id, + } + assert_response :redirect + end + + test "POST withdraw_via_deel refuses on a non-deel ledger" do + @ledger.update!(payment_methods: %w[qbo]) + DeelInvoiceAdjustments::CreateForLedger.expects(:call).never + + post withdraw_via_deel_admin_contributor_path(@contributor), params: { + ledger_id: @ledger.id, + amount: "100", + contract_id: @contract.deel_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 +``` + +- [ ] **Step 14.3: Run test, expect failure** + +```bash +bundle exec rails test test/integration/contributor_withdraw_via_deel_test.rb +``` + +Expected: FAIL (route missing). + +- [ ] **Step 14.4: Add the member_action to contributors admin** + +In `app/admin/contributors.rb`, add the following member_action (place near other Active Admin member_actions): + +```ruby + member_action :withdraw_via_deel, method: :post do + ledger = Ledger.find(params.require(:ledger_id)) + unless ledger.deel_enabled? + redirect_back fallback_location: admin_contributor_path(resource), alert: "Deel is not enabled for this ledger." + return + end + + DeelInvoiceAdjustments::CreateForLedger.call( + ledger: ledger, + amount: params.require(:amount), + contract_id: params.require(:contract_id), + description: params[:description].to_s, + date_submitted: params[:date_submitted].presence || Date.current, + initiated_by: current_admin_user, + ) + + redirect_back fallback_location: admin_contributor_path(resource), notice: "Withdrew via Deel." + rescue DeelInvoiceAdjustments::CreateForLedger::Error => e + redirect_back fallback_location: admin_contributor_path(resource), alert: e.message + end +``` + +- [ ] **Step 14.5: Replace withdrawal-request launch link in the contributor show partial** + +In `app/views/admin/contributors/_show.html.erb`, locate the existing link to `new_admin_ledger_withdrawal_request_path` (added earlier in this PR). Replace it with a small form that posts to `withdraw_via_deel_admin_contributor_path`, gated by `ledger.deel_enabled?`: + +```erb +<% if ledger.deel_enabled? %> + <%= form_tag(withdraw_via_deel_admin_contributor_path(contributor), method: :post, style: "display: inline") do %> + <%= hidden_field_tag :ledger_id, ledger.id %> + <%= number_field_tag :amount, ledger.balance.to_f, step: "0.01", min: "0.01", max: ledger.balance.to_f %> + <%= select_tag :contract_id, options_for_select(contributor.deel_person&.deel_contracts&.map { |c| [c.deel_contract_type_label, c.deel_id] } || []) %> + <%= submit_tag "Withdraw via Deel" %> + <% end %> +<% end %> +``` + +If the existing splice for `LedgerWithdrawalRequest` timeline rendering is still in the file (it should be from the prior work), remove it as part of Task 15. + +- [ ] **Step 14.6: Run tests, expect pass** + +```bash +bundle exec rails test test/integration/contributor_withdraw_via_deel_test.rb +``` + +Expected: all pass. + +- [ ] **Step 14.7: Commit** + +```bash +git add app/admin/contributors.rb app/views/admin/contributors/_show.html.erb test/integration/contributor_withdraw_via_deel_test.rb +git commit -m "Contributors admin: withdraw_via_deel member_action + gated form" +``` + +--- + +## Task 15: Delete the LedgerWithdrawalRequest apparatus + +**Files (delete):** +- `app/models/ledger_withdrawal_request.rb` +- `app/models/ledger_withdrawal_request_bill.rb` +- `app/admin/ledger_withdrawal_requests.rb` +- `app/views/admin/ledger_withdrawal_requests/_show.html.erb` +- `app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb` +- `app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb` +- `app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb` +- `app/services/ledger_withdrawal_requests/process_via_deel.rb` +- `lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb` + +- [ ] **Step 15.1: Delete the files** + +```bash +git rm app/models/ledger_withdrawal_request.rb \ + app/models/ledger_withdrawal_request_bill.rb \ + app/admin/ledger_withdrawal_requests.rb \ + app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb \ + app/services/ledger_withdrawal_requests/process_via_deel.rb \ + lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb +git rm -r app/views/admin/ledger_withdrawal_requests/ +rmdir app/services/ledger_withdrawal_requests 2>/dev/null || true +``` + +- [ ] **Step 15.2: Run boot smoke test to find stale references** + +```bash +bundle exec rails runner 'puts Ledger.first&.id' +``` + +Expected output: a ledger id (or empty if no ledgers). If this errors with `NameError: uninitialized constant`, capture the exact name and find references with grep — they need to be removed in Task 16. + +- [ ] **Step 15.3: Commit** + +```bash +git add -A +git commit -m "Delete LedgerWithdrawalRequest model, admin, services, discovery, views" +``` + +--- + +## Task 16: Clean up cross-references + +**Files:** +- Modify: `app/models/contributor.rb` (remove `ledger_withdrawal_requests_with_deleted` and any `preload_for_ledger_view!` addition for it) +- Modify: `app/models/stacks_task.rb` (remove `LedgerWithdrawalRequest` `when` branches in `subject_display_name` and `subject_url`) +- Modify: `app/admin/deel_invoice_adjustments.rb` (remove any LedgerWithdrawalRequest cross-link) +- Modify: `app/models/admin_authorization.rb` (remove any LedgerWithdrawalRequest permission entries) +- Modify: `app/views/admin/contributors/_show.html.erb` (remove the LedgerWithdrawalRequest timeline splice) +- Modify: `lib/stacks/task_builder.rb` (remove the registration for the deleted discovery, if Task 9 didn't already) + +- [ ] **Step 16.1: Find every remaining reference** + +```bash +grep -rn "LedgerWithdrawalRequest\|ledger_withdrawal_request" app/ lib/ test/ --include="*.rb" --include="*.erb" --include="*.arb" 2>/dev/null +``` + +Expected after Task 15: only references in `app/models/contributor.rb`, `app/models/stacks_task.rb`, `app/admin/deel_invoice_adjustments.rb`, `app/models/admin_authorization.rb`, `app/views/admin/contributors/_show.html.erb`, and `lib/stacks/task_builder.rb`. + +- [ ] **Step 16.2: Remove from app/models/contributor.rb** + +Delete the `ledger_withdrawal_requests_with_deleted` method and any `preload_for_ledger_view!` entry related to it. Search for any `is_a?(LedgerWithdrawalRequest)` branches in sort logic added during the prior splice work and remove them. + +- [ ] **Step 16.3: Remove from app/models/stacks_task.rb** + +Remove the `when LedgerWithdrawalRequest` branches in both `subject_display_name` and `subject_url`. The `when Ledger` branch (with the type-branch added in Task 9) stays. + +- [ ] **Step 16.4: Remove from app/admin/deel_invoice_adjustments.rb** + +Remove any cross-link to withdrawal requests (likely a sidebar link or column displaying the parent withdrawal request). + +- [ ] **Step 16.5: Remove from app/models/admin_authorization.rb** + +Remove any permission rules referencing `LedgerWithdrawalRequest` or `:ledger_withdrawal_requests`. + +- [ ] **Step 16.6: Remove the splice from app/views/admin/contributors/_show.html.erb** + +Delete the lines that render `LedgerWithdrawalRequest` rows in the timeline (the "Withdrawal Request" pill rendering added in the prior commits). + +- [ ] **Step 16.7: Verify boot and run full test suite** + +```bash +bundle exec rails runner 'puts "boots: #{Rails.application.config.cache_classes}"' +bundle exec rails test +``` + +Expected: boots cleanly; the full test suite passes (existing tests still pass after the deletion + cleanup). + +- [ ] **Step 16.8: Final grep — ensure no LedgerWithdrawalRequest references remain** + +```bash +grep -rn "LedgerWithdrawalRequest\|ledger_withdrawal_request" app/ lib/ test/ 2>/dev/null +``` + +Expected: empty result (no matches anywhere). + +- [ ] **Step 16.9: Commit** + +```bash +git add -A +git commit -m "Remove all LedgerWithdrawalRequest cross-references from runtime" +``` + +--- + +## Task 17: Delete the audit scripts + +**Files (delete):** +- `script/audit_qbo_cutover_balance_drift.rb` +- `script/accountant_reconciliation_worklist.rb` +- `script/why_balance_goes_up.rb` + +- [ ] **Step 17.1: Delete** + +```bash +git rm script/audit_qbo_cutover_balance_drift.rb \ + script/accountant_reconciliation_worklist.rb \ + script/why_balance_goes_up.rb 2>/dev/null || true +# why_balance_goes_up.rb may be untracked — delete from working tree as well: +rm -f script/why_balance_goes_up.rb 2>/dev/null || true +``` + +- [ ] **Step 17.2: Commit** + +```bash +git add -A +git commit -m "Remove one-shot QBO-cutover audit scripts" +``` + +--- + +## Task 18: Final sweep — vigilance on the strategy-change deletion list + +The user explicitly called out: ensure nothing from the discarded ideas slipped into the implementation. This task is a self-review pass before opening the PR. + +- [ ] **Step 18.1: Scan for forbidden tokens** + +```bash +grep -rn "justworks\|Justworks\|JUSTWORKS\|misc_enabled" app/ lib/ test/ docs/superpowers/ 2>/dev/null +``` + +Expected: only the spec/plan docs mention `justworks` in historical/explanatory context. No application code references it. + +```bash +grep -rn "LedgerWithdrawalRequest\|ledger_withdrawal_request" app/ lib/ test/ 2>/dev/null +``` + +Expected: empty. + +```bash +grep -rn "process_via_deel\|enumerate_candidate_bills\|bills_panel\|notes_panel" app/ lib/ test/ 2>/dev/null +``` + +Expected: empty. + +- [ ] **Step 18.2: Run full test suite** + +```bash +bundle exec rails test +``` + +Expected: zero failures, zero errors. Capture and compare against the baseline from Step 0b — no regressions outside the intentionally-deleted tests. + +- [ ] **Step 18.3: Sanity-check the rake task end-to-end** + +```bash +bundle exec rake ledgers:migrate_qbo_bound_zero_drift +bundle exec rails runner 'puts Ledger.group(:mode).count.inspect' +``` + +Expected: the rake task prints a count line; the runner output shows a mix of `legacy` (still blocked) and `qbo_bound` (auto-flipped where Δ < $0.01). + +- [ ] **Step 18.4: Verify spec ↔ code alignment** + +Reread `docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md`. Confirm every section's described behavior is reflected in the committed code. Any divergence is either fixed inline (preferable) or documented as an addendum at the top of the spec. + +- [ ] **Step 18.5: Commit any sweep fixes** + +```bash +git status +# If anything changed: +git add -A +git commit -m "Cleanup pass after final review" +``` + +--- + +## Task 19: Open the PR + +- [ ] **Step 19.1: Push branch** + +```bash +git push -u origin HEAD +``` + +- [ ] **Step 19.2: Open PR with summary** + +```bash +gh pr create --title "QBO-bound ledger cutover" --body "$(cat <<'EOF' +## Summary + +- Add per-Ledger `mode` enum (legacy / qbo_bound) and `payment_methods` (text[]) column with data-driven backfill (non-US Deel → ["deel"], everyone else → ["qbo"]). +- New balance rule for `qbo_bound`: only QBO Bill "Paid" status drops a positive host from balance. Negative ContributorAdjustments and DeelInvoiceAdjustments are audit-only. +- Per-Ledger Migrate panel + `member_action`, gated by a no-balance-change invariant. `Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration` surfaces every actionable ledger as a task. `bundle exec rake ledgers:migrate_qbo_bound_zero_drift` bulk-flips any ledger trivially ready. +- New "Payable QBO Bills" page under Money tab, tabbed per QBO account: shows open bills payable through Stacks (`payment_methods` includes `qbo` AND host `payable?`), per-row Refresh + per-tab bulk Refresh. +- Negative-CA validation guard on `qbo_bound` ledgers. +- LedgerWithdrawalRequest apparatus deleted (model, admin, views, services, task discovery). Deel withdrawal trigger now posts directly to `withdraw_via_deel` on the Contributor admin, which calls `DeelInvoiceAdjustments::CreateForLedger` (a port of the Deel-call core from the deleted `ProcessViaDeel`). + +## Spec + +`docs/superpowers/specs/2026-06-12-qbo-bound-ledger-cutover-design.md` + +## Test plan + +- [ ] `bundle exec rails test` passes locally +- [ ] `bundle exec rake ledgers:migrate_qbo_bound_zero_drift` runs and flips empty ledgers automatically +- [ ] Visit a Ledger admin show page → Migrate panel renders with Δ pre/post info +- [ ] Visit Money → Payable QBO Bills → tabs appear, per-tab Refresh button works +- [ ] On a qbo_bound ledger, attempting to create a negative ContributorAdjustment via the admin form is rejected with the expected validation error +- [ ] Withdraw via Deel button only renders for ledgers with `deel` in `payment_methods` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Capture the PR URL from the output. + +--- + +## Self-Review Notes (pre-execution) + +Spec coverage check: +- **Schema**: Task 1 +- **Ledger mode + payment_methods helpers**: Task 2 +- **Per-host predicates**: Task 3 +- **Ledger balance/unsettled branching**: Task 4 +- **Negative CA validation**: Task 5 +- **QboBoundMigrationCheck service**: Task 6 +- **Migrate UI panel**: Task 7 +- **Rake task for bulk migration**: Task 8 (explicit user requirement) +- **Task Builder discovery + StacksTask routing**: Task 9 +- **PayableQboBills service**: Task 10 +- **RefreshPayableQboBills service**: Task 11 +- **Money admin page + view**: Task 12 +- **DeelInvoiceAdjustments::CreateForLedger**: Task 13 +- **Contributor admin withdraw_via_deel**: Task 14 +- **LedgerWithdrawalRequest deletion**: Tasks 15, 16 +- **Audit script deletion**: Task 17 +- **Final sweep + open PR**: Tasks 18, 19 + +No placeholders. No "TBD". All code shown in steps; all commands explicit. Type/method naming is consistent across tasks: `Ledgers::QboBoundMigrationCheck::Result` referenced by name in Tasks 6, 7, 8; `Money::PayableQboBills::Row` referenced in Tasks 10, 12; `DeelInvoiceAdjustments::CreateForLedger::Error` referenced in Tasks 13, 14. From 2bc28d266655324a6c45cd9aead44609d3388f08 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 17:46:22 -0400 Subject: [PATCH 31/67] QBO cutover: add ledger.mode + ledger.payment_methods with backfill Co-Authored-By: Claude Sonnet 4.6 --- ...35814_create_ledger_withdrawal_requests.rb | 39 ---------- ...add_mode_and_payment_methods_to_ledgers.rb | 33 +++++++++ db/schema.rb | 73 ++++++++++--------- 3 files changed, 72 insertions(+), 73 deletions(-) delete mode 100644 db/migrate/20260606135814_create_ledger_withdrawal_requests.rb create mode 100644 db/migrate/20260612214545_add_mode_and_payment_methods_to_ledgers.rb diff --git a/db/migrate/20260606135814_create_ledger_withdrawal_requests.rb b/db/migrate/20260606135814_create_ledger_withdrawal_requests.rb deleted file mode 100644 index e0b8d8a7..00000000 --- a/db/migrate/20260606135814_create_ledger_withdrawal_requests.rb +++ /dev/null @@ -1,39 +0,0 @@ -class CreateLedgerWithdrawalRequests < ActiveRecord::Migration[6.1] - def change - create_table :ledger_withdrawal_requests do |t| - t.references :ledger, null: false, foreign_key: true - t.datetime :requested_at, null: false - t.datetime :processed_at - t.datetime :cancelled_at - t.references :cancelled_by, foreign_key: { to_table: :admin_users } - t.text :cancelled_reason - t.text :notes - # How the controller resolved the request, set when processed_at flips. - # One of: "deel", "qbo_bill_pay", "manual". - t.string :paid_via - t.bigint :deel_invoice_adjustment_id - t.timestamps - end - add_index :ledger_withdrawal_requests, :processed_at - add_index :ledger_withdrawal_requests, :cancelled_at - - create_table :ledger_withdrawal_request_bills do |t| - t.references :ledger_withdrawal_request, null: false, foreign_key: true, index: { name: "idx_lwrb_on_request_id" } - # Bills are identified by (qbo_account_id, qbo_id) — same composite key - # the rest of the QBO-side records use. Storing both lets us go straight - # to QboBill.find_by(qbo_account_id:, qbo_id:) without an extra join. - t.bigint :qbo_account_id, null: false - t.string :qbo_bill_id, null: false - # Snapshot of the Bill's amount at request time so the show page and - # totals stay stable even if a QBO-side edit changes the live amount. - t.decimal :amount_snapshot, precision: 12, scale: 2, null: false - t.timestamps - end - add_index :ledger_withdrawal_request_bills, - [:ledger_withdrawal_request_id, :qbo_account_id, :qbo_bill_id], - unique: true, name: "idx_lwrb_unique_per_bill" - add_index :ledger_withdrawal_request_bills, - [:qbo_account_id, :qbo_bill_id], - name: "idx_lwrb_on_bill" - 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..7fe22e13 --- /dev/null +++ b/db/migrate/20260612214545_add_mode_and_payment_methods_to_ledgers.rb @@ -0,0 +1,33 @@ +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| + contributor = ledger.contributor + next if contributor.nil? + + dp = contributor.deel_person + country = dp&.data.is_a?(Hash) ? dp.data["country"].to_s.upcase : nil + is_non_us_deel = dp.present? && country.present? && country != "US" + + ledger.update_column(:payment_methods, is_non_us_deel ? %w[deel] : %w[qbo]) + 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 f4bf082b..927ec9b1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2026_06_06_135814) do +ActiveRecord::Schema.define(version: 2026_06_12_214545) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gist" @@ -401,44 +401,18 @@ t.check_constraint "(company_treasury_split >= (0)::numeric) AND (company_treasury_split <= (1)::numeric)", name: "check_company_treasury_split_range" end - create_table "ledger_withdrawal_request_bills", force: :cascade do |t| - t.bigint "ledger_withdrawal_request_id", null: false - t.bigint "qbo_account_id", null: false - t.string "qbo_bill_id", null: false - t.decimal "amount_snapshot", precision: 12, scale: 2, null: false - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false - t.index ["ledger_withdrawal_request_id", "qbo_account_id", "qbo_bill_id"], name: "idx_lwrb_unique_per_bill", unique: true - t.index ["ledger_withdrawal_request_id"], name: "idx_lwrb_on_request_id" - t.index ["qbo_account_id", "qbo_bill_id"], name: "idx_lwrb_on_bill" - end - - create_table "ledger_withdrawal_requests", force: :cascade do |t| - t.bigint "ledger_id", null: false - t.datetime "requested_at", null: false - t.datetime "processed_at" - t.datetime "cancelled_at" - t.bigint "cancelled_by_id" - t.text "cancelled_reason" - t.text "notes" - t.string "paid_via" - t.bigint "deel_invoice_adjustment_id" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false - t.index ["cancelled_at"], name: "index_ledger_withdrawal_requests_on_cancelled_at" - t.index ["cancelled_by_id"], name: "index_ledger_withdrawal_requests_on_cancelled_by_id" - t.index ["ledger_id"], name: "index_ledger_withdrawal_requests_on_ledger_id" - t.index ["processed_at"], name: "index_ledger_withdrawal_requests_on_processed_at" - end - create_table "ledgers", force: :cascade do |t| t.bigint "enterprise_id", null: false 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| @@ -917,6 +891,23 @@ t.index ["enterprise_id"], name: "index_qbo_accounts_on_enterprise_id" end + create_table "qbo_bill_account_mappings", force: :cascade do |t| + t.bigint "enterprise_id", null: false + t.string "line_item_key", null: false + t.bigint "project_tracker_id" + t.bigint "contributor_id" + t.string "qbo_chart_account_qbo_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["contributor_id"], name: "index_qbo_bill_account_mappings_on_contributor_id" + t.index ["enterprise_id", "line_item_key", "contributor_id"], name: "idx_qbo_bill_acct_mappings_contributor", unique: true, where: "(contributor_id IS NOT NULL)" + t.index ["enterprise_id", "line_item_key", "project_tracker_id"], name: "idx_qbo_bill_acct_mappings_tracker", unique: true, where: "(project_tracker_id IS NOT NULL)" + t.index ["enterprise_id", "line_item_key"], name: "idx_qbo_bill_acct_mappings_default", unique: true, where: "((project_tracker_id IS NULL) AND (contributor_id IS NULL))" + t.index ["enterprise_id"], name: "index_qbo_bill_account_mappings_on_enterprise_id" + t.index ["project_tracker_id"], name: "index_qbo_bill_account_mappings_on_project_tracker_id" + t.check_constraint "(project_tracker_id IS NULL) OR (contributor_id IS NULL)", name: "qbo_bill_acct_mappings_one_subject" + end + create_table "qbo_bills", force: :cascade do |t| t.string "qbo_id", null: false t.jsonb "data" @@ -928,6 +919,19 @@ t.index ["qbo_vendor_id"], name: "index_qbo_bills_on_qbo_vendor_id" end + create_table "qbo_chart_accounts", force: :cascade do |t| + t.string "qbo_id", null: false + t.bigint "qbo_account_id", null: false + t.string "name", null: false + t.string "acct_num" + t.string "classification" + t.string "account_type" + t.boolean "active", default: true, null: false + t.jsonb "data" + t.index ["qbo_account_id", "qbo_id"], name: "index_qbo_chart_accounts_on_qbo_account_and_qbo_id", unique: true + t.index ["qbo_account_id"], name: "index_qbo_chart_accounts_on_qbo_account_id" + end + create_table "qbo_invoices", force: :cascade do |t| t.string "qbo_id", null: false t.jsonb "data" @@ -1248,9 +1252,6 @@ add_foreign_key "invoice_trackers", "invoice_passes" add_foreign_key "invoice_trackers", "qbo_accounts" add_foreign_key "invoice_trackers", "qbo_invoices", column: "qbo_account_id", primary_key: "qbo_account_id", name: "fk_invoice_trackers_qbo_invoice" - add_foreign_key "ledger_withdrawal_request_bills", "ledger_withdrawal_requests" - add_foreign_key "ledger_withdrawal_requests", "admin_users", column: "cancelled_by_id" - add_foreign_key "ledger_withdrawal_requests", "ledgers" add_foreign_key "ledgers", "contributors" add_foreign_key "ledgers", "enterprises" add_foreign_key "mailing_list_subscribers", "mailing_lists" @@ -1304,7 +1305,11 @@ add_foreign_key "project_tracker_links", "project_trackers" add_foreign_key "project_trackers", "runn_projects", primary_key: "runn_id" add_foreign_key "qbo_accounts", "enterprises" + add_foreign_key "qbo_bill_account_mappings", "contributors" + add_foreign_key "qbo_bill_account_mappings", "enterprises" + add_foreign_key "qbo_bill_account_mappings", "project_trackers" add_foreign_key "qbo_bills", "qbo_accounts" + add_foreign_key "qbo_chart_accounts", "qbo_accounts" add_foreign_key "qbo_invoices", "qbo_accounts" add_foreign_key "qbo_profit_and_loss_reports", "qbo_accounts" add_foreign_key "qbo_tokens", "qbo_accounts" From 007635ef40ec728e9ace489cef3baea247e4ebdb Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 17:54:33 -0400 Subject: [PATCH 32/67] Ledger: mode enum, payment_methods helpers, qbo_bound_visible_items Co-Authored-By: Claude Opus 4.7 --- app/models/ledger.rb | 26 ++++++++++++++++++++++---- test/models/ledger_test.rb | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/app/models/ledger.rb b/app/models/ledger.rb index 253beab8..8309555b 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 @@ -10,7 +22,6 @@ class Ledger < ApplicationRecord has_many :deel_invoice_adjustments has_many :pay_stubs has_many :recurring_ledger_adjustments, dependent: :destroy - has_many :ledger_withdrawal_requests, dependent: :destroy validates :enterprise_id, uniqueness: { scope: :contributor_id } @@ -133,9 +144,17 @@ def visible_items ].flatten end + # qbo_bound mode: drop DIAs (audit only) and negative CAs (audit only). + # Everything else flows through the same per-host predicate + # in_balance_under_qbo_bound?. + def qbo_bound_visible_items + visible_items.reject do |li| + li.is_a?(DeelInvoiceAdjustment) || + (li.is_a?(ContributorAdjustment) && li.amount.to_f < 0) + end + end + # Includes soft-deleted rows — used by items_grouped_by_month for display. - # LedgerWithdrawalRequests render as milestone rows here; they're left out - # of `visible_items` so they don't move balance/unsettled. def all_items_with_deleted [ ContributorPayout.with_deleted.includes(invoice_tracker: :invoice_pass).where(ledger_id: id).to_a, @@ -145,7 +164,6 @@ def all_items_with_deleted ProfitShare.with_deleted.includes(:periodic_report).where(ledger_id: id).to_a, DeelInvoiceAdjustment.with_deleted.where(ledger_id: id).to_a, PayStub.with_deleted.includes(:pay_cycle).where(ledger_id: id).to_a, - LedgerWithdrawalRequest.includes(:bills, :cancelled_by).where(ledger_id: id).to_a, ].flatten end end diff --git a/test/models/ledger_test.rb b/test/models/ledger_test.rb index f268bcf7..687c3fc5 100644 --- a/test/models/ledger_test.rb +++ b/test/models/ledger_test.rb @@ -151,3 +151,39 @@ 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 +end From b2730e8d52102bafc2de32702d2859769660a474 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 17:59:19 -0400 Subject: [PATCH 33/67] Hosts: in_balance_under_qbo_bound? predicates for QBO-bound balance rule Co-Authored-By: Claude Opus 4.7 --- app/models/contributor_adjustment.rb | 4 ++ app/models/contributor_payout.rb | 6 +++ app/models/deel_invoice_adjustment.rb | 5 +++ app/models/pay_stub.rb | 4 ++ app/models/profit_share.rb | 4 ++ app/models/reimbursement.rb | 5 +++ app/models/trueup.rb | 5 +++ test/models/ledger_test.rb | 61 +++++++++++++++++++++++++++ 8 files changed, 94 insertions(+) diff --git a/app/models/contributor_adjustment.rb b/app/models/contributor_adjustment.rb index 4ad83d9e..90041fbc 100644 --- a/app/models/contributor_adjustment.rb +++ b/app/models/contributor_adjustment.rb @@ -25,6 +25,10 @@ def effective_on_for_display effective_on end + def in_balance_under_qbo_bound? + payable? && !qbo_bill&.paid? + end + # SyncsAsQboBill contract def bill_txn_date effective_on diff --git a/app/models/contributor_payout.rb b/app/models/contributor_payout.rb index b1928f2b..95edf2f1 100644 --- a/app/models/contributor_payout.rb +++ b/app/models/contributor_payout.rb @@ -398,6 +398,12 @@ def as_commission blueprint["Commission"].sum { |l| l["amount"].to_f } end + # QBO-bound balance rule: in balance only if Stacks considers the row + # settled AND its QBO Bill mirror has not yet been marked Paid. + def in_balance_under_qbo_bound? + payable? && !qbo_bill&.paid? + end + # SyncsAsQboBill contract def bill_txn_date invoice_tracker.invoice_pass.start_of_month.end_of_month diff --git a/app/models/deel_invoice_adjustment.rb b/app/models/deel_invoice_adjustment.rb index 3f962718..471f64b1 100644 --- a/app/models/deel_invoice_adjustment.rb +++ b/app/models/deel_invoice_adjustment.rb @@ -16,6 +16,11 @@ class DeelInvoiceAdjustment < ApplicationRecord NON_DEDUCTING_STATUSES = %w[rejected cancelled canceled declined void voided].freeze APPROVED_LEDGER_STATUSES = %w[approved paid].freeze + # DIAs are audit-only on qbo_bound ledgers — never in balance. + def in_balance_under_qbo_bound? + false + end + # Withdrawals deduct from balance. def signed_amount -amount diff --git a/app/models/pay_stub.rb b/app/models/pay_stub.rb index fd11f70e..224196ef 100644 --- a/app/models/pay_stub.rb +++ b/app/models/pay_stub.rb @@ -45,6 +45,10 @@ def payable? accepted? && pay_cycle.stubs_status == :all_accepted && pay_cycle.approved? end + def in_balance_under_qbo_bound? + payable? && !qbo_bill&.paid? + end + def effective_on_for_display pay_cycle.ends_at end diff --git a/app/models/profit_share.rb b/app/models/profit_share.rb index a1f5387b..b5ef5fc3 100644 --- a/app/models/profit_share.rb +++ b/app/models/profit_share.rb @@ -47,6 +47,10 @@ def find_qbo_account!(qbo_accounts = nil) super(qbo_accounts) end + def in_balance_under_qbo_bound? + payable? && !qbo_bill&.paid? + end + # SyncsAsQboBill contract def bill_txn_date applied_at diff --git a/app/models/reimbursement.rb b/app/models/reimbursement.rb index 71ae6963..3bf9d421 100644 --- a/app/models/reimbursement.rb +++ b/app/models/reimbursement.rb @@ -33,6 +33,11 @@ def payable? accepted? end + # Reimbursements aren't synced as QBO bills; same gate as legacy. + def in_balance_under_qbo_bound? + accepted? + end + def effective_on_for_display created_at.to_date end diff --git a/app/models/trueup.rb b/app/models/trueup.rb index 36ebd96b..067444f5 100644 --- a/app/models/trueup.rb +++ b/app/models/trueup.rb @@ -20,6 +20,11 @@ def payable? true end + # Trueups always represent settled income; no payable? gate. + def in_balance_under_qbo_bound? + !qbo_bill&.paid? + end + # SyncsAsQboBill contract def bill_txn_date invoice_pass.start_of_month.end_of_month diff --git a/test/models/ledger_test.rb b/test/models/ledger_test.rb index 687c3fc5..63c888d3 100644 --- a/test/models/ledger_test.rb +++ b/test/models/ledger_test.rb @@ -187,3 +187,64 @@ class LedgerModeAndPaymentMethodsTest < ActiveSupport::TestCase assert_equal %w[deel qbo], Ledger::PAYMENT_METHODS end end + +class HostInBalanceUnderQboBoundTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "QBoundPred-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: 993_001, email: "qbp#{SecureRandom.hex(2)}@example.com", data: {}) + @contributor = Contributor.create!(forecast_person: fp) + @ledger = Ledger.find_or_create_for(enterprise: @enterprise, contributor: @contributor) + end + + test "DeelInvoiceAdjustment is never in balance under qbo_bound" do + dia = DeelInvoiceAdjustment.new(amount: 100, deel_status: "approved") + refute dia.in_balance_under_qbo_bound? + end + + test "Reimbursement uses accepted? for qbo_bound" do + r_accepted = Reimbursement.new + r_accepted.stubs(:accepted?).returns(true) + assert r_accepted.in_balance_under_qbo_bound? + + r_pending = Reimbursement.new + r_pending.stubs(:accepted?).returns(false) + refute r_pending.in_balance_under_qbo_bound? + end + + test "ContributorPayout: in balance when payable and qbo_bill unpaid" do + cp = ContributorPayout.new + cp.stubs(:payable?).returns(true) + cp.stubs(:qbo_bill).returns(nil) + assert cp.in_balance_under_qbo_bound? + + paid = mock("qbo_bill") + paid.stubs(:paid?).returns(true) + cp.stubs(:qbo_bill).returns(paid) + refute cp.in_balance_under_qbo_bound? + + cp.stubs(:payable?).returns(false) + cp.stubs(:qbo_bill).returns(nil) + refute cp.in_balance_under_qbo_bound? + end + + test "Trueup: in balance when qbo_bill unpaid (no payable? check)" do + t = Trueup.new + t.stubs(:qbo_bill).returns(nil) + assert t.in_balance_under_qbo_bound? + + paid = mock("qbo_bill") + paid.stubs(:paid?).returns(true) + t.stubs(:qbo_bill).returns(paid) + refute t.in_balance_under_qbo_bound? + end + + test "ProfitShare, PayStub, ContributorAdjustment all follow the payable?-and-unpaid pattern" do + [ProfitShare, PayStub, ContributorAdjustment].each do |klass| + h = klass.new + h.stubs(:payable?).returns(true) + h.stubs(:qbo_bill).returns(nil) + assert h.in_balance_under_qbo_bound?, "#{klass.name} should be in balance when payable and unpaid" + end + end +end From a9a9167d2eb3c53511943d903fa1d014042b9c41 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:05:08 -0400 Subject: [PATCH 34/67] Ledger: balance/unsettled branch on mode (legacy vs qbo_bound) Co-Authored-By: Claude Sonnet 4.6 --- app/models/ledger.rb | 15 ++++++---- test/models/ledger_test.rb | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/app/models/ledger.rb b/app/models/ledger.rb index 8309555b..27790a23 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -81,15 +81,20 @@ def self.ensure_for_enterprise!(enterprise) 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 preserves today's rules; qbo_bound trusts + # the QBO Bill Paid status as the single source of truth. def balance - visible_items.select(&:payable?).sum(&:signed_amount) + case mode + when "legacy" then visible_items.select(&:payable?).sum(&:signed_amount) + when "qbo_bound" then qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + end end def unsettled - visible_items.reject(&:payable?).sum(&:signed_amount) + case mode + when "legacy" then visible_items.reject(&:payable?).sum(&:signed_amount) + when "qbo_bound" then qbo_bound_visible_items.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + end end # Per-ledger by-month grouping for display. Includes soft-deleted rows so the contributor diff --git a/test/models/ledger_test.rb b/test/models/ledger_test.rb index 63c888d3..974cae5c 100644 --- a/test/models/ledger_test.rb +++ b/test/models/ledger_test.rb @@ -248,3 +248,62 @@ class HostInBalanceUnderQboBoundTest < ActiveSupport::TestCase end 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 positive host whose qbo_bill is paid" 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(:in_balance_under_qbo_bound?).returns(false) + 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 + 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 +end From 0c045d4da1722cbb07d3944ace084e7861b4cf38 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:07:43 -0400 Subject: [PATCH 35/67] Ledger: use enum predicates + raise on unknown mode (review fix) --- app/models/ledger.rb | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/models/ledger.rb b/app/models/ledger.rb index 27790a23..79990c4c 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -84,16 +84,22 @@ def self.ensure_for_enterprise!(enterprise) # Balance/unsettled split. legacy preserves today's rules; qbo_bound trusts # the QBO Bill Paid status as the single source of truth. def balance - case mode - when "legacy" then visible_items.select(&:payable?).sum(&:signed_amount) - when "qbo_bound" then qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + if legacy? + visible_items.select(&:payable?).sum(&:signed_amount) + elsif qbo_bound? + qbo_bound_visible_items.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + else + raise "Unknown ledger mode: #{mode.inspect}" end end def unsettled - case mode - when "legacy" then visible_items.reject(&:payable?).sum(&:signed_amount) - when "qbo_bound" then qbo_bound_visible_items.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + if legacy? + visible_items.reject(&:payable?).sum(&:signed_amount) + elsif qbo_bound? + qbo_bound_visible_items.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount) + else + raise "Unknown ledger mode: #{mode.inspect}" end end From f0c930721c71cb6c9e829447df8c63bbdbb12eea Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:08:59 -0400 Subject: [PATCH 36/67] ContributorAdjustment: reject negative amounts on qbo_bound ledgers Co-Authored-By: Claude Sonnet 4.6 --- app/models/contributor_adjustment.rb | 9 +++++++ test/models/contributor_adjustment_test.rb | 31 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/app/models/contributor_adjustment.rb b/app/models/contributor_adjustment.rb index 90041fbc..db44c4ea 100644 --- a/app/models/contributor_adjustment.rb +++ b/app/models/contributor_adjustment.rb @@ -11,8 +11,17 @@ class ContributorAdjustment < ApplicationRecord validates :amount, presence: true validates :effective_on, presence: true + validate :no_negative_on_qbo_bound_ledger # No linked invoice: counts toward balance like other payable rows. Linked invoice: only when fully paid in QBO. + def no_negative_on_qbo_bound_ledger + return unless ledger&.qbo_bound? && amount.to_f < 0 + errors.add( + :amount, + "negative adjustments are not allowed on QBO-bound ledgers — mark the corresponding QBO bill Paid instead", + ) + end + def payable? return true if qbo_invoice_id.blank? inv = qbo_invoice 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 From 2ba770370b604a88d11323445b6d1d250e7e0f6b Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:11:42 -0400 Subject: [PATCH 37/67] ContributorAdjustment: amount&.negative?, restore payable? docstring (review fix) --- app/models/contributor_adjustment.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/contributor_adjustment.rb b/app/models/contributor_adjustment.rb index db44c4ea..fbefeaa8 100644 --- a/app/models/contributor_adjustment.rb +++ b/app/models/contributor_adjustment.rb @@ -13,15 +13,15 @@ class ContributorAdjustment < ApplicationRecord validates :effective_on, presence: true validate :no_negative_on_qbo_bound_ledger - # No linked invoice: counts toward balance like other payable rows. Linked invoice: only when fully paid in QBO. def no_negative_on_qbo_bound_ledger - return unless ledger&.qbo_bound? && amount.to_f < 0 + 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? return true if qbo_invoice_id.blank? inv = qbo_invoice From 371c638fd0f10e627285de8626cb3f668eef41d2 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:13:04 -0400 Subject: [PATCH 38/67] Ledgers::QboBoundMigrationCheck: per-ledger gate with blocking-bill detail Co-Authored-By: Claude Sonnet 4.6 --- .../ledgers/qbo_bound_migration_check.rb | 57 +++++++++++++++++++ .../ledgers/qbo_bound_migration_check_test.rb | 50 ++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 app/services/ledgers/qbo_bound_migration_check.rb create mode 100644 test/services/ledgers/qbo_bound_migration_check_test.rb 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..cd3720f1 --- /dev/null +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -0,0 +1,57 @@ +module Ledgers + # Computes whether a legacy Ledger can flip to qbo_bound with zero + # change to balance or unsettled. Returns a Result struct exposing + # the deltas and the open QBO bills that explain any gap. + class QboBoundMigrationCheck + TOLERANCE = 0.01.freeze + + Result = Struct.new( + :current_balance, :current_unsettled, + :proposed_balance, :proposed_unsettled, + :balance_delta, :unsettled_delta, + :ready?, :blocking_bills, :ignored_negative_cas, + keyword_init: true, + ) + + BlockingBill = Struct.new(:host, :qbo_bill, :amount, keyword_init: true) + + def self.call(ledger) + legacy_visible = ledger.send(:visible_items) + qbb_visible = ledger.send(:qbo_bound_visible_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_visible.select(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f + new_u = qbb_visible.reject(&:in_balance_under_qbo_bound?).sum(&:signed_amount).to_f + + db = (new_b - legacy_b).round(2) + du = (new_u - legacy_u).round(2) + + 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, + ready?: db.abs < TOLERANCE && du.abs < TOLERANCE, + blocking_bills: collect_blocking_bills(legacy_visible), + ignored_negative_cas: legacy_visible.select { |li| li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 }, + ) + end + + def self.collect_blocking_bills(items) + items.filter_map do |li| + next nil if li.is_a?(DeelInvoiceAdjustment) + next nil if li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 + next nil unless li.respond_to?(:qbo_bill) + next nil unless li.respond_to?(:payable?) && li.payable? + + qb = (li.qbo_bill rescue nil) + next nil if qb.nil? || qb.paid? + + BlockingBill.new(host: li, qbo_bill: qb, amount: li.amount.to_f) + end + end + 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..a85df6f5 --- /dev/null +++ b/test/services/ledgers/qbo_bound_migration_check_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "MigCheck-#{SecureRandom.hex(2)}") + 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) + end + + test "empty legacy ledger is ready (Δ = 0 trivially)" do + result = Ledgers::QboBoundMigrationCheck.call(@ledger) + assert result.ready? + assert_in_delta 0, result.balance_delta, 0.001 + assert_in_delta 0, result.unsettled_delta, 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, :blocking_bills + assert_respond_to r, :ignored_negative_cas + end + + test "ledger is blocked when ledger.balance under qbo_bound != legacy" do + @ledger.stubs(:mode).returns("legacy") + + 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) + cp.stubs(:in_balance_under_qbo_bound?).returns(false) + + neg_ca = ContributorAdjustment.new(amount: -50) + neg_ca.stubs(:payable?).returns(true) + 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 + refute result.ready? + end +end From 527c0fc271f0a7c64aa82f37883bb51062fe8904 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:17:40 -0400 Subject: [PATCH 39/67] QboBoundMigrationCheck: extract shared audit-only predicate, drop dead guards (review fix) --- app/models/ledger.rb | 22 ++++++++++++------- .../ledgers/qbo_bound_migration_check.rb | 13 ++++++----- .../ledgers/qbo_bound_migration_check_test.rb | 3 --- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/models/ledger.rb b/app/models/ledger.rb index 79990c4c..eae59f55 100644 --- a/app/models/ledger.rb +++ b/app/models/ledger.rb @@ -140,7 +140,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 @@ -155,16 +163,14 @@ def visible_items ].flatten end - # qbo_bound mode: drop DIAs (audit only) and negative CAs (audit only). - # Everything else flows through the same per-host predicate - # in_balance_under_qbo_bound?. + # qbo_bound mode: drop audit-only rows; everything else flows through + # the per-host predicate in_balance_under_qbo_bound?. def qbo_bound_visible_items - visible_items.reject do |li| - li.is_a?(DeelInvoiceAdjustment) || - (li.is_a?(ContributorAdjustment) && li.amount.to_f < 0) - end + visible_items.reject { |li| self.class.audit_only_under_qbo_bound?(li) } end + private + # Includes soft-deleted rows — used by items_grouped_by_month for display. def all_items_with_deleted [ diff --git a/app/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb index cd3720f1..6fc87867 100644 --- a/app/services/ledgers/qbo_bound_migration_check.rb +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -3,7 +3,7 @@ module Ledgers # change to balance or unsettled. Returns a Result struct exposing # the deltas and the open QBO bills that explain any gap. class QboBoundMigrationCheck - TOLERANCE = 0.01.freeze + TOLERANCE = 0.01 Result = Struct.new( :current_balance, :current_unsettled, @@ -40,14 +40,15 @@ def self.call(ledger) ) end + # Open QBO bills that explain why qbo_bound balance != legacy balance. + # Skips audit-only rows (DIAs and negative CAs) since they're handled by + # the qbo_bound rule itself, not by reconciling individual bills. def self.collect_blocking_bills(items) items.filter_map do |li| - next nil if li.is_a?(DeelInvoiceAdjustment) - next nil if li.is_a?(ContributorAdjustment) && li.amount.to_f < 0 - next nil unless li.respond_to?(:qbo_bill) - next nil unless li.respond_to?(:payable?) && li.payable? + next nil if Ledger.audit_only_under_qbo_bound?(li) + next nil unless li.payable? - qb = (li.qbo_bill rescue nil) + qb = li.qbo_bill next nil if qb.nil? || qb.paid? BlockingBill.new(host: li, qbo_bill: qb, amount: li.amount.to_f) diff --git a/test/services/ledgers/qbo_bound_migration_check_test.rb b/test/services/ledgers/qbo_bound_migration_check_test.rb index a85df6f5..a7f5d408 100644 --- a/test/services/ledgers/qbo_bound_migration_check_test.rb +++ b/test/services/ledgers/qbo_bound_migration_check_test.rb @@ -27,8 +27,6 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase end test "ledger is blocked when ledger.balance under qbo_bound != legacy" do - @ledger.stubs(:mode).returns("legacy") - paid_qb = mock("qbo_bill"); paid_qb.stubs(:paid?).returns(true) cp = ContributorPayout.new(amount: 100) cp.stubs(:payable?).returns(true) @@ -37,7 +35,6 @@ class Ledgers::QboBoundMigrationCheckTest < ActiveSupport::TestCase cp.stubs(:in_balance_under_qbo_bound?).returns(false) neg_ca = ContributorAdjustment.new(amount: -50) - neg_ca.stubs(:payable?).returns(true) neg_ca.stubs(:signed_amount).returns(-50) @ledger.stubs(:visible_items).returns([cp, neg_ca]) From 4493026fb1a431e5413944069d16340ddf1ec299 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:20:12 -0400 Subject: [PATCH 40/67] Ledger admin: Migrate-to-QBO-bound panel + member_action Co-Authored-By: Claude Opus 4.7 --- app/admin/ledgers.rb | 62 +++++++++++++++++++++++ test/integration/ledger_migration_test.rb | 48 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 test/integration/ledger_migration_test.rb diff --git a/app/admin/ledgers.rb b/app/admin/ledgers.rb index 3c9f0c5a..cc3aab6c 100644 --- a/app/admin/ledgers.rb +++ b/app/admin/ledgers.rb @@ -7,4 +7,66 @@ # the parent's index. Without it, the breadcrumb link is missing. actions :index, :show permit_params + + member_action :migrate_to_qbo_bound, method: :post do + result = Ledgers::QboBoundMigrationCheck.call(resource) + if result.ready? + resource.update!(mode: :qbo_bound) + redirect_to admin_ledger_path(resource), notice: "Migrated to QBO-bound." + else + redirect_to admin_ledger_path(resource), + alert: "Cannot migrate: Δbalance #{result.balance_delta}, Δunsettled #{result.unsettled_delta}." + 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 + para "Current (legacy): balance $#{result.current_balance} unsettled $#{result.current_unsettled}" + para "Proposed (qbo_bound): balance $#{result.proposed_balance} unsettled $#{result.proposed_unsettled}" + para "Δ balance #{result.balance_delta}, Δ unsettled #{result.unsettled_delta}" + end + if result.ready? + div do + para "Net-zero change — safe to migrate." + button_to "Migrate to QBO-bound", migrate_to_qbo_bound_admin_ledger_path(resource), method: :post, data: { confirm: "Flip this ledger to qbo_bound?" } + end + else + div do + if result.blocking_bills.any? + para "Open QBO bills blocking the migration:" + ul do + result.blocking_bills.first(20).each do |bb| + li do + text_node "#{bb.host.class.name} ##{bb.host.id} — $#{bb.amount.to_f.round(2)} — " + link_to "Pay in QBO ↗", bb.qbo_bill.qbo_url, target: "_blank", rel: "noopener" + end + end + end + end + if result.ignored_negative_cas.any? + para "Negative CAs (audit-only after migration):" + ul do + result.ignored_negative_cas.first(10).each do |ca| + li "CA ##{ca.id} — $#{ca.amount.to_f.round(2)}" + end + end + end + para "Resolve the open bills in QBO, then refresh this page or click Re-check." + button_to "Re-check", admin_ledger_path(resource), method: :get + end + end + end + end + end end diff --git a/test/integration/ledger_migration_test.rb b/test/integration/ledger_migration_test.rb new file mode 100644 index 00000000..ec152159 --- /dev/null +++ b/test/integration/ledger_migration_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class LedgerMigrationTest < ActionDispatch::IntegrationTest + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "MigPanel-#{SecureRandom.hex(2)}") + 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) + + @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 with non-zero drift" 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, + ready?: false, blocking_bills: [], ignored_negative_cas: [], + ) + 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 + + private + + def sign_in(admin) + post admin_user_session_path, params: { admin_user: { email: admin.email, password: "password123" } } + end +end From 2d7ef27166005275bbc87de0cab2e7524b01c8c3 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:28:18 -0400 Subject: [PATCH 41/67] Migrate panel: number_to_currency, legacy guard, Reimbursement crash fix --- app/admin/ledgers.rb | 16 ++++++++++------ .../ledgers/qbo_bound_migration_check.rb | 5 +++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/admin/ledgers.rb b/app/admin/ledgers.rb index cc3aab6c..1948c0ea 100644 --- a/app/admin/ledgers.rb +++ b/app/admin/ledgers.rb @@ -9,13 +9,17 @@ permit_params 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." else redirect_to admin_ledger_path(resource), - alert: "Cannot migrate: Δbalance #{result.balance_delta}, Δunsettled #{result.unsettled_delta}." + alert: "Cannot migrate: Δbalance #{number_to_currency(result.balance_delta)}, Δunsettled #{number_to_currency(result.unsettled_delta)}." end end @@ -32,9 +36,9 @@ panel "Migrate to QBO-bound" do result = Ledgers::QboBoundMigrationCheck.call(resource) div do - para "Current (legacy): balance $#{result.current_balance} unsettled $#{result.current_unsettled}" - para "Proposed (qbo_bound): balance $#{result.proposed_balance} unsettled $#{result.proposed_unsettled}" - para "Δ balance #{result.balance_delta}, Δ unsettled #{result.unsettled_delta}" + 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 if result.ready? div do @@ -48,7 +52,7 @@ ul do result.blocking_bills.first(20).each do |bb| li do - text_node "#{bb.host.class.name} ##{bb.host.id} — $#{bb.amount.to_f.round(2)} — " + text_node "#{bb.host.class.name} ##{bb.host.id} — #{number_to_currency(bb.amount.to_f)} — " link_to "Pay in QBO ↗", bb.qbo_bill.qbo_url, target: "_blank", rel: "noopener" end end @@ -58,7 +62,7 @@ para "Negative CAs (audit-only after migration):" ul do result.ignored_negative_cas.first(10).each do |ca| - li "CA ##{ca.id} — $#{ca.amount.to_f.round(2)}" + li "CA ##{ca.id} — #{number_to_currency(ca.amount.to_f)}" end end end diff --git a/app/services/ledgers/qbo_bound_migration_check.rb b/app/services/ledgers/qbo_bound_migration_check.rb index 6fc87867..3bf25069 100644 --- a/app/services/ledgers/qbo_bound_migration_check.rb +++ b/app/services/ledgers/qbo_bound_migration_check.rb @@ -41,11 +41,12 @@ def self.call(ledger) end # Open QBO bills that explain why qbo_bound balance != legacy balance. - # Skips audit-only rows (DIAs and negative CAs) since they're handled by - # the qbo_bound rule itself, not by reconciling individual bills. + # Skips audit-only rows (DIAs, negative CAs) and rows that don't sync + # as QBO Bills at all (e.g. Reimbursement). def self.collect_blocking_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 From 419efca73b8bd0707e9e608295ed5c192072ca9c Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:28:47 -0400 Subject: [PATCH 42/67] Migrate panel: use helpers.number_to_currency in member_action context --- app/admin/ledgers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/admin/ledgers.rb b/app/admin/ledgers.rb index 1948c0ea..2a15e245 100644 --- a/app/admin/ledgers.rb +++ b/app/admin/ledgers.rb @@ -19,7 +19,7 @@ redirect_to admin_ledger_path(resource), notice: "Migrated to QBO-bound." else redirect_to admin_ledger_path(resource), - alert: "Cannot migrate: Δbalance #{number_to_currency(result.balance_delta)}, Δunsettled #{number_to_currency(result.unsettled_delta)}." + alert: "Cannot migrate: Δbalance #{helpers.number_to_currency(result.balance_delta)}, Δunsettled #{helpers.number_to_currency(result.unsettled_delta)}." end end From ce3243f0bce8d515721031b356e73eceea795781 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:29:45 -0400 Subject: [PATCH 43/67] ledgers:migrate_qbo_bound_zero_drift rake task Co-Authored-By: Claude Sonnet 4.6 --- lib/tasks/ledgers.rake | 23 ++++++++++++++++++++ test/lib/tasks/ledgers_rake_test.rb | 33 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 lib/tasks/ledgers.rake create mode 100644 test/lib/tasks/ledgers_rake_test.rb 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/lib/tasks/ledgers_rake_test.rb b/test/lib/tasks/ledgers_rake_test.rb new file mode 100644 index 00000000..89420cc2 --- /dev/null +++ b/test/lib/tasks/ledgers_rake_test.rb @@ -0,0 +1,33 @@ +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.find_or_create_by!(name: "RakeMig-#{SecureRandom.hex(2)}") + 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) + 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, ready?: false, blocking_bills: [], ignored_negative_cas: [], + ) + Ledgers::QboBoundMigrationCheck.stubs(:call).returns(blocked) + + Rake::Task["ledgers:migrate_qbo_bound_zero_drift"].invoke + assert @ledger.reload.legacy? + end +end From 1f08182c9d466601c42d1109c9b07a8009a3fe3f Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:34:06 -0400 Subject: [PATCH 44/67] TaskBuilder: surface legacy ledgers pending QBO migration Co-Authored-By: Claude Opus 4.7 --- app/models/stacks_task.rb | 7 ++- lib/stacks/task_builder.rb | 2 +- .../legacy_ledgers_pending_qbo_migration.rb | 44 +++++++++++++++++++ ...gacy_ledgers_pending_qbo_migration_test.rb | 39 ++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb create mode 100644 test/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration_test.rb diff --git a/app/models/stacks_task.rb b/app/models/stacks_task.rb index 7f4e9f71..e12c01ff 100644 --- a/app/models/stacks_task.rb +++ b/app/models/stacks_task.rb @@ -134,7 +134,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 when LedgerWithdrawalRequest if type == :ledger_withdrawal_request_needs_processing helpers.admin_ledger_withdrawal_requests_path(scope: "pending") diff --git a/lib/stacks/task_builder.rb b/lib/stacks/task_builder.rb index edab9317..9ae5a596 100644 --- a/lib/stacks/task_builder.rb +++ b/lib/stacks/task_builder.rb @@ -50,7 +50,7 @@ class TaskBuilder Discoveries::Surveys, Discoveries::PayCycles, Discoveries::MissingQboVendors, - Discoveries::LedgerWithdrawalRequests, + 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..37aaddfc --- /dev/null +++ b/lib/stacks/task_builder/discoveries/legacy_ledgers_pending_qbo_migration.rb @@ -0,0 +1,44 @@ +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 + ].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/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 From d35ec37f234a50b206f1b3c5fecc0dd7cd344b55 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:38:37 -0400 Subject: [PATCH 45/67] Money::PayableQboBills: cross-enterprise open-bill selection Co-Authored-By: Claude Sonnet 4.6 --- app/services/money/payable_qbo_bills.rb | 42 +++++++++++++++ test/services/money/payable_qbo_bills_test.rb | 51 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 app/services/money/payable_qbo_bills.rb create mode 100644 test/services/money/payable_qbo_bills_test.rb diff --git a/app/services/money/payable_qbo_bills.rb b/app/services/money/payable_qbo_bills.rb new file mode 100644 index 00000000..c9338fd3 --- /dev/null +++ b/app/services/money/payable_qbo_bills.rb @@ -0,0 +1,42 @@ +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, + ].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.qbo_bill rescue nil) + 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/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 From 450a050557c2068e2f6f30a8e43baeb36babb6a4 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:39:10 -0400 Subject: [PATCH 46/67] Money::RefreshPayableQboBills: bulk re-sync open bills for one QBO account Co-Authored-By: Claude Sonnet 4.6 --- .../money/refresh_payable_qbo_bills.rb | 12 ++++++++++ .../money/refresh_payable_qbo_bills_test.rb | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 app/services/money/refresh_payable_qbo_bills.rb create mode 100644 test/services/money/refresh_payable_qbo_bills_test.rb 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/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 From 88d85105cb5b0c8d58bf3347f2cf5fbac882c36e Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:41:40 -0400 Subject: [PATCH 47/67] Money admin: Payable QBO Bills page, tabbed per QBO account Co-Authored-By: Claude Sonnet 4.6 --- app/admin/money.rb | 29 +++++++++++- .../admin/money/payable_qbo_bills.html.erb | 46 +++++++++++++++++++ test/integration/payable_qbo_bills_test.rb | 35 ++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 app/views/admin/money/payable_qbo_bills.html.erb create mode 100644 test/integration/payable_qbo_bills_test.rb 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/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/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 From f4479dc1af9033ae535426a63b1d6476691c9198 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:44:21 -0400 Subject: [PATCH 48/67] DeelInvoiceAdjustments::CreateForLedger: direct Deel API call (no withdrawal request) Co-Authored-By: Claude Sonnet 4.6 --- .../create_for_ledger.rb | 53 +++++++++++++++++ .../create_for_ledger_test.rb | 57 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 app/services/deel_invoice_adjustments/create_for_ledger.rb create mode 100644 test/services/deel_invoice_adjustments/create_for_ledger_test.rb diff --git a/app/services/deel_invoice_adjustments/create_for_ledger.rb b/app/services/deel_invoice_adjustments/create_for_ledger.rb new file mode 100644 index 00000000..a6196d09 --- /dev/null +++ b/app/services/deel_invoice_adjustments/create_for_ledger.rb @@ -0,0 +1,53 @@ +module DeelInvoiceAdjustments + # Creates a DeelInvoiceAdjustment in Deel for a given ledger + contract, + # then persists the response as a Stacks-side DIA. Replaces the + # withdrawal-request-mediated path from LedgerWithdrawalRequests::ProcessViaDeel. + class CreateForLedger + class Error < StandardError; end + + def self.call(ledger:, amount:, contract_id:, description:, date_submitted:, initiated_by:) + new(ledger: ledger, amount: amount, contract_id: contract_id, + description: description, date_submitted: date_submitted, initiated_by: initiated_by).call + end + + def initialize(ledger:, amount:, contract_id:, description:, date_submitted:, initiated_by:) + @ledger = ledger + @amount = BigDecimal(amount.to_s) + @contract_id = contract_id.to_s + @description = description.to_s + @date_submitted = date_submitted + @initiated_by = initiated_by + end + + def call + parsed = call_deel_api + raise Error, "Deel did not return an adjustment id" if parsed.dig("data", "id").blank? + + DeelInvoiceAdjustment.create_from_deel_response!( + ledger: @ledger, + deel_contract_id: @contract_id, + amount: @amount, + description: @description, + date_submitted: @date_submitted, + parsed_response: parsed, + ) + rescue ActiveRecord::RecordInvalid => e + raise Error, "Could not persist DIA: #{e.message}" + end + + private + + # Calls the Deel invoice-adjustments API directly, matching the payload + # structure from Contributors::SubmitDeelInvoiceAdjustment#call. + def call_deel_api + Stacks::Deel.create_invoice_adjustment!( + amount: @amount, + contract_id: @contract_id, + description: @description, + date_submitted: @date_submitted, + ) + rescue Stacks::Deel::ApiError => e + raise Error, e.message + end + end +end diff --git a/test/services/deel_invoice_adjustments/create_for_ledger_test.rb b/test/services/deel_invoice_adjustments/create_for_ledger_test.rb new file mode 100644 index 00000000..f09e11f3 --- /dev/null +++ b/test/services/deel_invoice_adjustments/create_for_ledger_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class DeelInvoiceAdjustments::CreateForLedgerTest < ActiveSupport::TestCase + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "DelegLed-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "del#{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[deel]) + + dp_id = "dp#{SecureRandom.hex(2)}" + DeelPerson.create!(deel_id: dp_id, data: {}) + @contract = DeelContract.create!(deel_id: "dc#{SecureRandom.hex(2)}", deel_person_id: dp_id, data: { "type" => "ongoing_time_based" }) + + @admin = AdminUser.create!(email: "dca#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", roles: ["admin"]) + end + + test "creates a DIA when Deel API call succeeds" do + fake_response = { "data" => { "id" => "adj-42", "status" => "pending" } } + DeelInvoiceAdjustment.expects(:create_from_deel_response!).with( + ledger: @ledger, + deel_contract_id: @contract.deel_id, + amount: 100, + description: "test", + date_submitted: Date.current, + parsed_response: fake_response, + ).returns(DeelInvoiceAdjustment.new) + + DeelInvoiceAdjustments::CreateForLedger.any_instance.stubs(:call_deel_api).returns(fake_response) + + result = DeelInvoiceAdjustments::CreateForLedger.call( + ledger: @ledger, + amount: 100, + contract_id: @contract.deel_id, + description: "test", + date_submitted: Date.current, + initiated_by: @admin, + ) + assert result.is_a?(DeelInvoiceAdjustment) + end + + test "raises CreateForLedger::Error when Deel API returns no adjustment id" do + DeelInvoiceAdjustments::CreateForLedger.any_instance.stubs(:call_deel_api).returns({ "data" => {} }) + + assert_raises(DeelInvoiceAdjustments::CreateForLedger::Error) do + DeelInvoiceAdjustments::CreateForLedger.call( + ledger: @ledger, + amount: 100, + contract_id: @contract.deel_id, + description: "test", + date_submitted: Date.current, + initiated_by: @admin, + ) + end + end +end From f0099912d13bfa659593bdbc4852fba374020cdf Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:45:34 -0400 Subject: [PATCH 49/67] Contributors admin: withdraw_via_deel member_action + gated form Co-Authored-By: Claude Sonnet 4.6 --- app/admin/contributors.rb | 21 +++++++ app/views/admin/contributors/_show.html.erb | 18 ++++++ .../contributor_withdraw_via_deel_test.rb | 55 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 test/integration/contributor_withdraw_via_deel_test.rb diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index b6cf4e5e..6479f298 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -85,6 +85,27 @@ def manual_deel_invoice_visible?(contributor) end end + member_action :withdraw_via_deel, method: :post do + ledger = Ledger.find(params.require(:ledger_id)) + unless ledger.deel_enabled? + redirect_back fallback_location: admin_contributor_path(resource), alert: "Deel is not enabled for this ledger." + return + end + + DeelInvoiceAdjustments::CreateForLedger.call( + ledger: ledger, + amount: params.require(:amount), + contract_id: params.require(:contract_id), + description: params[:description].to_s, + date_submitted: params[:date_submitted].presence || Date.current, + initiated_by: current_admin_user, + ) + + redirect_back fallback_location: admin_contributor_path(resource), notice: "Withdrew via Deel." + rescue DeelInvoiceAdjustments::CreateForLedger::Error => e + redirect_back fallback_location: admin_contributor_path(resource), alert: e.message + end + member_action :toggle_contributor_payout_acceptance, method: :post do cp = ContributorPayout.find(params[:contributor_payout_id]) return unless cp.contributor.forecast_person.try(:admin_user) == current_admin_user || current_admin_user.is_admin? diff --git a/app/views/admin/contributors/_show.html.erb b/app/views/admin/contributors/_show.html.erb index f164bdb4..0042cb44 100644 --- a/app/views/admin/contributors/_show.html.erb +++ b/app/views/admin/contributors/_show.html.erb @@ -319,6 +319,24 @@ <% end %> +<% if view_mode == :ledger && current_ledger&.deel_enabled? %> +
+
+
+

Withdraw via Deel

+
+
+ <%= form_tag(withdraw_via_deel_admin_contributor_path(contributor), method: :post, style: "display: inline") do %> + <%= hidden_field_tag :ledger_id, current_ledger.id %> + <%= number_field_tag :amount, current_ledger.balance.to_f, step: "0.01", min: "0.01", max: current_ledger.balance.to_f %> + <%= select_tag :contract_id, options_for_select(contributor.deel_person&.deel_contracts&.map { |c| [c.deel_contract_type_label, c.deel_id] } || []) %> + <%= submit_tag "Withdraw via Deel" %> + <% end %> +
+
+
+<% end %> + <%# Per-contributor recurring ledger adjustments. Filters by the active ledger when one is selected; shows all ledgers' rows on the All view. Renders a compact table with links to the top-level admin for editing. %> diff --git a/test/integration/contributor_withdraw_via_deel_test.rb b/test/integration/contributor_withdraw_via_deel_test.rb new file mode 100644 index 00000000..25957c33 --- /dev/null +++ b/test/integration/contributor_withdraw_via_deel_test.rb @@ -0,0 +1,55 @@ +require "test_helper" + +class ContributorWithdrawViaDeelTest < ActionDispatch::IntegrationTest + setup do + Thread.current[:sanctuary_enterprise] = nil + @enterprise = Enterprise.find_or_create_by!(name: "WVD-#{SecureRandom.hex(2)}") + fp = ForecastPerson.create!(forecast_id: rand(1..2_000_000_000), email: "wvd#{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[deel]) + + dp_id = "dp#{SecureRandom.hex(2)}" + DeelPerson.create!(deel_id: dp_id, data: {}) + @contract = DeelContract.create!(deel_id: "dc#{SecureRandom.hex(2)}", deel_person_id: dp_id, data: { "type" => "ongoing_time_based" }) + + @admin = AdminUser.create!(email: "wvd#{SecureRandom.hex(2)}@example.com", password: "password123", password_confirmation: "password123", roles: ["admin"]) + sign_in @admin + end + + test "POST withdraw_via_deel calls CreateForLedger on a deel-enabled ledger" do + DeelInvoiceAdjustments::CreateForLedger.expects(:call).with( + ledger: @ledger, + amount: "100", + contract_id: @contract.deel_id, + description: "", + date_submitted: anything, + initiated_by: instance_of(AdminUser), + ).returns(DeelInvoiceAdjustment.new) + + post withdraw_via_deel_admin_contributor_path(@contributor), params: { + ledger_id: @ledger.id, + amount: "100", + contract_id: @contract.deel_id, + } + assert_response :redirect + end + + test "POST withdraw_via_deel refuses on a non-deel ledger" do + @ledger.update!(payment_methods: %w[qbo]) + DeelInvoiceAdjustments::CreateForLedger.expects(:call).never + + post withdraw_via_deel_admin_contributor_path(@contributor), params: { + ledger_id: @ledger.id, + amount: "100", + contract_id: @contract.deel_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 From 2a200db40c16f1450f48bb1942fbd7dfd36f80a5 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:46:52 -0400 Subject: [PATCH 50/67] Delete LedgerWithdrawalRequest model, admin, services, discovery, views --- app/admin/ledger_withdrawal_requests.rb | 267 ------------------ app/models/ledger_withdrawal_request.rb | 97 ------- app/models/ledger_withdrawal_request_bill.rb | 58 ---- .../enumerate_candidate_bills.rb | 106 ------- .../process_via_deel.rb | 54 ---- .../_bills_panel.html.erb | 114 -------- .../_intro_panel.html.erb | 13 - .../_message_panel.html.erb | 24 -- .../_notes_panel.html.erb | 20 -- .../ledger_withdrawal_requests/_show.html.erb | 136 --------- .../missing_qbo_vendor.html.erb | 15 - .../discoveries/ledger_withdrawal_requests.rb | 30 -- script/why_balance_goes_up.rb | 221 +++++++++++++++ 13 files changed, 221 insertions(+), 934 deletions(-) delete mode 100644 app/admin/ledger_withdrawal_requests.rb delete mode 100644 app/models/ledger_withdrawal_request.rb delete mode 100644 app/models/ledger_withdrawal_request_bill.rb delete mode 100644 app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb delete mode 100644 app/services/ledger_withdrawal_requests/process_via_deel.rb delete mode 100644 app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb delete mode 100644 app/views/admin/ledger_withdrawal_requests/_intro_panel.html.erb delete mode 100644 app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb delete mode 100644 app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb delete mode 100644 app/views/admin/ledger_withdrawal_requests/_show.html.erb delete mode 100644 app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb delete mode 100644 lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb create mode 100644 script/why_balance_goes_up.rb diff --git a/app/admin/ledger_withdrawal_requests.rb b/app/admin/ledger_withdrawal_requests.rb deleted file mode 100644 index 1200d80c..00000000 --- a/app/admin/ledger_withdrawal_requests.rb +++ /dev/null @@ -1,267 +0,0 @@ -ActiveAdmin.register LedgerWithdrawalRequest do - menu label: "Withdrawal Requests", parent: "Money" - config.filters = false - actions :index, :new, :create, :show - - scope :pending, default: true - scope "Paid", :processed - scope :cancelled - - permit_params :ledger_id, :notes, bills_attributes: [:qbo_account_id, :qbo_bill_id, :amount_snapshot] - - action_item :process_via_qbo, only: :show, if: proc { resource.pending? && current_admin_user.is_admin? } do - url = qbo_vendor_url_for(resource) - if url.present? - link_to "Open Vendor in QBO ↗", url, target: "_blank", rel: "noopener" - else - msg = "No QBO vendor mapped for this contributor on #{resource.enterprise.name}" - link_to "Open Vendor in QBO ↗", "#", onclick: "alert(#{msg.to_json}); return false;" - end - end - - action_item :cancel, only: :show, if: proc { resource.pending? && current_admin_user.is_admin? } do - # Browser-prompt for the reason so the controller doesn't have to bounce - # through a separate form just to capture one string. JS submits the - # reason as a form param via a synthesized POST. - confirm_msg = "Cancel this withdrawal request? The bills go back to selectable for the contributor." - cancel_url = cancel_admin_ledger_withdrawal_request_path(resource) - link_to "Cancel Request", "#", onclick: <<~JS.html_safe - (function(){ - if (!confirm(#{confirm_msg.to_json})) return false; - var reason = prompt("Reason for cancelling? (optional)"); - if (reason === null) return false; - var token = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); - var form = document.createElement('form'); - form.method = 'post'; - form.action = #{cancel_url.to_json}; - var t = document.createElement('input'); t.type = 'hidden'; t.name = 'authenticity_token'; t.value = token; form.appendChild(t); - if (reason) { var r = document.createElement('input'); r.type = 'hidden'; r.name = 'reason'; r.value = reason; form.appendChild(r); } - document.body.appendChild(form); form.submit(); - return false; - })(); return false; - JS - end - - member_action :process_via_deel, method: :post do - LedgerWithdrawalRequests::ProcessViaDeel.call( - request: resource, - processed_by: current_admin_user, - contract_id: params.require(:contract_id), - description: params[:description].to_s, - amount: params[:amount].presence, - date_submitted: params[:date_submitted].presence || Date.current, - allow_overpayment: ActiveModel::Type::Boolean.new.cast(params[:allow_overpayment]), - ) - redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Processed via Deel." - rescue LedgerWithdrawalRequests::ProcessViaDeel::Error => e - redirect_to admin_ledger_withdrawal_request_path(resource), alert: e.message - end - - member_action :cancel, method: :post do - resource.update!( - cancelled_at: Time.current, - cancelled_by: current_admin_user, - cancelled_reason: params[:reason].to_s.presence, - ) - redirect_to admin_ledger_withdrawal_request_path(resource), notice: "Request cancelled." - end - - controller do - helper_method :enumerate_candidates_for, :contributor_owns_ledger?, :qbo_vendor_url_for - - before_action :require_ledger_param, only: [:new] - before_action :verify_ledger_access!, only: [:new, :create] - before_action :verify_qbo_vendor_mapping!, only: [:new, :create] - before_action :require_admin_for_processing!, only: [:process_via_deel, :cancel] - - # Fail fast (with a friendly screen, not a redirect) when the contributor - # has no ContributorQboVendor for the ledger's enterprise QBO account. - # Withdrawal requests can only resolve into Bills that need to be paid - # against a specific vendor; without that mapping nothing downstream - # will work and the form would only produce a confusing dead end. - def verify_qbo_vendor_mapping! - ledger_id = params[:ledger_id] || params.dig(:ledger_withdrawal_request, :ledger_id) - ledger = Ledger.find_by(id: ledger_id) - return if ledger.nil? - - qa = ledger.enterprise.qbo_account - vendor = qa && ledger.contributor.qbo_vendor_for(qa) - return if vendor.present? - - @missing_vendor_ledger = ledger - @missing_vendor_qbo_account = qa - render :missing_qbo_vendor, layout: "active_admin", status: :unprocessable_entity - end - - def require_admin_for_processing! - return if current_admin_user.is_admin? - redirect_to admin_ledger_withdrawal_request_path(resource), alert: "Only Stacks admins can process or cancel requests." - end - - # Best-effort deep link to the connected QBO vendor record for this - # ledger's contributor. Used by the "Process via QBO" action. - def qbo_vendor_url_for(request) - qa = request.enterprise.qbo_account - return nil if qa.nil? - vendor = request.contributor.qbo_vendor_for(qa) - return nil if vendor.nil? - "https://qbo.intuit.com/app/vendordetail?nameId=#{vendor.qbo_id}" - end - - def require_ledger_param - return if params[:ledger_id].present? - redirect_to admin_root_path, alert: "A ledger must be selected before submitting a withdrawal request." - end - - def verify_ledger_access! - ledger_id = params[:ledger_id] || params.dig(:ledger_withdrawal_request, :ledger_id) - return redirect_to(admin_root_path, alert: "Ledger not specified.") if ledger_id.blank? - - ledger = Ledger.find_by(id: ledger_id) - return redirect_to(admin_root_path, alert: "Ledger not found.") if ledger.nil? - - return if current_admin_user.is_admin? - return if contributor_owns_ledger?(ledger) - - redirect_to admin_root_path, alert: "You cannot submit withdrawals for that ledger." - end - - def contributor_owns_ledger?(ledger) - fp = current_admin_user.forecast_person - return false if fp.nil? - ledger.contributor_id == fp.contributor&.id - end - - def enumerate_candidates_for(ledger) - LedgerWithdrawalRequests::EnumerateCandidateBills.call(ledger) - end - - # Pre-set the ledger from the URL param so AA's form DSL has access to it. - def build_resource - resource = super - ledger_id = params[:ledger_id] || params.dig(:ledger_withdrawal_request, :ledger_id) - resource.ledger ||= Ledger.find_by(id: ledger_id) if ledger_id.present? - resource - end - - def create - ledger = Ledger.find(params.dig(:ledger_withdrawal_request, :ledger_id)) - selected_keys = Array(params.dig(:ledger_withdrawal_request, :selected_bill_keys)).reject(&:blank?) - - if selected_keys.empty? - @ledger = ledger - @candidates = enumerate_candidates_for(ledger) - @ledger_withdrawal_request = LedgerWithdrawalRequest.new(ledger: ledger) - flash.now[:alert] = "Select at least one bill to request payment for." - render :new, status: :unprocessable_entity - return - end - - # Re-resolve every selected (qbo_account_id, qbo_bill_id) against the - # candidate list — never trust the form's amount field for a value we - # snapshot. This also drops anything the contributor selected but is no - # longer selectable (paid in QBO between page load and submit, etc). - candidates_by_key = enumerate_candidates_for(ledger).index_by { |r| "#{r.qbo_account_id}:#{r.qbo_bill_id}" } - valid_rows = selected_keys.filter_map { |key| candidates_by_key[key] }.select(&:selectable) - - if valid_rows.empty? - redirect_to(new_admin_ledger_withdrawal_request_path(ledger_id: ledger.id), - alert: "None of the selected bills are still eligible. Try again.") - return - end - - req = LedgerWithdrawalRequest.create!( - ledger: ledger, - requested_at: Time.current, - notes: params.dig(:ledger_withdrawal_request, :notes), - bills_attributes: valid_rows.map { |r| { qbo_account_id: r.qbo_account_id, qbo_bill_id: r.qbo_bill_id, amount_snapshot: r.amount } }, - ) - - redirect_to admin_ledger_withdrawal_request_path(req), notice: "Withdrawal request submitted." - end - - def scoped_collection - super.includes(:ledger, :enterprise, :contributor, bills: :qbo_account) - end - end - - index download_links: false do - column :contributor do |r| - r.contributor.forecast_person&.email || "Contributor ##{r.contributor.id}" - end - column :enterprise do |r| - r.enterprise.name - end - column "Bills", &:bills - column "Total" do |r| - number_to_currency(r.total_amount) - end - column :requested_at - column :status do |r| - if r.cancelled? - status_tag("Cancelled") - elsif r.processed? - status_tag("Processed (#{r.paid_via})") - else - status_tag("Pending") - end - end - actions - end - - show do - render partial: "show", locals: { resource: resource } - end - - form do |f| - ledger = f.object.ledger - if ledger.nil? - panel "Pick a ledger first" do - para "Open this form from a contributor's ledger tab so we know which enterprise to bill against." - end - else - candidates = LedgerWithdrawalRequests::EnumerateCandidateBills.call(ledger) - .select(&:selectable) - .sort_by { |r| [r.effective_on || Date.new(1970, 1, 1), r.qbo_bill_id.to_s] } - .reverse - - if candidates.empty? - # No bills available — render the shared message panel (same chrome - # as the missing-QBO-vendor screen) instead of the AA panel default. - # No Notes input, no submit button — there's nothing to submit. - render partial: "admin/ledger_withdrawal_requests/message_panel", locals: { - title: "📭 Nothing to request right now", - paragraphs: [ - "No bills on your #{ledger.enterprise.name} ledger are ready to request payment for yet.", - "Anything that's not yet payable (e.g. waiting on cycle approval), already paid in QuickBooks, or already included in another open request won't show up here. Once a new bill becomes payable, come back and submit a request.", - ], - back_path: admin_contributor_path(ledger.contributor, ledger: ledger.id), - } - else - f.semantic_errors - - # Reach for the same dashboard-modules / module-header / module-body - # / index_table HTML the rest of Stacks uses (see the Contributor - # ledger view) so the chrome here matches without us trying to skin - # AA's .panel and fieldset chrome from the outside. - render partial: "admin/ledger_withdrawal_requests/intro_panel", locals: { ledger: ledger } - - f.input :ledger_id, as: :hidden, input_html: { value: ledger.id } - - render partial: "admin/ledger_withdrawal_requests/bills_panel", locals: { candidates: candidates } - - render partial: "admin/ledger_withdrawal_requests/notes_panel", locals: { f: f } - - # Initial label reflects "everything selected" (the default state on - # first render). JS in _bills_panel keeps it in sync as the user - # toggles rows. - initial_total = candidates.sum(&:amount) - f.actions do - f.action :submit, - label: "Submit Withdrawal Request for #{number_to_currency(initial_total)}" - f.cancel_link(admin_contributor_path(ledger.contributor)) - end - end - end - end -end diff --git a/app/models/ledger_withdrawal_request.rb b/app/models/ledger_withdrawal_request.rb deleted file mode 100644 index a2b43b16..00000000 --- a/app/models/ledger_withdrawal_request.rb +++ /dev/null @@ -1,97 +0,0 @@ -class LedgerWithdrawalRequest < ApplicationRecord - # Any change to processed_at / cancelled_at flips this row's - # discovery-eligibility, so bust the TaskBuilder cache on save/destroy - # so the next admin page render rebuilds the queue. - include BustsTaskCache - - PAID_VIA_DEEL = "deel".freeze - PAID_VIA_QBO_BILL_PAY = "qbo_bill_pay".freeze - PAID_VIA_MANUAL = "manual".freeze - PAID_VIA_VALUES = [PAID_VIA_DEEL, PAID_VIA_QBO_BILL_PAY, PAID_VIA_MANUAL].freeze - - belongs_to :ledger - belongs_to :cancelled_by, class_name: "AdminUser", optional: true - belongs_to :deel_invoice_adjustment, optional: true - has_many :bills, - class_name: "LedgerWithdrawalRequestBill", - dependent: :destroy, - inverse_of: :ledger_withdrawal_request - has_one :contributor, through: :ledger - has_one :enterprise, through: :ledger - - accepts_nested_attributes_for :bills - - validates :requested_at, presence: true - validates :paid_via, inclusion: { in: PAID_VIA_VALUES }, allow_nil: true - validate :paid_via_set_iff_processed - validate :cannot_be_processed_and_cancelled - - scope :pending, -> { where(processed_at: nil, cancelled_at: nil) } - scope :processed, -> { where.not(processed_at: nil) } - scope :cancelled, -> { where.not(cancelled_at: nil) } - - def pending? - processed_at.nil? && cancelled_at.nil? - end - - def processed? - processed_at.present? - end - - def cancelled? - cancelled_at.present? - end - - # Sum of the amount_snapshot column across every included bill — what the - # contributor saw on the selection screen when they submitted. Stable - # against later QBO-side amount edits. - def total_amount - bills.sum(:amount_snapshot) - end - - # Subset of bills whose linked QboBill mirror is marked Paid. Drives the - # "N / M paid" progress display and the auto-process trigger. - def paid_bills - bills.select(&:paid?) - end - - def all_bills_paid? - bills.any? && bills.all?(&:paid?) - end - - # Lets Ledger#items_grouped_by_month / Contributor#all_items_grouped_by_month - # splice withdrawal requests into the timeline alongside regular ledger items - # without a custom code path — they're rendered as informational milestones - # ("contributor asked for these bills to be paid on this date") rather than - # balance-affecting rows. No payable?/signed_amount on purpose: the balance - # walks never branch on this class, so withdrawal requests stay invisible to - # balance / unsettled / total_income. - def effective_on_for_display - requested_at&.to_date - end - - # Auto-process: when every Bill in this request is Paid in QBO, flip - # processed_at without requiring a controller click. Called by the daily - # QBO sync after the QboBill mirror updates. Idempotent. - def maybe_auto_process!(paid_via: PAID_VIA_QBO_BILL_PAY) - return if processed? || cancelled? - return unless all_bills_paid? - update!(processed_at: Time.current, paid_via: paid_via) - end - - private - - def paid_via_set_iff_processed - if processed_at.present? && paid_via.blank? - errors.add(:paid_via, "must be set when processed_at is set") - elsif processed_at.blank? && paid_via.present? - errors.add(:paid_via, "must be blank when processed_at is blank") - end - end - - def cannot_be_processed_and_cancelled - if processed_at.present? && cancelled_at.present? - errors.add(:base, "cannot be both processed and cancelled") - end - end -end diff --git a/app/models/ledger_withdrawal_request_bill.rb b/app/models/ledger_withdrawal_request_bill.rb deleted file mode 100644 index 42393b2d..00000000 --- a/app/models/ledger_withdrawal_request_bill.rb +++ /dev/null @@ -1,58 +0,0 @@ -class LedgerWithdrawalRequestBill < ApplicationRecord - belongs_to :ledger_withdrawal_request, inverse_of: :bills - belongs_to :qbo_account - - validates :qbo_bill_id, presence: true - validates :amount_snapshot, presence: true - validates :qbo_bill_id, uniqueness: { - scope: [:ledger_withdrawal_request_id, :qbo_account_id], - message: "already attached to this request", - } - - # Host classes that emit Bills through SyncsAsQboBill. Used by - # #host_record to resolve which ledger item is being settled by this - # row — that's how the Process via Deel description lists things like - # "Contributor Payout" / "Pay Stub" instead of just bare QBO Bill ids. - HOST_CLASSES = [ - ContributorPayout, - ContributorAdjustment, - ProfitShare, - Trueup, - PayStub, - ].freeze - - # Resolve the local QboBill mirror via the composite (qbo_account_id, qbo_id) - # key — same pattern SyncsAsQboBill hosts use. Memoized per instance. - def qbo_bill - return @_qbo_bill if defined?(@_qbo_bill) - @_qbo_bill = QboBill.find_by(qbo_account_id: qbo_account_id, qbo_id: qbo_bill_id) - end - - # Walks the SyncsAsQboBill host tables and returns the (ledger item) row - # that pushed this QBO Bill. There's no reverse FK we can rely on, so - # we look up by qbo_bill_id across each candidate class. Memoized. - def host_record - return @_host_record if defined?(@_host_record) - @_host_record = HOST_CLASSES.lazy.filter_map do |klass| - klass.find_by(qbo_bill_id: qbo_bill_id) - end.first - end - - # Human label for the host class, falling back to "QBO Bill" if we can't - # resolve which host emitted it (e.g. mirror existed at request time but - # the host row has since been hard-deleted). - def host_label - host_record ? host_record.class.name.titleize : "QBO Bill" - end - - def host_effective_on - return nil unless host_record.respond_to?(:effective_on_for_display) - host_record.effective_on_for_display - end - - # "Paid in QBO" is the source of truth. Defer to the QboBill mirror's - # status field, which the daily sync updates from QBO. - def paid? - qbo_bill&.paid? || false - end -end diff --git a/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb b/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb deleted file mode 100644 index cca04142..00000000 --- a/app/services/ledger_withdrawal_requests/enumerate_candidate_bills.rb +++ /dev/null @@ -1,106 +0,0 @@ -module LedgerWithdrawalRequests - # Walks every SyncsAsQboBill host attached to a ledger and returns one row - # per linked Bill (with its candidacy state) — the selection screen renders - # each, with grayed-out reasons for non-selectable rows. - class EnumerateCandidateBills - # Host classes to walk. Each row has #qbo_bill_id and a #payable? per - # SyncsAsQboBill, and #amount. - HOST_CLASSES = [ - ContributorPayout, - ContributorAdjustment, - ProfitShare, - Trueup, - PayStub, - ].freeze - - Row = Struct.new(:host, :qbo_bill, :qbo_bill_id, :qbo_account_id, :amount, :selectable, :reason, :description, :effective_on, keyword_init: true) - - def self.call(ledger) - new(ledger).call - end - - def initialize(ledger) - @ledger = ledger - end - - def call - claimed_bill_keys = open_request_bill_keys - rows = collect_rows - rows.map { |row| annotate_candidacy(row, claimed_bill_keys) } - end - - private - - attr_reader :ledger - - def collect_rows - HOST_CLASSES.flat_map do |klass| - klass.where(ledger_id: ledger.id).map do |host| - qbo_bill_id = host.qbo_bill_id - qbo_account_id = host.qbo_account_for_bill&.id - qbo_bill = (qbo_bill_id.present? && qbo_account_id.present?) ? host.qbo_bill : nil - Row.new( - host: host, - qbo_bill: qbo_bill, - qbo_bill_id: qbo_bill_id, - qbo_account_id: qbo_account_id, - amount: host.respond_to?(:amount) ? host.amount.to_f : 0, - description: row_description(host), - effective_on: host.respond_to?(:effective_on_for_display) ? host.effective_on_for_display : nil, - selectable: false, - reason: nil, - ) - end - end - end - - def annotate_candidacy(row, claimed_bill_keys) - host = row.host - - if !host.payable? - row.reason = "Not yet payable" - return row - end - - if row.qbo_bill_id.blank? || row.qbo_account_id.blank? - row.reason = "Bill not yet pushed to QBO" - return row - end - - if row.qbo_bill.nil? - row.reason = "QBO Bill mirror missing — wait for next sync" - return row - end - - if row.qbo_bill.paid? - row.reason = "Already paid in QBO" - return row - end - - if claimed_bill_keys.include?([row.qbo_account_id, row.qbo_bill_id]) - row.reason = "Already in an open withdrawal request" - return row - end - - row.selectable = true - row - end - - # Bills already locked into a pending request on this ledger. Used to - # gray those out on the selection screen so two requests can't claim - # the same Bill. - def open_request_bill_keys - LedgerWithdrawalRequestBill - .joins(:ledger_withdrawal_request) - .where(ledger_withdrawal_requests: { ledger_id: ledger.id, processed_at: nil, cancelled_at: nil }) - .pluck(:qbo_account_id, :qbo_bill_id) - .to_set - end - - # Plain "type" label — date and description are rendered separately so - # we don't pile everything into one column. - def row_description(host) - host.class.name.titleize - end - end -end diff --git a/app/services/ledger_withdrawal_requests/process_via_deel.rb b/app/services/ledger_withdrawal_requests/process_via_deel.rb deleted file mode 100644 index e9c0223e..00000000 --- a/app/services/ledger_withdrawal_requests/process_via_deel.rb +++ /dev/null @@ -1,54 +0,0 @@ -module LedgerWithdrawalRequests - # Resolves a pending request by creating one Deel invoice adjustment for - # the request's total amount. Mirror of the manual Deel withdrawal flow, - # but driven from the controller's "Process via Deel" button on a - # request show page rather than a per-row form. - # - # Doesn't touch the underlying QBO Bills' Paid state — that's a - # follow-up. Once shipped, this service should additionally POST a - # BillPayment per Bill to keep QBO accounting in sync. - class ProcessViaDeel - class Error < StandardError; end - - def self.call(**kwargs) - new(**kwargs).call - end - - def initialize(request:, processed_by:, contract_id:, description:, amount: nil, date_submitted: Date.current, allow_overpayment: false) - @request = request - @processed_by = processed_by - @contract_id = contract_id.to_s - @description = description.to_s - @amount = amount.presence # nil → fall back to request.total_amount - @date_submitted = date_submitted - # Skipping the settled-balance cap is an admin-only override. Default - # false so a typo doesn't silently submit a withdrawal that exceeds - # the contributor's actual balance. - @allow_overpayment = allow_overpayment && @processed_by&.is_admin? - end - - def call - raise Error, "Request is not pending" unless @request.pending? - - adjustment = Contributors::SubmitDeelInvoiceAdjustment.call( - contributor: @request.contributor, - ledger: @request.ledger, - contract_id: @contract_id, - amount: @amount || @request.total_amount, - description: @description.presence || "Stacks withdrawal request ##{@request.id}", - date_submitted: @date_submitted, - skip_balance_validation: @allow_overpayment, - ) - - @request.update!( - processed_at: Time.current, - paid_via: LedgerWithdrawalRequest::PAID_VIA_DEEL, - deel_invoice_adjustment_id: adjustment.id, - ) - - adjustment - rescue Contributors::SubmitDeelInvoiceAdjustment::Error => e - raise Error, e.message - end - end -end diff --git a/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb deleted file mode 100644 index d4255fba..00000000 --- a/app/views/admin/ledger_withdrawal_requests/_bills_panel.html.erb +++ /dev/null @@ -1,114 +0,0 @@ -<%# Bills selection table. Mirrors the structure used by - contributors/_show.html.erb so it picks up the existing index_table / - .even / .odd styling for free. - - Rows are pre-checked by default — most contributors want to request - everything they have available — and a header checkbox toggles them in - bulk. JS below recomputes the running total whenever a row toggles and - rewrites the submit button label so the contributor knows what they're - about to request before they click. %> -
-
-
-

Bills ready to request

-
-
- - - - - - - - - - - - <% candidates.each_with_index do |row, idx| %> - <% key = "#{row.qbo_account_id}:#{row.qbo_bill_id}" %> - "> - - - - - - - <% end %> - -
<%= check_box_tag "lwr_select_all", "1", true, - class: "lwr-select-all", - title: "Toggle all" %>DateTypeAmountQBO
<%= check_box_tag "ledger_withdrawal_request[selected_bill_keys][]", key, true, - class: "lwr-bill-checkbox", - data: { amount: row.amount } %><%= row.effective_on %><%= row.description %><%= number_to_currency(row.amount) %> - <% if row.qbo_bill.present? %> - <%= link_to "View ↗", row.qbo_bill.qbo_url, target: "_blank", rel: "noopener" %> - <% end %> -
-
-
-
- - diff --git a/app/views/admin/ledger_withdrawal_requests/_intro_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_intro_panel.html.erb deleted file mode 100644 index 907ae599..00000000 --- a/app/views/admin/ledger_withdrawal_requests/_intro_panel.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -<%# Header explanation card. Uses the same dashboard-modules / module-header - / module-body markup the Contributor ledger view uses, so the chrome - matches automatically without restyling AA's .panel. %> -
-
-
-

Request payment — <%= ledger.enterprise.name %>

-
-
-

Pick the bills you want to be paid for. Once submitted, the request lands on the financial controller's desk to process via Deel or QBO Bill Pay.

-
-
-
diff --git a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb deleted file mode 100644 index 59484c20..00000000 --- a/app/views/admin/ledger_withdrawal_requests/_message_panel.html.erb +++ /dev/null @@ -1,24 +0,0 @@ -<%# Shared panel for empty/error states on the withdrawal request flow. - Locals: - title — string shown in the module header - paragraphs — Array of strings, each rendered as its own

- back_path — URL the "Back to ledger ↗" button points at -%> -

-
-
-

<%= title %>

-
-
- <% paragraphs.each do |p| %> -

<%= p %>

- <% end %> - <%# `a.button` is styled by AA's `form a.button` rule, so wrap the - link in a bare form so that scope applies without re-implementing - the pill style. %> -
- <%= link_to "Back to ledger ↗", back_path, class: "button" %> -
-
-
-
diff --git a/app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb b/app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb deleted file mode 100644 index c70f3b32..00000000 --- a/app/views/admin/ledger_withdrawal_requests/_notes_panel.html.erb +++ /dev/null @@ -1,20 +0,0 @@ -<%# Notes block. Doesn't use formtastic's fieldset.inputs (which carries - its own chrome we'd have to fight) — just the same dashboard-module - shell with a raw textarea inside. The textarea's name maps onto the - standard ledger_withdrawal_request[notes] param because we're inside - the AA form_for block. %> -
-
-
-

Notes

-
-
-

- Optional. Anything the financial controller should know. -

- <%= text_area_tag "ledger_withdrawal_request[notes]", nil, - rows: 4, - style: "width: 100%; box-sizing: border-box;" %> -
-
-
diff --git a/app/views/admin/ledger_withdrawal_requests/_show.html.erb b/app/views/admin/ledger_withdrawal_requests/_show.html.erb deleted file mode 100644 index a1717504..00000000 --- a/app/views/admin/ledger_withdrawal_requests/_show.html.erb +++ /dev/null @@ -1,136 +0,0 @@ -

- Withdrawal Request #<%= resource.id %> - - <%= resource.contributor.forecast_person&.email %> · <%= resource.enterprise.name %> - -

- -
-
-
-

- Status: - <% if resource.cancelled? %> - Cancelled <%= resource.cancelled_at.to_date %> - by <%= resource.cancelled_by&.email || "(unknown)" %> - <% if resource.cancelled_reason.present? %> - — <%= resource.cancelled_reason %> - <% else %> - (no reason given) - <% end %> - <% elsif resource.processed? %> - Processed via <%= resource.paid_via %> on <%= resource.processed_at.to_date %> - <% else %> - Pending - <% end %> -  ·  - Requested: <%= resource.requested_at.to_date %> -  ·  - Total: <%= number_to_currency(resource.total_amount) %> -  ·  - Bills: <%= resource.paid_bills.size %> / <%= resource.bills.size %> paid in QBO -

- <% if resource.notes.present? %> -

Notes: <%= resource.notes %>

- <% end %> -
-
-
- -<% if resource.pending? && current_admin_user.is_admin? %> - <% - deel_contracts = DeelContract.sorted_for_balance_withdrawal_select( - resource.contributor.deel_person_id, - deel_legal_entity_id: resource.enterprise.deel_legal_entity_id, - ) - - # Default description is an itemized statement of every Bill this - # request is settling. One line per bill; each line names the source - # ledger item type (Contributor Payout / Pay Stub / etc.), the - # effective date when we can resolve it, the snapshot amount, and a - # deep link into QBO so the Deel recipient can verify. Admin can - # rewrite the whole thing inline before submit. - bill_lines = resource.bills.includes(:qbo_account).map do |b| - parts = [b.host_label] - parts << b.host_effective_on.to_s if b.host_effective_on.present? - parts << number_to_currency(b.amount_snapshot) - qbo_url = b.qbo_bill&.qbo_url || "https://qbo.intuit.com/app/bill?&txnId=#{b.qbo_bill_id}" - "- #{parts.join(" — ")} (QBO Bill ##{b.qbo_bill_id}: #{qbo_url})" - end - - default_description = +"Stacks withdrawal request ##{resource.id} — settling the following bills:\n\n" - default_description << bill_lines.join("\n") - %> -
-
-

Process via Deel

-
- <% if resource.contributor.deel_person_id.blank? || deel_contracts.empty? %> -

No Deel contracts available for this contributor on <%= resource.enterprise.name %>. Use the QBO path instead.

- <% else %> - <%= form_with url: process_via_deel_admin_ledger_withdrawal_request_path(resource), method: :post, local: true do |f| %> -

-
- <%= select_tag :contract_id, options_for_select(deel_contracts.map { |dc| [dc.display_name_for_deel_invoice_select, dc.deel_id] }) %> -

-

-
- <%= number_field_tag :amount, resource.total_amount, step: 0.01, min: 0.01, - style: "width: 200px;" %> - Defaults to the request total. Override if Deel needs a different number. -

-

-
- <%= text_area_tag :description, default_description, rows: 3, - style: "width: 100%; box-sizing: border-box;" %> -

-

-
- <%= date_field_tag :date_submitted, Date.current %> -

-

- -
- By default the Deel API call will fail if the amount exceeds the contributor's Stacks balance. Tick this only when you mean to advance a balance. -

-

<%= submit_tag "Process via Deel", class: "button", data: { confirm: "Submit a Deel invoice adjustment and mark this request processed?" } %>

- <% end %> - <% end %> -
-
-
-<% end %> - - - - - - - - - - - - <% resource.bills.each_with_index do |bill, idx| %> - "> - - - - - - <% end %> - -
BillAmount (at request time)Paid in QBO?QBO
#<%= bill.qbo_bill_id %><%= number_to_currency(bill.amount_snapshot) %> - <% if bill.paid? %> - Paid - <% else %> - Open - <% end %> - - <% if bill.qbo_bill.present? %> - <%= link_to "Open in QBO ↗", bill.qbo_bill.qbo_url, target: "_blank", rel: "noopener" %> - <% end %> -
diff --git a/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb b/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb deleted file mode 100644 index a0305b4c..00000000 --- a/app/views/admin/ledger_withdrawal_requests/missing_qbo_vendor.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -<% - enterprise_name = @missing_vendor_ledger.enterprise.name - paragraphs = [ - "Your #{ERB::Util.html_escape(enterprise_name)} ledger doesn't have a QBO vendor record attached to your contributor account, so we can't send a withdrawal request to QuickBooks.".html_safe, - "Please ask the Stacks admin team to associate a QBO vendor record to your Stacks contributor account on #{ERB::Util.html_escape(enterprise_name)}. Once that's set up, this page will let you submit a request.".html_safe, - ] - if @missing_vendor_qbo_account.nil? - paragraphs << "Heads up: this enterprise isn't connected to a QuickBooks account at all yet. The admin team will need to set that up first.".html_safe - end -%> -<%= render partial: "message_panel", locals: { - title: "🔌 This ledger isn't connected to QuickBooks yet", - paragraphs: paragraphs, - back_path: admin_contributor_path(@missing_vendor_ledger.contributor, ledger: @missing_vendor_ledger.id), -} %> diff --git a/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb b/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb deleted file mode 100644 index 467ef4a5..00000000 --- a/lib/stacks/task_builder/discoveries/ledger_withdrawal_requests.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Stacks - class TaskBuilder - module Discoveries - # Surfaces ONE aggregate task for global Stacks admins whenever - # there's at least one pending LedgerWithdrawalRequest in the - # queue — clicking it lands them on the pending scope of the - # index, not on a specific request. The subject is the oldest - # pending row so the cache descriptor is stable as long as any - # row remains pending; the display name + URL on StacksTask - # treat the request as an indicator for the whole queue. - class LedgerWithdrawalRequests < Base - def tasks - oldest_pending = LedgerWithdrawalRequest - .pending - .order(:requested_at) - .first - return [] if oldest_pending.nil? - - [ - task( - subject: oldest_pending, - type: :ledger_withdrawal_request_needs_processing, - owners: @admin_fallback, - ), - ] - end - end - end - end -end diff --git a/script/why_balance_goes_up.rb b/script/why_balance_goes_up.rb new file mode 100644 index 00000000..73a0c347 --- /dev/null +++ b/script/why_balance_goes_up.rb @@ -0,0 +1,221 @@ +# For each contributor whose post-cutover balance goes UP, decompose the +# delta into its three drivers and print row-level detail. +# +# Math: +# Δ = balance_new − balance_current +# = (positive hosts that stay in balance) +# − (positive hosts in balance now − DIA total − |neg CA total|) +# = DIA_total + |neg_CA_total| − paid_host_drops +# +# So a contributor goes UP exactly when the deductions we remove +# (DIAs + negative CAs) exceed the positive hosts that drop out via +# Paid QBO bills. The per-row detail tells us WHICH deductions weren't +# matched by a Paid bill. + +QBO_HOST_KLASSES = [ContributorPayout, ContributorAdjustment, ProfitShare, Trueup, PayStub].freeze + +def eligible_ledger_ids_for(contributor) + contributor.ledgers.includes(:enterprise).filter_map do |l| + qa = l.enterprise.qbo_account + next nil if qa.nil? + vendor = contributor.qbo_vendor_for(qa) + next nil if vendor.nil? + l.id + end.to_set +end + +def safe_qbo_bill(li) + li.qbo_bill +rescue StandardError + nil +end + +def breakdown(items, eligible_ids) + out = { + dias: [], # [li] + neg_cas: [], # [li] + pos_hosts_paid: [], # [li] — drop out under cutover + pos_hosts_open: [], # [li] — have a QBO bill but not paid (stay) + pos_hosts_nobill: [],# [li] — no QBO bill at all (stay) + } + + items[:all].each do |li| + next unless li.respond_to?(:ledger_id) && eligible_ids.include?(li.ledger_id) + next if li.respond_to?(:deleted_at) && li.deleted_at.present? + + case li + when DeelInvoiceAdjustment + out[:dias] << li if li.deducts_balance? + when ContributorAdjustment + if li.amount.to_f < 0 + out[:neg_cas] << li + elsif li.payable? + qb = safe_qbo_bill(li) + if qb&.paid? + out[:pos_hosts_paid] << [li, qb] + elsif qb + out[:pos_hosts_open] << [li, qb] + else + out[:pos_hosts_nobill] << li + end + end + when ContributorPayout, ProfitShare, PayStub + next unless li.payable? + qb = safe_qbo_bill(li) + if qb&.paid? + out[:pos_hosts_paid] << [li, qb] + elsif qb + out[:pos_hosts_open] << [li, qb] + else + out[:pos_hosts_nobill] << li + end + when Trueup + qb = safe_qbo_bill(li) + if qb&.paid? + out[:pos_hosts_paid] << [li, qb] + elsif qb + out[:pos_hosts_open] << [li, qb] + else + out[:pos_hosts_nobill] << li + end + end + end + + out +end + +def sum_amount(rows) + rows.sum { |r| (r.is_a?(Array) ? r[0] : r).amount.to_f }.round(2) +end + +def classify(dia_total, neg_ca_total, paid_total) + removed = dia_total + neg_ca_total + if neg_ca_total > 0 && dia_total < 0.01 + "NEG-CA-DRIVEN" + elsif dia_total > 0 && neg_ca_total < 0.01 + "DIA-DRIVEN" + elsif neg_ca_total > 0 && dia_total > 0 + "BOTH" + else + "?" + end +end + +# Candidate set — same as the worklist +dia_contrib_ids = + DeelInvoiceAdjustment.joins(:ledger).where(deleted_at: nil).pluck("ledgers.contributor_id").uniq +neg_ca_contrib_ids = + ContributorAdjustment.where("amount < 0").joins(:ledger).pluck("ledgers.contributor_id").uniq + +# We need open-bills too so we can match the worklist's "UP" set definitionally, +# but the candidate set itself only matters for selecting whom to scan. +qbo_host_contrib_ids = QBO_HOST_KLASSES.flat_map do |klass| + klass.where.not(qbo_bill_id: nil).joins(:ledger).pluck("ledgers.contributor_id") +end.uniq + +candidate_ids = (dia_contrib_ids + neg_ca_contrib_ids + qbo_host_contrib_ids).uniq + +results = [] + +Contributor.unscoped.where(id: candidate_ids).find_each do |c| + next if c.forecast_person.nil? + eligible_ids = eligible_ledger_ids_for(c) + next if eligible_ids.empty? + + c.preload_for_ledger_view! + items = c.all_items_grouped_by_month(false) + + bd = breakdown(items, eligible_ids) + + dia_total = sum_amount(bd[:dias]) + neg_ca_total = sum_amount(bd[:neg_cas]).abs + paid_total = sum_amount(bd[:pos_hosts_paid]) + open_total = sum_amount(bd[:pos_hosts_open]) + nobill_total = sum_amount(bd[:pos_hosts_nobill]) + + # Δ = dia + neg_ca − paid + d_bal = (dia_total + neg_ca_total - paid_total).round(2) + next if d_bal < 0.01 + + results << { + c: c, + bd: bd, + dia_total: dia_total, + neg_ca_total: neg_ca_total, + paid_total: paid_total, + open_total: open_total, + nobill_total: nobill_total, + d_bal: d_bal, + klass: classify(dia_total, neg_ca_total, paid_total), + } +end + +results.sort_by! { |r| -r[:d_bal] } + +puts "#{results.size} contributors with Δbalance > 0" +puts +puts "Pattern distribution:" +results.group_by { |r| r[:klass] }.sort_by { |_, rs| -rs.size }.each do |k, rs| + total = rs.sum { |r| r[:d_bal] }.round(2) + puts " #{k.ljust(20)} #{rs.size} contributors Σ Δ +$#{total}" +end +puts + +results.each_with_index do |r, idx| + c = r[:c] + bd = r[:bd] + puts "=" * 78 + puts "#{idx + 1}. ##{c.id} #{c.forecast_person.email} [#{r[:klass]}]" + puts " Δ = +$#{r[:d_bal]} (DIA $#{r[:dia_total]} + |negCA| $#{r[:neg_ca_total]} − paidQBO $#{r[:paid_total]})" + puts " Positive hosts still on the books after cutover:" + puts " - open QBO bills (will drop when accountant marks Paid): $#{r[:open_total]} (#{bd[:pos_hosts_open].size} rows)" + puts " - no QBO bill at all (will NEVER drop, but never deducted via QBO either): $#{r[:nobill_total]} (#{bd[:pos_hosts_nobill].size} rows)" + puts + + if bd[:neg_cas].any? + puts " Negative CAs being deleted (#{bd[:neg_cas].size}, $#{r[:neg_ca_total]}):" + bd[:neg_cas].first(8).each do |ca| + desc = ca.description.to_s[0, 70] + puts " - CA ##{ca.id} $#{ca.amount.to_f.round(2)} #{ca.created_at.to_date} #{desc}" + end + puts " ... (#{bd[:neg_cas].size - 8} more)" if bd[:neg_cas].size > 8 + puts + end + + if bd[:dias].any? + puts " DIAs being ignored (#{bd[:dias].size}, $#{r[:dia_total]}):" + bd[:dias].first(8).each do |dia| + desc = dia.description.to_s[0, 70] + puts " - DIA ##{dia.id} $#{dia.amount.to_f.round(2)} #{dia.date_submitted} #{dia.deel_status} #{desc}" + end + puts " ... (#{bd[:dias].size - 8} more)" if bd[:dias].size > 8 + puts + end + + if bd[:pos_hosts_paid].any? + puts " Positive hosts dropping out via Paid QBO bills (#{bd[:pos_hosts_paid].size}, $#{r[:paid_total]}):" + bd[:pos_hosts_paid].first(5).each do |li, qb| + puts " - #{li.class.name} ##{li.id} $#{li.amount.to_f.round(2)} #{qb.qbo_url}" + end + puts " ... (#{bd[:pos_hosts_paid].size - 5} more)" if bd[:pos_hosts_paid].size > 5 + puts + end + + if bd[:pos_hosts_open].any? + puts " Positive hosts with OPEN QBO bills (will drop when accountant marks Paid):" + bd[:pos_hosts_open].first(5).each do |li, qb| + puts " - #{li.class.name} ##{li.id} $#{li.amount.to_f.round(2)} #{qb.qbo_url}" + end + puts " ... (#{bd[:pos_hosts_open].size - 5} more)" if bd[:pos_hosts_open].size > 5 + puts + end + + if bd[:pos_hosts_nobill].any? + puts " Positive hosts with NO QBO bill at all (these never synced):" + bd[:pos_hosts_nobill].first(5).each do |li| + puts " - #{li.class.name} ##{li.id} $#{li.amount.to_f.round(2)} ledger=#{li.ledger_id}" + end + puts " ... (#{bd[:pos_hosts_nobill].size - 5} more)" if bd[:pos_hosts_nobill].size > 5 + puts + end +end From e470fc2a0d0c8da0720413affd6bf4f2c405dde5 Mon Sep 17 00:00:00 2001 From: Hugh Francis Date: Fri, 12 Jun 2026 18:50:17 -0400 Subject: [PATCH 51/67] Remove all LedgerWithdrawalRequest cross-references from runtime --- app/admin/contributors.rb | 11 ------- app/admin/deel_invoice_adjustments.rb | 9 +++--- app/models/admin_authorization.rb | 12 ------- app/models/contributor.rb | 31 ------------------- app/models/stacks_task.rb | 19 ------------ .../create_for_ledger.rb | 3 +- app/views/admin/contributors/_show.html.erb | 22 ------------- lib/stacks/task_builder.rb | 1 - 8 files changed, 5 insertions(+), 103 deletions(-) diff --git a/app/admin/contributors.rb b/app/admin/contributors.rb index 6479f298..27e4cb8f 100644 --- a/app/admin/contributors.rb +++ b/app/admin/contributors.rb @@ -64,17 +64,6 @@ def manual_deel_invoice_visible?(contributor) end end - action_item :request_payment, only: :show do - selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger]) - if selected_ledger - link_to "Request Payment", - new_admin_ledger_withdrawal_request_path(ledger_id: selected_ledger.id) - else - link_to "Request Payment", "#", - onclick: "alert(#{LEDGER_REQUIRED_ALERT.to_json}); return false;" - end - end - action_item :submit_reimbursement, only: :show do selected_ledger = params[:ledger].present? && resource.ledgers.find_by(id: params[:ledger]) if selected_ledger diff --git a/app/admin/deel_invoice_adjustments.rb b/app/admin/deel_invoice_adjustments.rb index 8c5bbb52..8e813bcf 100644 --- a/app/admin/deel_invoice_adjustments.rb +++ b/app/admin/deel_invoice_adjustments.rb @@ -27,14 +27,13 @@ def manual_deel_invoice_submission_allowed?(contributor) end def verify_deel_invoice_access! - # :new / :create were retired in favor of the LedgerWithdrawalRequest - # flow — only admins can still reach them (for legacy backfills / - # corrections). :index and :show stay available to the owning - # contributor so they can audit their existing Deel withdrawals. + # :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 now. Submit a Ledger Withdrawal Request instead." + alert: "Direct Deel withdrawals are admin-only." return end diff --git a/app/models/admin_authorization.rb b/app/models/admin_authorization.rb index bd66216d..7c76daaf 100644 --- a/app/models/admin_authorization.rb +++ b/app/models/admin_authorization.rb @@ -73,18 +73,6 @@ def authorized?(action, subject = nil) end end - # Ledger withdrawal requests: any contributor can browse / submit / view - # them at the adapter level; the controller's verify_ledger_access! - # filters whose ledger they can request against. - if subject.is_a?(LedgerWithdrawalRequest) || subject == LedgerWithdrawalRequest - if user.forecast_person&.contributor.present? - return true if [:index, :new, :create, :read].include?(action) - if subject.is_a?(LedgerWithdrawalRequest) && subject.ledger.contributor_id == user.forecast_person.contributor.id - return true unless OWN_LEDGER_ITEM_DENY.include?(action) - end - end - end - if subject.is_a?(Reimbursement) || subject == Reimbursement if user.forecast_person&.contributor.present? return true if action == :create diff --git a/app/models/contributor.rb b/app/models/contributor.rb index ff1537eb..ac13a9c0 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -64,7 +64,6 @@ def qbo_vendor_for(qbo_account) has_many :deel_invoice_adjustments, through: :ledgers has_many :pay_stubs, through: :ledgers has_many :recurring_ledger_adjustments, through: :ledgers - has_many :ledger_withdrawal_requests, through: :ledgers # Each *_with_deleted method below is memoized per-instance. The first call # fires a query; subsequent calls return the cached array. @@ -123,17 +122,6 @@ def pay_stubs_with_deleted .where(ledgers: { contributor_id: id }).to_a end - # Withdrawal requests are not soft-deletable — `_with_deleted` is a naming - # convention here so the splice into all_items_grouped_by_month reads - # uniformly with the other ledger-item collections. - def ledger_withdrawal_requests_with_deleted - @_ledger_withdrawal_requests_with_deleted ||= - LedgerWithdrawalRequest - .joins(:ledger) - .includes(:bills, :cancelled_by) - .where(ledgers: { contributor_id: id }).to_a - end - private # Resolves the composite-key QboInvoice lookup that `payable?` does on @@ -198,9 +186,6 @@ def preload_for_ledger_view! @_pay_stubs_with_deleted = PayStub.with_deleted.joins(:ledger).where(ledgers: { contributor_id: id }) .includes(:ledger, :pay_cycle).to_a - @_ledger_withdrawal_requests_with_deleted = - LedgerWithdrawalRequest.joins(:ledger).where(ledgers: { contributor_id: id }) - .includes(:ledger, :bills, :cancelled_by).to_a # Short-circuit `item.contributor` (and any downstream delegate hop) to # this Contributor. All preloaded items belong to ledgers whose contributor @@ -213,7 +198,6 @@ def preload_for_ledger_view! @_profit_shares_with_deleted, @_deel_invoice_adjustments_with_deleted, @_pay_stubs_with_deleted, - @_ledger_withdrawal_requests_with_deleted, ].each do |items| items.each do |item| item.ledger.association(:contributor).target = self @@ -414,7 +398,6 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at preloaded_adjustments = contributor_adjustments_with_deleted preloaded_deel_invoice_adjustments = deel_invoice_adjustments_with_deleted preloaded_pay_stubs = pay_stubs_with_deleted - preloaded_withdrawal_requests = ledger_withdrawal_requests_with_deleted if override_ledger_ends_at.present? ledger_ends_at = override_ledger_ends_at @@ -425,7 +408,6 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at *preloaded_adjustments, *preloaded_deel_invoice_adjustments, *preloaded_pay_stubs, - *preloaded_withdrawal_requests, ].reduce(Date.today) do |acc, li| if li.is_a?(ContributorPayout) acc = li.invoice_tracker.invoice_pass.start_of_month if li.invoice_tracker.invoice_pass.start_of_month > acc @@ -443,9 +425,6 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at elsif li.is_a?(PayStub) d = li.effective_on_for_display acc = d if d > acc - elsif li.is_a?(LedgerWithdrawalRequest) - d = li.effective_on_for_display - acc = d if d && d > acc end acc end + 2.months @@ -509,11 +488,6 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at ps.effective_on_for_display <= period.ends_at end - withdrawal_requests_in_period = preloaded_withdrawal_requests.select do |wr| - d = wr.effective_on_for_display - d.present? && d >= period.starts_at && d <= period.ends_at - end - sorted = [ *contributor_payouts_in_period, @@ -523,7 +497,6 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at *adjustments_in_period, *deel_invoice_in_period, *pay_stubs_in_period, - *withdrawal_requests_in_period, ].sort do |a, b| date_a = nil if a.is_a?(Trueup) @@ -540,8 +513,6 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at date_a = a.date_submitted elsif a.is_a?(PayStub) date_a = a.effective_on_for_display - elsif a.is_a?(LedgerWithdrawalRequest) - date_a = a.effective_on_for_display end date_b = nil @@ -559,8 +530,6 @@ def all_items_grouped_by_month(include_salary = true, override_ledger_starts_at date_b = b.date_submitted elsif b.is_a?(PayStub) date_b = b.effective_on_for_display - elsif b.is_a?(LedgerWithdrawalRequest) - date_b = b.effective_on_for_display end date_b <=> date_a diff --git a/app/models/stacks_task.rb b/app/models/stacks_task.rb index e12c01ff..892b6a37 100644 --- a/app/models/stacks_task.rb +++ b/app/models/stacks_task.rb @@ -47,8 +47,6 @@ class StacksTask # Ledger issues missing_qbo_vendor_for_contributor: "Contributor needs a QBO vendor for this enterprise's ledger", - # Withdrawal request issues - ledger_withdrawal_request_needs_processing: "You have pending ledger withdrawal requests", }.freeze # type — Symbol classifying the task (:project_capsule_incomplete, :survey, …) @@ -102,17 +100,6 @@ def subject_display_name when Stacks::Notion::Lead then subject.try(:page_title).presence || "Notion Lead" when PayCycle then "#{subject.enterprise.name} — #{subject.starts_at.to_s(:long)} to #{subject.ends_at.to_s(:long)}" when Ledger then "#{subject.contributor.forecast_person&.email || "Contributor ##{subject.contributor_id}"} on #{subject.enterprise.name}" - when LedgerWithdrawalRequest - # The discovery surfaces ONE aggregate task that stands in for the - # whole pending queue, so prefer a count-based label over the - # specific subject row's details. Falls back to per-row info if - # this is ever wired as a per-row task. - if type == :ledger_withdrawal_request_needs_processing - count = LedgerWithdrawalRequest.pending.count - "#{count} pending ledger withdrawal request#{"s" if count != 1}" - else - "#{subject.contributor.forecast_person&.email} on #{subject.enterprise.name} — #{ActionController::Base.helpers.number_to_currency(subject.total_amount)} (#{subject.bills.size} bills)" - end else subject.try(:display_name).presence || subject.try(:name).presence || subject.to_s end @@ -140,12 +127,6 @@ def subject_url else helpers.edit_admin_contributor_path(subject.contributor) end - when LedgerWithdrawalRequest - if type == :ledger_withdrawal_request_needs_processing - helpers.admin_ledger_withdrawal_requests_path(scope: "pending") - else - helpers.admin_ledger_withdrawal_request_path(subject) - end else subject.try(:external_link) end end diff --git a/app/services/deel_invoice_adjustments/create_for_ledger.rb b/app/services/deel_invoice_adjustments/create_for_ledger.rb index a6196d09..1733a4b1 100644 --- a/app/services/deel_invoice_adjustments/create_for_ledger.rb +++ b/app/services/deel_invoice_adjustments/create_for_ledger.rb @@ -1,7 +1,6 @@ module DeelInvoiceAdjustments # Creates a DeelInvoiceAdjustment in Deel for a given ledger + contract, - # then persists the response as a Stacks-side DIA. Replaces the - # withdrawal-request-mediated path from LedgerWithdrawalRequests::ProcessViaDeel. + # then persists the response as a Stacks-side DIA. class CreateForLedger class Error < StandardError; end diff --git a/app/views/admin/contributors/_show.html.erb b/app/views/admin/contributors/_show.html.erb index 0042cb44..658ed1e8 100644 --- a/app/views/admin/contributors/_show.html.erb +++ b/app/views/admin/contributors/_show.html.erb @@ -94,15 +94,11 @@ <%= li.date_submitted.strftime("%B %d, %Y") %> <% elsif li.is_a?(PayStub) %> <%= li.pay_cycle.ends_at.strftime("%B %d, %Y") %> - <% elsif li.is_a?(LedgerWithdrawalRequest) %> - <%= li.requested_at.to_date.strftime("%B %d, %Y") %> <% end %>
<% if li.is_a?(PayStub) %> Pay Stub - <% elsif li.is_a?(LedgerWithdrawalRequest) %> - Withdrawal Request <% else %> <%= li.model_name.human %> @@ -185,20 +181,6 @@ <% end %> - <% elsif li.is_a?(LedgerWithdrawalRequest) %> - <% if li.cancelled? %> - Cancelled - <% elsif li.processed? %> - - <% else %> - - Pending - <%= li.paid_bills.size %> / <%= li.bills.size %> bills paid - - <% end %> <% end %> @@ -286,8 +268,6 @@ <% end %> - <% elsif li.is_a?(LedgerWithdrawalRequest) %> - - <%= number_to_currency(li.total_amount) %> <% end %> @@ -306,8 +286,6 @@ <%= link_to "Info ↗", admin_contributor_deel_invoice_adjustment_path(contributor, li) %> <% elsif li.is_a?(PayStub) %> <%= link_to "Info ↗", admin_pay_cycle_pay_stub_path(li.pay_cycle, li) %> - <% elsif li.is_a?(LedgerWithdrawalRequest) %> - <%= link_to "Request ##{li.id} ↗", admin_ledger_withdrawal_request_path(li) %> <% end %> <% end %>