Skip to content

Instantly share code, notes, and snippets.

@juike
Last active May 26, 2026 11:38
Show Gist options
  • Select an option

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

Select an option

Save juike/f0857526fbaecb5bd2b475a40dcd8763 to your computer and use it in GitHub Desktop.

Billing refactor — first iteration interfaces

Summary for non-engineers

The problem

Today's billing system was built for one shape of arrangement: monthly or twice-monthly cycles, a single approval step, one invoice per cycle, hourly or fixed work. Every variation a client has wanted over the years (custom invoicing dates, different payout providers, autopay vs manual, etc.) has been added as a branch inside the same code path. The system works, but extending it for a genuinely new arrangement (milestone-based, retainer, weekly, multi-entity, native-currency invoicing) requires touching code that all current clients depend on. That's risky, slow, and the risk grows with every new client we onboard.

Where we want to end up

Two layers, with very different jobs:

  • Storage — a thin, neutral set of tables that just record what was billed: who, when, how much, in what currency. The storage layer makes no assumptions about the shape of billing. A cycle can be any date range; an invoice can group any set of items; a payment isn't tied to a single period.
  • Orchestration — a collection of independent strategies. Each strategy describes one way of billing (standard monthly, milestone, retainer, weekly, etc.) and is self-contained. A client (or a group of workers inside a client) is bound to one strategy. Two clients on different strategies share no logic — fixing the milestone strategy can never break a client on monthly billing.

Strategies are built from smaller reusable pieces (period generation, invoice grouping, approval gating, payout scheduling). Same pieces, different combinations, predictable behavior.

A separate Validator sits above all strategies and checks contractual invariants — for example, "regardless of how billing is split into invoices, the total per month must equal the contracted amount." The Validator is configured from the contract and doesn't care which strategy ran.

How we get there — iteratively, not in one rewrite

  • Iteration 0 — the safety net. Before any code changes, we write an end-to-end test suite that locks in the current behavior of every billing arrangement in production. These tests are the contract iteration 1 cannot break. Without them, the refactor is unsafe — we'd be moving lots of code with no automated way to detect a regression. With them, "behavior unchanged" becomes a verifiable claim instead of an aspirational one.

  • Iteration 1 — refactor without behavior change. The visible payoff is zero, which is the point. The system runs identically afterward — every client's invoices, payments and emails come out exactly as before. What changes is only how the code is organized: variation moves out of long methods into small named classes, so future product requirements can be added by writing a new class alongside the existing ones instead of editing code all clients depend on. Delivered as a series of small PRs, each independently shippable and verified against the iteration 0 suite. Concretely, this iteration covers:

    • extract the rules that compute due dates and payout dates out of the cycle model;
    • extract the rules that decide which billing frequency a cycle belongs to;
    • extract the rules that decide between automatic and manual approval;
    • separate the hourly-with-timesheets flow from the fixed-rate flow into two parallel strategies;
    • introduce a single dispatch point at the entry of the daily billing job, so all variation is selected once per group of workers and the rest of the pipeline doesn't branch;
    • update the eight reactive triggers (timesheet submission, talent activation, payment-settings change, etc.) to go through the same dispatch point.
  • Iteration 2 — opening the system to real variation. Once the seams from iteration 1 are in place, we use them to satisfy actual product requirements. Two examples we've worked through in detail: (a) letting clients include one-time payments in the same invoice as the talent's regular cycle billing, instead of producing a separate invoice; (b) splitting invoices by currency so multi-currency teams get one native-currency invoice per region. Each new capability is a new file alongside the existing ones — no editing of code that other clients depend on. The schema bends gently as needed (a nullable column on one-time payments, lifting a one-to-one relationship to one-to-many) — never destructively. Over time, this iteration also introduces the genuinely new shapes when product asks for them: milestone-based, threshold-based, cross-cycle aggregation. The first such requirement triggers the introduction of two pieces from the long-term vision that iteration 1 deferred: a more flexible "billable work" record (when an arrangement doesn't fit calendar cycles) and the Validator (so contractual invariants like "monthly total matches contract" are enforced regardless of how the strategy split things up). Each of these is justified by the concrete requirement that pulled it in, not built speculatively.

What we deliberately don't do

  • We don't redesign the storage layer up front. The current billing_cycles table is already nearly neutral (just a date range, due date, client) — it doesn't need replacing. The constraints we want to lift live in code, not schema.
  • We don't introduce new core entities (like a universal BillableWork record) until a real product requirement forces it. Premature abstraction is the most expensive mistake here.
  • We don't redesign the orchestration layer up front either. The four-axis composite (Billing × Invoicing × Approval × Payment) is the end goal — the long-term vision is a wizard where a client representative picks one implementation per axis and the system assembles a working strategy from those pieces. We grow toward this composite gradually, splitting each axis into finer sub-axes only when a real product requirement forces a combination that doesn't fit a single setting.

How we know it's working

The test of the architecture is not "we extracted some classes." It's that adding a new arrangement is a new file alongside existing ones, with no edits to anything in production. We've validated this by walking through two real product requirements (one-time-payment merging, per-currency invoicing) — both fit cleanly without touching any existing strategy.

What's still open

Two pieces of the original vision will be picked up inside iteration 2, once their corresponding product requirement appears — not earlier:

  • Payment unbound from invoice/period. Today a payment is created 1:1 with each invoice line item. To fully decouple (e.g. to let one payment cover several invoices, or one invoice be paid by multiple payments) requires schema work that we'll do when product asks for it.
  • The Validator. Until a strategy with a non-trivial flow exists, contractual-total checks can be deferred. Once milestone or threshold-based strategies arrive, this becomes urgent and lands as part of iteration 2.

Summary for engineers

Goal of this iteration: localize the existing implicit branching (frequency, autopay, due_date calculation, hourly vs fixed flow) behind explicit policy/strategy objects. The hourly/fixed split is included so the architecture is exercised by two real strategies running side by side, not just by policy extraction in isolation. No new models, no schema changes, behavior unchanged.

Dispatch

All grouping happens once, at the entry point. Cycle uniqueness is (client_id, frequency, period); WorkerGroup is finer-grained (also splits by rate_currency, autopay, rate_type) — so multiple WorkerGroups share one cycle. The dispatch is therefore a two-level loop: outer by frequency (cycle scope — find/create, cleanup, remove-empty-approval-requests live here), inner by WorkerGroup (strategy scope — reconcile + create live here).

# back-end/domain_packs/hg/lib/hg/services/process_billing_cycles.rb
def process
  talents = find_chargeable_talents

  # Outer loop: cycle uniqueness is (client_id, frequency, period).
  # PBC owns cycle-level concerns: find/create, cleanup, finalization.
  talents.group_by { |t| t.periodic_payment_settings_on(cycle_date).frequency }.each do |frequency, frequency_talents|
    cycle = find_or_create_cycle(frequency)
    cleanup_pending_line_items!(cycle, frequency_talents)

    # Inner loop: WorkerGroup is the strategy dispatch unit.
    # Strategies do per-WorkerGroup create + reconcile only.
    frequency_talents.group_by { |t| HG::Billing::WorkerGroup.from(t.periodic_payment_settings_on(cycle_date), client_id: client.id) }
                     .each do |worker_group, group_talents|
      strategy = HG::Billing::StrategyResolver.call(worker_group)
      strategy.run(worker_group: worker_group, talents: group_talents, cycle: cycle, cycle_date: cycle_date)
    end

    remove_empty_approval_requests!(cycle)
  end
end

The same dispatch is reached transitively by reactive triggers — they call ProcessBillingCycles(client, date) (timesheet/submit at actions/timesheet/business_action.rb:15, talents/activate at actions/talents/activate.rb:24, talents/update_payment_settings at actions/talents/update_payment_settings.rb:111, talents/update_next_autopay_amount at actions/talents/update_next_autopay_amount.rb:27). Narrowing them to single-talent dispatch is unsafe — the cycle-scoped remove_line_items_for_removed_talents! would destroy other talents' pending line items in the same cycle.

Worker group (value object, no DB)

WorkerGroup = Data.define(:client_id, :rate_currency, :frequency, :autopay, :rate_type) do
  # @param payment_settings [HG::Models::PaymentSettings]
  # @param client_id        [Integer]   passed in because PaymentSettings belongs_to talent, not client
  # @return [WorkerGroup]
  def self.from(payment_settings, client_id:); end
end

Single source of truth for the four axes of variation, plus client_id for dispatch scoping. No table — derived from payment_settings (plus the caller's client_id) on the fly. Note: rate_currency is read via payment_settings.rate.currency since the attribute is private on PaymentSettings. Materializing as a table is deferred until something other than payment_settings needs to live on it.

Strategy layer

class HG::Billing::ClientStrategy
  # All `talents` in the call share the same WorkerGroup tuple — same frequency,
  # same rate_type, same autopay flag, same currency. The strategy is free to
  # assume homogeneity.
  #
  # `cycle` is provided by the caller (PBC) because cycle uniqueness is
  # `(client_id, frequency, period)` — coarser than the WorkerGroup — so the
  # same cycle is shared across multiple strategy invocations within a frequency.
  #
  # @param worker_group [HG::Billing::WorkerGroup]
  # @param talents      [Array<HG::Models::Talent>]
  # @param cycle        [HG::Models::Cycle]
  # @param cycle_date   [Date]
  # @return [void]
  def run(worker_group:, talents:, cycle:, cycle_date:); raise NotImplementedError; end
end
module HG::Billing::StrategyResolver
  # @param worker_group [HG::Billing::WorkerGroup]
  # @return [HG::Billing::ClientStrategy]
  def self.call(worker_group)
    case worker_group.rate_type
    when "hourly" then Strategies::LegacyHourly.new
    else               Strategies::LegacyFixed.new
    end
  end
end

Strategies::LegacyBase (template)

Holds the per-WorkerGroup orchestration that used to live in Cycle::Processor. The class HG::Services::Cycle::Processor is removed; its cycle-scoped parts (cleanup_pending_line_items!, remove_empty_approval_requests!, find_or_create_cycle) move to ProcessBillingCycles; its WorkerGroup-scoped parts (reconcile + create) move here.

class HG::Billing::Strategies::LegacyBase < HG::Billing::ClientStrategy
  def run(worker_group:, talents:, cycle:, cycle_date:)
    reconcile_existing_line_items!(cycle, talents)
    create_new_line_items!(cycle, talents, worker_group)
  end

  protected

  # Subclass hooks — these are the three points where today's Processor branches on hourly_talent?.
  def build_line_item(cycle:, talent:, value:, approval_request:); raise NotImplementedError; end
  def reconcile_line_item!(line_item);                              raise NotImplementedError; end
  def handle_stale_line_item!(line_item, talent);                   raise NotImplementedError; end
end

cleanup_pending_line_items! and remove_empty_approval_requests! are intentionally not in the strategy. They operate on the cycle's full line-item set (across all WorkerGroups), so running them per-WorkerGroup would let one group destroy another group's pending line items. PBC's outer loop runs them once per cycle, before/after the inner WorkerGroup dispatch. handle_stale_line_item! (a hook on the strategy) stays — it operates on one line item at a time, so it's per-talent-scoped, safe to run inside the strategy when the line item belongs to the strategy's WorkerGroup. Today's remove_line_items_with_stale_payment_settings! (which calls the hook) splits: the iteration over line items belonging to passed talents happens inside reconcile_existing_line_items!; the hook itself is the rate-type branch.

Strategies::LegacyHourly

class HG::Billing::Strategies::LegacyHourly < LegacyBase
  def build_line_item(cycle:, talent:, value:, approval_request:)
    line_item = base_build(cycle, talent, value, approval_request)
    create_timesheet_with_records!(line_item)
    line_item
  end

  def reconcile_line_item!(line_item)
    ts = line_item.timesheet
    return unless ts&.submitted? || ts&.approved?
    return unless line_item.approval_request.approvable?
    return if line_item.value == ts.total_amount

    line_item.update!(value: ts.total_amount)
    HG::Services::Cycle::RecalculateLineItem.new(line_item).update!
  end

  def handle_stale_line_item!(line_item, talent)
    ps = talent.payment_settings
    return if line_item.payment_settings_id == ps.id

    line_item.update!(payment_settings_id: ps.id)
    HG::Services::Cycle::RecalculateLineItem.new(line_item).update!
  end
end

Strategies::LegacyFixed

class HG::Billing::Strategies::LegacyFixed < LegacyBase
  def build_line_item(cycle:, talent:, value:, approval_request:)
    base_build(cycle, talent, value, approval_request)
  end

  def reconcile_line_item!(_line_item)
    # noop — no timesheet to reconcile against
  end

  def handle_stale_line_item!(line_item, _talent)
    line_item.destroy
  end
end

Frequency policy

class HG::Billing::FrequencyPolicy
  REGISTRY = { "monthly" => Monthly, "semi_monthly" => SemiMonthly }.freeze

  # @param frequency [String]
  # @return [HG::Billing::FrequencyPolicy]
  def self.for(frequency); end

  # @param date [Date]
  # @return [Range<Date>]
  def period(date); end

  # @param date [Date]
  # @return [Date]
  def next_period_after(date); end

  # @return [Symbol]  :monthly | :first_semi_monthly | :second_semi_monthly
  def key_for(period); end
end

Two concrete subclasses: Monthly, SemiMonthly. Adding Weekly later is a third subclass + one REGISTRY entry. Existing code that does case frequency / monthly? / first_semi_monthly? is rewritten to delegate here.

Due-date / payout-date policies

class HG::Billing::PayoutDatePolicy
  def initialize(client:); end

  # @param period           [Range<Date>]
  # @param frequency_policy [HG::Billing::FrequencyPolicy]
  # @return [Date]
  def call(period:, frequency_policy:); end
end

class HG::Billing::DueDatePolicy
  def initialize(client:); end

  # @param period           [Range<Date>]
  # @param frequency_policy [HG::Billing::FrequencyPolicy]
  # @param payout_date      [Date]
  # @return [Date]
  def call(period:, frequency_policy:, payout_date:); end
end

These replace Cycle#calculate_due_date, Cycle#payout_date, Cycle#custom_invoicing_due_date, and related private helpers. The model still exposes due_date and payout_date (column reads) — values are populated by the policies at cycle creation. Read fan-out across mailers, calendar events, etc. is unaffected.

Approval policy

class HG::Billing::ApprovalPolicy
  # @param worker_group [HG::Billing::WorkerGroup]
  # @return [HG::Billing::ApprovalPolicy]
  def self.for(worker_group); end

  # @param cycle [HG::Models::Cycle]
  # @param value [Money]   sign matters: negative cannot be auto-approved even under Auto
  # @return [HG::Models::ApprovalRequest]
  def gate(cycle:, value:); end
end

Two concrete subclasses:

  • Auto (when worker_group.autopay == true): positive value → autopay approval request, negative → manual.
  • Manual (when worker_group.autopay == false): always manual.

This replaces Cycle#find_or_create_manual_approval_request, Cycle#find_or_create_autopay_approval_request, and the value.positive? ? autopay : manual branch at Cycle::Processor:110.

Surface summary

Component Count
Strategy interface 1
Strategy implementations 2 (LegacyHourly, LegacyFixed)
Strategy base / template 1 (LegacyBase)
Resolver 1
Policies 4 (FrequencyPolicy, DueDatePolicy, PayoutDatePolicy, ApprovalPolicy)
Value objects 1 (WorkerGroup)
New DB tables 0
New columns 0

What goes away

  • HG::Services::Cycle::Processor — content absorbed by Strategies::LegacyBase.
  • Cycle#calculate_due_date, payout_date, custom_invoicing_due_date, custom_invoicing_payout_date, payout_day, custom_invoicing_day?, due_period — into date policies.
  • Cycle#monthly?, first_semi_monthly?, second_semi_monthly?, period, frequency_key, ransacker cycle_type — into FrequencyPolicy.
  • Cycle#find_or_create_manual_approval_request, find_or_create_autopay_approval_request — into ApprovalPolicy.
  • The value.positive? ? autopay : manual ternary in Cycle::Processor — into ApprovalPolicy::Auto.
  • case frequency branches in AutopayPayoutCalculator, SchedulePreview, PaymentCalendarEvents, VirtualCycleProjector — delegate to FrequencyPolicy.

Cycle model shrinks from ~245 lines to ~80–100 (associations, columns, cover?, validations).

Out of scope for this iteration

  • BillableWork or any new models.
  • Decoupling the cycle ↔ approval_request ↔ invoice 1:1:1 chain.
  • WorkerGroup as a DB table.
  • Aggregated / split / threshold-based invoicing.
  • Pre-fund / milestone / multi-entity strategies.

Payment strategy (deferred — named, not built)

The four long-term axes are Billing × Invoicing × Approval × Payment. Iteration 1 lays seams for the first three; payment is deferred. This subsection names the seam so iteration 2 doesn't have to re-derive where it slots in — no code is added here.

HG::Billing::PaymentStrategy is a peer of ClientStrategy and InvoicingStrategy, resolved per WorkerGroup at the same dispatch point. Concretely: the daily ProcessBillingCycles job and the eight reactive triggers already build a WorkerGroup and call StrategyResolver.call(worker_group) for the billing strategy; a sibling PaymentStrategyResolver.call(worker_group) returns the payment strategy for the same group, and that strategy is invoked from the two call sites where money currently moves — client-side charge (today inline in the invoice/approval path) and worker-side payout (today inline in Cycle::Processor and the payout job). Both call sites stop hard-coding payment logic and dispatch through the strategy, exactly as the iteration-2 InvoicingStrategy work does for invoice creation.

Sketch of the interface (illustrative — final signatures are picked when the first requirement lands):

class HG::Billing::PaymentStrategy
  # Settle the client side of an issued invoice.
  # @param invoice [HG::Models::Invoice]
  # @return [Array<HG::Models::Payment>]  may be 1 (Today) or N (PartialFunding, per-batch, etc.)
  def charge(invoice:); raise NotImplementedError; end

  # Initiate the worker payout for a settled line item.
  # @param line_item [HG::Models::InvoiceLineItem]
  # @return [Array<HG::Models::Payout>]
  def payout(line_item:); raise NotImplementedError; end
end

The first concrete subclass — PaymentStrategy::Today — wraps the current inline behavior, the same way InvoicingStrategy::Separate wraps today's invoice creation. It is the safety wrapper that lets iteration 2 introduce a second subclass without touching call sites.

Dimensions that will land on this seam (surfaced by the use-case review in cases-architecture-gaps.md):

  • Payment rails — ACH, Wire, SEPA, SWIFT, card, local rails (PIX, UPI, SPEI), stablecoin, on-platform wallet
  • Funding timing — prefund before/after invoice approval, just-in-time, rolling balance, scheduled funding, auto top-up
  • Partial funding — 50/50 splits, by country, by priority, net wages vs fees separately
  • FX lock logic — at funding / at payout / at invoice / per contractor / per batch
  • Failure handling — payment fail, partial settlement, chargeback, reversal, bank hold, FX fluctuation after funding
  • Treasury modes — lump sum, rolling balance, per country, per legal entity, per cost center

Two cross-axis items also land here:

  • Invoice currency ≠ payout currencyInvoicingStrategy controls only how line items map to invoices; the rate at which the client charge is converted into the worker payout is a PaymentStrategy concern.
  • Worker chooses payout currency — same shape; requires both a payout_currency distinct from WorkerGroup.rate_currency and a strategy decision about when to lock FX.

What this section does not authorize: no PaymentStrategy class, no resolver, no test scaffolding, no call-site changes. The point is to fix the shape and location in advance so the first iteration-2 ticket against payment can land as one new file plus a resolver, not as a debate about where to put it.

Validation that the architecture works

The signal that this iteration succeeds is not "policies extracted" — it is "two strategies (LegacyHourly, LegacyFixed) coexist for the same client, dispatched per WorkerGroup, sharing nothing but the storage layer (existing tables) and the policy objects." Once that runs in production with no behavior change, adding a third strategy (e.g. milestone-based) becomes a new file alongside the existing two, with no edits to the existing strategies.


Second iteration — example: merging one-time payments into the cycle invoice

This section is an example of what iteration 2 looks like in practice, prompted by a concrete product requirement: a client wants one-time payments with payout_date_type=next_billing_cycle to be included in the invoice for that talent's billing cycle, instead of producing a separate invoice as today.

What this requirement reveals

This is the first place where iteration 1's seam pays off: the requirement adds a new axis of variation — invoicing — that is independent of the rate_type-based axis from iteration 1. Today, invoice creation is implicitly hard-coded in two places:

  1. HG::Actions::ApprovalRequest::Approve — when an approval clears, builds an invoice from the cycle's line items.
  2. HG::Actions::OneTimePayments::Create:113 — when an OTP is created, builds a separate invoice with reason: one_time_payment.

To support "merge OTP into the cycle invoice," these two call sites must collapse behind a single interface.

New interface

class HG::Billing::InvoicingStrategy
  # Build the invoice(s) when an ApprovalRequest is approved.
  # @param approval_request [HG::Models::ApprovalRequest]
  # @return [Array<HG::Models::Invoice>]   may be 1 (Separate / Merge*) or N (PerCurrency)
  def issue_for_approval(approval_request:); raise NotImplementedError; end

  # Build the invoice(s), or defer, when a OneTimePayment is created.
  # @param one_time_payment [HG::Models::OneTimePayment]
  # @return [Array<HG::Models::Invoice>]   empty = deferred to a future approval
  def issue_for_one_time_payment(one_time_payment:); raise NotImplementedError; end
end

The return type is always an array — call sites never assume "exactly one." This is what makes the per-currency variation in the next example expressible without changing the interface.

Two concrete implementations

# Today's behavior. Cycle approval → one invoice. OTP → its own invoice.
class HG::Billing::InvoicingStrategy::Separate < InvoicingStrategy
  def issue_for_approval(approval_request:)
    [HG::Actions::Invoice::CreateFromApproval.call(approval_request)]
  end

  def issue_for_one_time_payment(one_time_payment:)
    [HG::Actions::Invoice::CreateFromOneTimePayment.call(one_time_payment)]
  end
end

# New behavior. OTPs with payout_date_type=next_billing_cycle are absorbed
# into the matching talent's cycle invoice when approval clears.
class HG::Billing::InvoicingStrategy::MergeNextCycleOTP < InvoicingStrategy
  def issue_for_approval(approval_request:)
    invoice = HG::Actions::Invoice::CreateFromApproval.call(approval_request)
    pending_next_cycle_otps_for(approval_request).each do |otp|
      HG::Actions::Invoice::AppendOneTimePayment.call(invoice, otp)
    end
    [invoice]
  end

  def issue_for_one_time_payment(one_time_payment:)
    return [] if one_time_payment.next_billing_cycle_date?  # deferred
    [HG::Actions::Invoice::CreateFromOneTimePayment.call(one_time_payment)]
  end

  private

  def pending_next_cycle_otps_for(approval_request)
    HG::Models::OneTimePayment
      .where(client_id: approval_request.cycle.client_id)
      .where(talent_id: approval_request.line_items.pluck(:talent_id))
      .where(payout_date_type: :next_billing_cycle)
      .where(invoice_id: nil)
  end
end

Resolver and call sites

module HG::Billing::InvoicingStrategyResolver
  def self.call(client)
    if client.feature_enabled?(:merge_next_cycle_otp)
      InvoicingStrategy::MergeNextCycleOTP.new
    else
      InvoicingStrategy::Separate.new
    end
  end
end

The two existing call sites stop hard-coding invoice creation:

# Actions::ApprovalRequest::Approve
HG::Billing::InvoicingStrategyResolver.call(approval_request.cycle.client)
  .issue_for_approval(approval_request: approval_request)

# Actions::OneTimePayments::Create
HG::Billing::InvoicingStrategyResolver.call(client)
  .issue_for_one_time_payment(one_time_payment: one_time_payment)

Schema impact (the first real one)

Iteration 1 had zero schema changes. This iteration adds exactly one:

add_reference :one_time_payments, :invoice, null: true, foreign_key: true

Today the OTP↔invoice link is via Invoice.reason → OneTimePayment (polymorphic-ish). The merge case requires the reverse — an OTP needs to know which invoice it landed in (or that it's still pending). The column is nullable: a pending OTP under MergeNextCycleOTP has no invoice yet.

What this validates about the architecture

  • InvoicingStrategy is fully orthogonal to the iteration-1 strategies. A LegacyHourly worker group can be invoiced under Separate or MergeNextCycleOTP. A LegacyFixed group likewise. The cross-product is four combinations and none of them require new code in the iteration-1 classes.
  • The new requirement was implemented by adding two new files (InvoicingStrategy::Separate, InvoicingStrategy::MergeNextCycleOTP) plus a resolver and a one-column migration. No existing strategy or policy from iteration 1 was modified.
  • The two call sites that used to hard-code invoice creation now both go through the same interface. Adding a third path (e.g. milestone-driven invoicing) becomes a third implementation rather than a third call site.

This is the test of iteration 1's design. If extending the system for this requirement had forced edits across the existing strategies/policies, the orthogonality claim would be wrong and the architecture would need re-thinking. Because it doesn't, the seam is real.


Second iteration — example: invoice grouping by currency

A different InvoicingStrategy variant, prompted by a different product requirement: clients with multi-currency teams want separate native-currency invoices per region (USD-paid talents → one USD invoice, EUR-paid → one EUR invoice) instead of a single mixed-currency invoice with exchange rates.

What this requirement reveals

Today's grouping is hard-coded: one approval request → one invoice. Currency is reconciled inside that single invoice via invoicing_value and exchange_rate columns on each line item, with MultiCurrencyInvoiceWebhook papering over the differences.

This grouping rule is the second axis baked into the current code that needs to come out — the first being "OTP gets its own invoice." Both are forms of the same underlying constraint: how approval-clearing maps to invoice creation. The InvoicingStrategy interface is exactly that mapping.

PerCurrency implementation

class HG::Billing::InvoicingStrategy::PerCurrency < InvoicingStrategy
  def issue_for_approval(approval_request:)
    approval_request.line_items
      .group_by { |li| li.payment_settings.rate_currency }
      .map do |currency, line_items|
        HG::Actions::Invoice::CreateFromLineItems.call(
          approval_request: approval_request,
          line_items:       line_items,
          currency:         currency,
        )
      end
  end

  def issue_for_one_time_payment(one_time_payment:)
    [HG::Actions::Invoice::CreateFromOneTimePayment.call(one_time_payment)]
  end
end

Each issued invoice is in the native currency of its line items. No invoicing_value, no per-line exchange_rate. The multi-currency reconciliation logic that lives in MultiCurrencyInvoiceWebhook and on InvoiceLineItem becomes a no-op for this strategy — those mechanisms are only needed by strategies that mix currencies into one invoice.

Schema

The current relationship ApprovalRequest has_one :invoice (via polymorphic Invoice.reason) becomes has_many :invoices. Structurally this is a model change, not a migrationInvoice.reason_id / reason_type already supports multiplicity; the "one invoice per approval" rule lives only in Ruby validations.

# before
class ApprovalRequest < ApplicationRecord
  has_one :invoice, as: :reason
end

# after
class ApprovalRequest < ApplicationRecord
  has_many :invoices, as: :reason
end

If a validates_uniqueness_of or similar guard exists in the codebase enforcing single-invoice-per-approval, it goes away. This is the second soft change relaxing the cycle ↔ approval ↔ invoice 1:1:1 chain.

What this validates about the architecture

  • A single client can run LegacyHourly + LegacyFixed (iteration-1 axis) and PerCurrency (iteration-2 axis) simultaneously. The two iteration-1 strategies know nothing about invoices; PerCurrency knows nothing about timesheets. The only contract between them is ApprovalRequest.
  • Multi-currency complexity (invoicing_value, exchange rates per line item) stops being a global concern. It is needed only by strategies that intentionally collapse multiple currencies into one invoice. PerCurrency is free of it by construction.
  • Adding the variation required no edits to existing iteration-1 or iteration-2 classes. One new file (InvoicingStrategy::PerCurrency), one resolver branch, one model association change.

Composition is the goal

The long-term direction is a client-facing wizard where a representative picks one option per axis and the system assembles the resulting strategy from those choices at runtime. Naming a class for each combination doesn't scale to that — N independent decisions across M options each is M^N classes, most of which would never be used.

This means two things for how we draw the lines:

  1. Each axis must be small enough that its options are independent decisions. "Per-currency invoicing" and "merge OTPs into cycle invoice" are two different decisions, even though both feel like InvoicingStrategy concerns. To make them composable without a PerCurrencyWithMergedOTPs class, we split InvoicingStrategy into sub-axes:

    • InvoiceGrouping: SinglePerApproval | PerCurrency | PerWorker | Aggregated
    • OTPRouting: Separate | MergedIntoCycleInvoice
    • DueDateScheme: NetX(days) | EndOfMonth | CustomDay(day)
    • IssuanceTrigger: OnApproval | Threshold(amount) | Schedule(cron)

    "Per-currency + merged OTPs" is now a configuration — { grouping: PerCurrency, otp_routing: Merged, ... } — not a third class.

  2. A compatibility matrix becomes load-bearing. Not every combination is meaningful (Pre-fund payment × Per-milestone billing × Auto approval — pre-fund what, exactly?). The wizard must narrow the options as the user picks, and the system must reject invalid combinations at save time. The matrix is a small declarative file that lists which (axis_a value, axis_b value) pairs are allowed.

When to split an axis into sub-axes: when a product requirement asks for a combination that the current axis can't express as a single setting. The split is justified by the requirement, not by speculation. Iteration 2's two examples (MergeNextCycleOTP, PerCurrency) are exactly this trigger — they are the first signs that InvoicingStrategy is too coarse.

Parameterization replaces named subclasses. Net15 vs Net30 vs Net60 is one class NetX(days) parameterized with days. ManualNApprovers(n) is one class parameterized by required count. Class-per-variant is reserved for genuinely different behaviors, not different values.

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