You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[HG-3372] Extract DueDatePolicy and PayoutDatePolicy from Cycle
Description
Move the rules that compute a cycle's due_date and payout_date into two stand-alone policy objects (HG::Billing::DueDatePolicy, HG::Billing::PayoutDatePolicy), constructed with the client and called at cycle creation time. The Cycle model keeps its due_date / payout_date columns and read API unchanged — only the computation moves out. This is step 1 of the iteration-1 refactor: variation that currently lives as private helpers on the model gets pulled into named classes so future date schemes (Net X, end-of-month, custom days) become new policy classes rather than new branches inside Cycle. Behavior is unchanged.
Acceptance criteria
HG::Billing::DueDatePolicy and HG::Billing::PayoutDatePolicy exist; the listed Cycle#* date methods are removed and the persisted due_date / payout_date columns are populated by the policies at cycle creation.
Extract FrequencyPolicy (Monthly / SemiMonthly) from Cycle and callers
Replace the implicit branching on frequency (monthly?, first_semi_monthly?, second_semi_monthly?, case frequency) with an HG::Billing::FrequencyPolicy class plus two concrete subclasses (Monthly, SemiMonthly) registered in REGISTRY. The policy owns period computation (period, next_period_after, key_for) so callers stop case-switching on a string. Adding Weekly later becomes one new subclass and one registry entry. Behavior is unchanged.
Acceptance criteria
HG::Billing::FrequencyPolicy exists with Monthly and SemiMonthly subclasses
the frequency predicates and case frequency branches on Cycle are removed
AutopayPayoutCalculator, SchedulePreview, PaymentCalendarEvents, and VirtualCycleProjector delegate to the policy
Extract ApprovalPolicy from Cycle / Cycle::Processor
Move the autopay-vs-manual decision out of Cycle and Cycle::Processor into HG::Billing::ApprovalPolicy, with Auto and Manual subclasses resolved from the talent's payment_settings.autopay? flag. Auto retains the "positive value → autopay request, non-positive → manual" rule so the value.positive? ? autopay : manual ternary currently inline in Cycle::Processor:110 lives in one place. Behavior is unchanged.
Acceptance criteria
HG::Billing::ApprovalPolicy::Auto and Manual exist
Cycle#find_or_create_manual_approval_request and Cycle#find_or_create_autopay_approval_request are removed
the value.positive? ? autopay : manual ternary in Cycle::Processor is replaced by a policy call
Replace Cycle::Processor with LegacyBase + LegacyHourly / LegacyFixed strategies
Today HG::Services::Cycle::Processor branches on hourly_talent? at three points (line-item build, reconciliation, stale handling). Move that orchestration into HG::Billing::Strategies::LegacyBase as a template, with LegacyHourly and LegacyFixed subclasses each overriding the three hooks. Cycle::Processor is deleted; its single caller ProcessBillingCycles:26 switches to per-rate-type strategy dispatch. Cycle creation stays in ProcessBillingCycles#find_or_create_cycle for now (the strategy receives cycle: directly). The strategy signature is run(client:, cycle:, cycle_date:, talents:) — matching today's Cycle::Processor args; ticket 05 reshapes it to (worker_group:, talents:, cycle_date:) when WorkerGroup arrives. Behavior is unchanged.
Acceptance criteria
HG::Billing::Strategies::LegacyBase exists; run contains today's Cycle::Processor#process body (cleanup → reconcile → create → remove-empty-approval-requests) with three abstract hooks
LegacyHourly#build_line_item extends the base with create_timesheet_with_records; LegacyFixed#build_line_item is the base only
LegacyHourly#reconcile_line_item! implements today's reconcile_submitted_timesheet_value!; LegacyFixed#reconcile_line_item! is a no-op
LegacyHourly#handle_stale_line_item! updates payment_settings_id in place; LegacyFixed#handle_stale_line_item! destroys the line item
ProcessBillingCycles:26 dispatches by rate_type and calls strategy.run(client:, cycle:, cycle_date:, talents:)
HG::Services::Cycle::Processor is deleted
Note on sequencing
The intermediate dispatch in ProcessBillingCycles and the strategy signature (client:, cycle:, cycle_date:, talents:) are deliberate — ticket 05 (WorkerGroup + StrategyResolver) replaces both. The intermediate keeps 04 shippable in isolation.
Introduce WorkerGroup + StrategyResolver dispatch in ProcessBillingCycles
Replace the per-frequency dispatch in ProcessBillingCycles with a two-level loop: outer by frequency (the unit of cycle uniqueness — one cycle per (client_id, frequency, period)), inner by HG::Billing::WorkerGroup (a Data.define value object over client_id, rate_currency, frequency, autopay, rate_type). For each cycle, PBC owns cycle-level concerns (find_or_create_cycle, orphan cleanup, remove_empty_approval_requests); for each WorkerGroup within the cycle, PBC resolves a strategy via HG::Billing::StrategyResolver and calls strategy.run(worker_group:, talents:, cycle:, cycle_date:) for per-group create + reconcile. After this ticket, all rate-type / frequency / autopay variation is selected at the dispatch point; no downstream branching. Behavior is unchanged.
LegacyBase#run shrinks: cleanup_pending_line_items! and remove_empty_approval_requests! move from LegacyBase to ProcessBillingCycles (cycle scope, not WorkerGroup scope — see note below). The strategy now does only reconcile + create — both already talent-scoped at line-item level, so safe to run per-WorkerGroup. Signature changes from (client:, cycle:, cycle_date:, talents:) to (worker_group:, talents:, cycle:, cycle_date:) — client: drops (it's in worker_group), cycle: stays (shared across WorkerGroups within a frequency).
Acceptance criteria
HG::Billing::WorkerGroup exists as Data.define(:client_id, :rate_currency, :frequency, :autopay, :rate_type); WorkerGroup.from(payment_settings, client_id:) builds it, reading rate_currency via payment_settings.rate.currency (the attribute is private on PaymentSettings)
HG::Billing::StrategyResolver.call(worker_group) returns Strategies::LegacyHourly.new for rate_type == 'hourly', else Strategies::LegacyFixed.new
LegacyBase#run signature is (worker_group:, talents:, cycle:, cycle_date:); body contains only reconcile and create
cleanup_pending_line_items! and remove_empty_approval_requests! are private methods on ProcessBillingCycles (or extracted helpers it calls), not on LegacyBase
Note on the two-level loop
Cycle uniqueness is (client_id, frequency, period); WorkerGroup adds rate_currency, autopay, rate_type — finer-grained. Multiple WorkerGroups share one cycle. If cleanup_pending_line_items! ran inside strategy.run with only a WorkerGroup's talent subset, remove_line_items_for_removed_talents! would destroy line items belonging to other WorkerGroups in the same cycle. Keeping cleanup + finalization at PBC (cycle scope) and create + reconcile in the strategy (WorkerGroup scope) avoids the cross-WorkerGroup destruction without requiring a schema change.
Introduce InvoicingStrategy seam at invoice creation
Today invoice creation lives in two unrelated places: HG::Actions::ApprovalRequest::Approve (cycle approval clears → invoice built from cycle line items) and HG::Actions::OneTimePayments::Create:113 (OTP → separate invoice built inline). The two paths share nothing, so extending either with multi-currency, per-legal-entity, per-cost-center, per-payout-batch, milestone-based, or pre/post-funded variants currently means editing both call sites. Introduce HG::Billing::InvoicingStrategy with two methods (issue_for_approval(approval_request:), issue_for_one_time_payment(one_time_payment:)) and one concrete Separate implementation that wraps today's behavior. Each call site dispatches through the strategy. This is the prep that unlocks the whole Invoice Clients dimension (multi-currency, per-entity, per-currency-invoice, etc.). Behavior is unchanged.
Acceptance criteria
HG::Billing::InvoicingStrategy interface exists with issue_for_approval and issue_for_one_time_payment
HG::Billing::InvoicingStrategy::Separate wraps today's behavior at both call sites
HG::Actions::ApprovalRequest::Approve and HG::Actions::OneTimePayments::Create no longer build invoices inline — they dispatch through the strategy
The iteration-1 WorkerGroup is a five-element Data.define(:client_id, :rate_currency, :frequency, :autopay, :rate_type). Use-case review shows at least four more keys queued for iteration 2 — country (mixed-cadence-by-country payouts), legal_entity (invoice-per-entity), cost_center (invoice-per-cost-center), payout_currency (worker chooses payout currency distinct from rate currency). Adding any of them later should be a one-file change, but only if downstream code (the group_by in ProcessBillingCycles, the eight reactive triggers, StrategyResolver.call, ApprovalPolicy.for, the test helpers) does not hardcode the five-element shape. Update WorkerGroup.from, callers, and tests to treat the tuple as opaque: pattern-match by name (group.frequency, group.rate_type), never by position; use hash conversion for serialization; do not destructure into positional locals. Behavior is unchanged.
Acceptance criteria
WorkerGroup.from(payment_settings) is the only construction site; all other code receives the value object and reads its accessors by name
the group_by { |t| WorkerGroup.from(...) } in ProcessBillingCycles and in each of the eight reactive entry points works unchanged if the tuple gains a sixth element
StrategyResolver.call, ApprovalPolicy.for, and FrequencyPolicy.for accept the WorkerGroup by name access only — no positional destructuring
Name PaymentStrategy as a known iteration-2 seam (doc only)
The iteration-1 architecture doc (back-end/local/claude/billing-first-iteration-interfaces.md) names four long-term axes — Billing × Invoicing × Approval × Payment — and lays seams for the first three. Payment has no seam, no placeholder, no signature sketch. Use-case review surfaces six payment dimensions (rails, funding timing, partial funding, FX lock, failure handling, treasury modes) plus two cross-axis items (invoice currency ≠ payout currency, worker chooses payout currency) that all land on the same missing seam. When iteration 2 picks the first one up, the team should not have to re-derive where the seam slots in. Amend the architecture doc to add a "Payment strategy (deferred)" section: state that HG::Billing::PaymentStrategy is a peer of ClientStrategy and InvoicingStrategy, resolved per WorkerGroup at the same dispatch point in ProcessBillingCycles, and sketch the method shape (charge(invoice:) for the client side, payout(line_item:) for the worker side, both returning arrays for consistency with InvoicingStrategy). No code changes. Acknowledge explicitly that the seam is named to prevent ad-hoc placement, not built.
Acceptance criteria
back-end/local/claude/billing-first-iteration-interfaces.md gains a "Payment strategy (deferred)" subsection under "Out of scope for this iteration"
the section names the resolver dispatch point, sketches the two method signatures, and lists the six payment dimensions from the use-case review that will land on it
Invoice#due_date is set at two unrelated call sites today: HG::Actions::ApprovalRequest::UpdateDocuments:152 hardcodes invoice.due_date = Date.current (Net 0), and HG::Actions::OneTimePayments::Create:154 sets it from one_time_payment.due_date (= payout_date - PaymentMethodOffset.days). The two formulas differ; neither is parameterized by client. Net Terms (Net 15 / 30 / 45) — the next product requirement — needs invoice.due_date = issued_on + N.business_days, which fits neither existing site without editing both. Note that HG::Billing::DueDatePolicy is not the right seam: it computes Cycle#due_date (the approval/cycle closure deadline), a different column with different consumers (approval scheduling, mailers, timesheet overdue queries). Introduce HG::Billing::InvoiceDueDatePolicy, constructed with the client, with a single call(issued_on:) → Date method. Concrete Net0 subclass wraps today's behavior. Both call sites stop hardcoding and dispatch through the policy. Adding NetX(days) later is one new subclass + one new client.net_terms_days column. Behavior is unchanged.
Acceptance criteria
HG::Billing::InvoiceDueDatePolicy exists with call(issued_on:) → Date
HG::Billing::InvoiceDueDatePolicy::Net0 returns issued_on (matching today's Date.current and the OTP formula for current clients)
HG::Actions::ApprovalRequest::UpdateDocuments and HG::Actions::OneTimePayments::Create no longer set invoice.due_date inline — both go through the policy resolved per-client
Cycle#due_date / HG::Billing::DueDatePolicy are unaffected (different column, different concept)
Why now
Net Terms is the next product requirement (Net 15 / 30 / 45 for fully managed clients, per the "Credit and Financing" dimension in the use-case review). With this ticket landed, "Net 30" becomes class NetX < InvoiceDueDatePolicy + a column on Client, with no edits to anything in production. Without this ticket, the first Net Terms PR has to touch both inline-Date.current sites and risk diverging the two formulas further.