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.
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.
-
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.
- We don't redesign the storage layer up front. The current
billing_cyclestable 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
BillableWorkrecord) 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.
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.
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.
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.
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
endThe 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.
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
endSingle 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.
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
endmodule 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
endHolds 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
endcleanup_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.
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
endclass 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
endclass 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
endTwo 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.
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
endThese 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.
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
endTwo concrete subclasses:
Auto(whenworker_group.autopay == true): positive value → autopay approval request, negative → manual.Manual(whenworker_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.
| 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 |
HG::Services::Cycle::Processor— content absorbed byStrategies::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, ransackercycle_type— intoFrequencyPolicy.Cycle#find_or_create_manual_approval_request,find_or_create_autopay_approval_request— intoApprovalPolicy.- The
value.positive? ? autopay : manualternary inCycle::Processor— intoApprovalPolicy::Auto. case frequencybranches inAutopayPayoutCalculator,SchedulePreview,PaymentCalendarEvents,VirtualCycleProjector— delegate toFrequencyPolicy.
Cycle model shrinks from ~245 lines to ~80–100 (associations, columns, cover?, validations).
BillableWorkor any new models.- Decoupling the
cycle ↔ approval_request ↔ invoice1:1:1 chain. WorkerGroupas a DB table.- Aggregated / split / threshold-based invoicing.
- Pre-fund / milestone / multi-entity strategies.
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
endThe 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 currency —
InvoicingStrategycontrols only how line items map to invoices; the rate at which the client charge is converted into the worker payout is aPaymentStrategyconcern. - Worker chooses payout currency — same shape; requires both a
payout_currencydistinct fromWorkerGroup.rate_currencyand 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.
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.
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.
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:
HG::Actions::ApprovalRequest::Approve— when an approval clears, builds an invoice from the cycle's line items.HG::Actions::OneTimePayments::Create:113— when an OTP is created, builds a separate invoice withreason: one_time_payment.
To support "merge OTP into the cycle invoice," these two call sites must collapse behind a single 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
endThe 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.
# 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
endmodule HG::Billing::InvoicingStrategyResolver
def self.call(client)
if client.feature_enabled?(:merge_next_cycle_otp)
InvoicingStrategy::MergeNextCycleOTP.new
else
InvoicingStrategy::Separate.new
end
end
endThe 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)Iteration 1 had zero schema changes. This iteration adds exactly one:
add_reference :one_time_payments, :invoice, null: true, foreign_key: trueToday 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.
InvoicingStrategyis fully orthogonal to the iteration-1 strategies. ALegacyHourlyworker group can be invoiced underSeparateorMergeNextCycleOTP. ALegacyFixedgroup 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.
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.
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.
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
endEach 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.
The current relationship ApprovalRequest has_one :invoice (via polymorphic Invoice.reason) becomes has_many :invoices. Structurally this is a model change, not a migration — Invoice.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
endIf 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.
- A single client can run
LegacyHourly+LegacyFixed(iteration-1 axis) andPerCurrency(iteration-2 axis) simultaneously. The two iteration-1 strategies know nothing about invoices;PerCurrencyknows nothing about timesheets. The only contract between them isApprovalRequest. - 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.PerCurrencyis 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.
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:
-
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
InvoicingStrategyconcerns. To make them composable without aPerCurrencyWithMergedOTPsclass, we splitInvoicingStrategyinto 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. - InvoiceGrouping:
-
A compatibility matrix becomes load-bearing. Not every combination is meaningful (
Pre-fundpayment ×Per-milestonebilling ×Autoapproval — 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.