Skip to content

Instantly share code, notes, and snippets.

@juike
Last active May 27, 2026 09:08
Show Gist options
  • Select an option

  • Save juike/6449aed24617d79f402310748c6e24e0 to your computer and use it in GitHub Desktop.

Select an option

Save juike/6449aed24617d79f402310748c6e24e0 to your computer and use it in GitHub Desktop.
HG-3372 iteration 1 — JIRA tickets

[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
  • ProcessBillingCycles#process performs the two-level loop (frequency → cleanup → WorkerGroup → strategy.run → remove-empty)
  • 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

Make WorkerGroup dispatch key-agnostic

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
  • no Ruby files are added or modified

Introduce InvoiceDueDatePolicy with Net0 default

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment