QBO-bound ledger cutover#109
Open
hhff wants to merge 68 commits into
Open
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Both screens now render through the same _message_panel partial: - dashboard-modules / module-header / module-body chrome - consistent <p> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <form> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…stones
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hdrawal request) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…st setup - 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 <noreply@anthropic.com>
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.
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.
…en bills
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.
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.
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.
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.
…everywhere
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).
… / migration conflicts)
…ion item 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)
…new ledgers, validation, tests 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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrates Stacks ledgers from the legacy balance model (negative `ContributorAdjustment`s and `DeelInvoiceAdjustment`s deduct from balance) to a qbo_bound model where the QBO Bill paid status is the single source of truth. The migration is per-ledger and gated by a strict invariant: post-migration Stacks total must match the QBO vendor's AP balance one-to-one.
Replaces the LedgerWithdrawalRequest bundling apparatus that was added in earlier work on this branch.
Headline numbers (dev DB after merging in fresh prod state)
What ships
Per-ledger state
qbo_bound balance computation
Migration gate
Bulk + worklist
Reimbursement uniformity
Deel withdrawal flow
Other
Rollout
Test plan
🤖 Generated with Claude Code