Skip to content

QBO-bound ledger cutover#109

Open
hhff wants to merge 68 commits into
mainfrom
worktree-qbo-invoice-withdrawal-pattern
Open

QBO-bound ledger cutover#109
hhff wants to merge 68 commits into
mainfrom
worktree-qbo-invoice-withdrawal-pattern

Conversation

@hhff

@hhff hhff commented Jun 6, 2026

Copy link
Copy Markdown
Member

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)

  • 645 / 672 ledgers auto-migrate via `rake ledgers:migrate_qbo_bound_zero_drift`
  • 27 require operator reconciliation, split between PayStub backfill needs and real Hugh-shape (Expense-to-AP) reconciliation work

What ships

Per-ledger state

  • `Ledger#mode` enum (`legacy` / `qbo_bound`, default `legacy`)
  • `Ledger#payment_methods` text[] (`deel` / `qbo`) with data-driven backfill — non-US Deel → `[deel]`, everyone else → `[qbo]`
  • Same inference reused by `before_validation` and all three `Ledger.ensure_*` paths, so new contributors don't end up with `payment_methods: []`
  • Validation rejecting unknown payment_methods values

qbo_bound balance computation

  • `Ledger#qbo_bound_open_items`: audit-only rows excluded; fully-paid QBO bills excluded
  • `Ledger#qbo_bound_contribution(li)`: uses `QboBill#remaining_balance` for partial payments (a $1,778.40 bill paid down to $0.40 contributes $0.40, not $1,778.40)
  • `Ledger#balance` and `Ledger#unsettled` factor through one shared `sum_for_bucket` helper

Migration gate

  • `Ledgers::QboBoundMigrationCheck`: compares Stacks proposed open total against `QboVendor.data["balance"]`
  • `ready?` is true iff the ledger is trivially empty OR the vendor exists AND the totals match within $0.01
  • Per-ledger Migrate panel on the Ledger admin show page with full driver breakdown, plus a "Refresh QBO vendor data" action for the stale-cache case

Bulk + worklist

  • `rake ledgers:migrate_qbo_bound_zero_drift` flips every ready legacy ledger
  • `Stacks::TaskBuilder::Discoveries::LegacyLedgersPendingQboMigration` surfaces remaining work as tasks
  • New "Payable QBO Bills" page under Money tab — tabbed per QBO account, per-row + bulk Refresh

Reimbursement uniformity

  • `Reimbursement` now includes `SyncsAsQboBill` (followed the model used by the merged PR Reimbursement: sync as QBO Bill #111)
  • Toggle-acceptance pushes/refreshes the QBO bill on both branches; doesn't delete on un-accept
  • Reimbursement included in `Money::PayableQboBills::HOST_KLASSES` and the migration discovery's `PAYABLE_TABLES`

Deel withdrawal flow

  • LedgerWithdrawalRequest apparatus deleted (model, admin, views, services, task discovery, all cross-references)
  • "New Deel Withdrawal" action item restored, routing to the existing `admin/contributors/:id/deel_invoice_adjustments/new` form (no inline UI built — the existing controller already had the create flow)
  • Action item gated on `ledger.deel_enabled?`

Other

  • Negative ContributorAdjustment creation is rejected at validation when the ledger is qbo_bound (forces the QBO mark-paid workflow rather than off-platform offsets)
  • Audit scripts that informed the design are removed
  • 58 tests in the gate suite (mode/payment_methods, partial-payment, refresh action, payment_methods inference, validation)

Rollout

  1. Merge + deploy
  2. Run `bundle exec rake ledgers:migrate_qbo_bound_zero_drift` on production — bulk auto-flips
  3. `bundle exec rake reimbursements:backfill_qbo_bills` (already on main from PR Reimbursement: sync as QBO Bill #111) — sync any remaining accepted reimbursements
  4. Financial controller works through the Task Builder tasks for the remainder, marking blocking bills Paid in QBO + clicking Re-check
  5. Twice-monthly: controller works through Payable QBO Bills page tab-by-tab to pay open bills

Test plan

  • CI green
  • `bundle exec rake ledgers:migrate_qbo_bound_zero_drift` on staging — confirm bulk flip + idempotent re-run
  • Visit a legacy Ledger admin show — Migrate panel renders with QBO match check + driver breakdown
  • Click "Refresh QBO vendor data" on a stale-cache case — confirm vendor balance updates
  • Visit Money → Payable QBO Bills — tab through enterprises, confirm per-tab Refresh works
  • Negative ContributorAdjustment on a qbo_bound ledger — confirm validation rejection
  • "New Deel Withdrawal" action item shows only when `ledger.deel_enabled?`; absent or alerts otherwise

🤖 Generated with Claude Code

hhff and others added 30 commits June 6, 2026 14:01
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>
hhff and others added 14 commits June 12, 2026 18:20
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>
@hhff hhff changed the title QBO Bill withdrawal pattern: contributors queue, controller processes QBO-bound ledger cutover Jun 12, 2026
hhff added 15 commits June 12, 2026 19:32
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).
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant