Comp Calculator v2: PayBill Unification

May 1, 2023 · 8 min
platformretrobilling

Trusted has two distinct financial systems under the hood. Techelon is the operational backend that finance and billing teams live in: payroll runs, invoice generation, reconciliation reports, timekeeping rules, and the early pay invoice program. It’s the system that moves money.

Feeding Techelon is the pay package computation layer---the engine that calculates what a nurse should be paid on a given contract. That computation runs three times during a nurse’s journey: when a job is curated (the package is configured for the role), when she receives an offer (confirmed for her specifically), and when her contract is locked. In a well-designed system, all three produce identical results from identical inputs, and there is a complete record of what inputs were used.

For most of Trusted’s history before this, the calculator didn’t live in Rails at all. It was a script in a separate repo owned by a single founder, running outside the deploy pipeline, code review, and audit logging the rest of the platform required. One person held the keys to the math that decided every nurse’s paycheck.

Before April 2023, neither was true reliably.

The three computation paths had diverged over time. Billing rules updated in one context didn’t propagate to the others. When a discrepancy appeared between what a nurse was offered and what her contract said---or when a customer disputed an invoice---the question “what inputs produced this number and when?” often required querying multiple systems and hoping nothing had changed between them.

Comp Calculator v2 fixed the computation layer: a single engine, a unified rules hierarchy, and immutable snapshot records of every calculation.

The snapshot table

The core artifact is a table called pay_bill_snapshots.

It started life as billing_configs---a narrower concept, created in October 2020, that captured billing configuration state without preserving the full computation record. The rename in March 2023 reflected what the table needed to do: store an immutable record of every pay package computation from that point forward.

Each snapshot is polymorphic---it can belong to a Job, JobApplication, JobOffer, or Contract. The same computation target can have multiple snapshots over time; the released_at timestamp determines which one is current. The latest_valid scope returns the most recent non-error snapshot ordered by released_at DESC.

A snapshot has four JSONB columns that together constitute a complete, self-contained record of a computation:

  • target_attributes: denormalized job parameters at the time of computation: billing rate, hours per shift, shifts per week, contract length, state, postal code, program, business, clinical unit, role type, start and end date. This is a snapshot of the job record, not a foreign key to it. It can reconstruct the computation without touching the current state of the job.

  • billing_attributes: the flattened billing rules that applied. This is where the rates live: charge rate, on-call rate, overtime multipliers, holiday handling, orientation hours, call-off policy, MSP fee. Billing attributes are computed from the rules hierarchy, then stored.

  • compensation_attributes: the global configuration values that were active at computation time: take rate, benefits percentages, tax rates, GSA lodging and meals limits, 401k contribution, cellphone reimbursement. These come from the comp_calculator_global_configurations table, which has its own versioned lifecycle.

  • comp_calculator_inputs: the exact versioned input struct that was passed to the calculator. This is what ties everything together: the merged, normalized representation of target + billing + compensation inputs that the calculator consumed.

A snapshot also stores the output: travel_pay_package and local_pay_package as JSONB, both the computed rates and the full weekly cost breakdown. The snapshot is the computation record from input to output, self-contained.

A source field distinguishes how each snapshot was produced:

  • pay_bill_rules: computed from the billing rules hierarchy (the normal path)
  • manual: manually set by a curator in the curation UI
  • manual_recalculate: triggered by a curator to recompute from current rules
  • ingestion: produced from VMS ingestion data (for major VMS partners and similar external sources)
  • simulation: computed for a what-if preview; never persisted
  • initial: a migration artifact marking snapshots ported from the old billing_configs data; flagged as an “untrusted combination” in the codebase

The billing rules table

The pay_bill_rules table was created in January 2023 as part of the same initiative.

It uses single-table inheritance with 15 rule type subclasses:

ChargeRate, OnCallRate, CallBackRate, OvertimeRate, DoubleTimeRate,
HolidayRate, HolidayOverride, Holiday, BillingCycle,
OrientationRate, CallOffPolicy, MasterServiceAgreement,
Dispute, Payment, PreEmploymentModule

Each rule is scoped to a target---Program, Business, or BusinessClinicalUnit---with optional shift_length and weekly_contracted_hours columns that support per-shift and per-contracted-hours overrides. A ChargeRate rule scoped to a 12-hour shift produces different billing attributes than the same rule’s default (shift-length-unspecified) version. The unique constraint on (type, target_type, target_id, shift_length, weekly_contracted_hours, text_value) enforces that only one rule of each type exists per scope.

All mutations to pay_bill_rules are audited via IronTrail---our audit log system---which records who changed what and when in the irontrail_changes table.

Each rule subclass implements a billing_attributes method that produces the keys it contributes to the snapshot’s billing_attributes column. ChargeRate, for example:

def billing_attributes(_)
  if default?  # shift_length.blank?
    { charge_rate_expression: expression, charge_rate_value: expression_value.to_f }
  else
    length = shift_length.to_i
    { "charge_rate_for_#{length}_hour_shift_expression" => expression,
      "charge_rate_for_#{length}_hour_shift_value" => expression_value.to_f }
  end
end

The expression field uses five types: multiplication, addition, constant, flat_rate, and subtraction. These determine how a billing rate is computed from a base rate: multiplication means base_rate * value, constant means value (flat), subtraction means max(base_rate - value, 0), and so on. The expression type is stored alongside the value in billing_attributes, so a historical snapshot preserves not just the computed rate but the formula that produced it.

The rules hierarchy

For a given job, the billing rules that apply are resolved through a three-level cascade:

BusinessClinicalUnit rules
    ↓ (fill in missing types from)
Business rules
    ↓ (fill in still-missing types from)
Program rules

The DefaultRequirementsBuilder service walks this hierarchy. For each rule type, it takes the most specific applicable rule: unit-level if it exists, then business-level, then program-level. This lets a health system override specific rates for specific units without duplicating the entire rule set.

Once the applicable rules are resolved, ConfigBuilder calls each rule’s billing_attributes method and merges the results into a hash. That hash, after running through calculate_inputs to resolve expressions into dollar amounts, becomes the snapshot’s billing_attributes column.

The computation engine

The full computation pipeline runs through five stages:

1. Target attributes. TargetAttributesBuilder extracts the job parameters from the target record (billing rate, hours per shift, contract length, location, specialty) and normalizes them into a TargetAttributes struct. This struct is stored in the snapshot’s target_attributes column.

2. Billing attributes. BillingAttributesBuilder resolves the applicable rules via DefaultRequirementsBuilder, calls ConfigBuilder to collect their billing attribute contributions, then calls calculate_inputs to resolve rate expressions against the target attributes. The result---a fully populated BillingAttributes object with all rates as dollar amounts---is stored in billing_attributes.

3. Calculator selection. CompCalculatorResolver looks up the active comp_calculator_global_configurations record as of computation time and returns the corresponding calculator class. The global configuration table has its own versioned lifecycle (draft → test → active → retiring → archived), with a unique constraint ensuring only one configuration can be in test status at a time. The calculator version---v3 through v8, with named subversions---determines the computation logic.

4. Input construction. InputBuilder merges target attributes, billing attributes, and compensation attributes into a versioned input struct---either InputV1 or InputV8. This struct is stored in comp_calculator_inputs.

5. Pay package computation. The calculator takes the input struct and produces a PayPackage---either PayPackageV1 or PayPackageV8---with the full output: gross pay weekly, estimated receivables, nontaxable stipends, overtime hours and multipliers, GSA lodging and meals limits, orientation cost, cellphone reimbursement. Both the travel and local variants are computed and stored.

Automated rule propagation

The v2 rollout started with a pilot on the programs of one of our largest health system customers in January 2023. The pilot introduced two things simultaneously: a three-person approval process for billing rule changes, and automated propagation of approved changes to all active and removed jobs under the affected program.

The three-person process isn’t a database-enforced approval queue. It’s a process---three people must review before a rule change is applied. Enforcement is via IronTrail audit logs (every change is attributed) and permission gating on who can modify configurations, not an approval table with a state machine.

The automation that required the process: when billing rules update, the system recomputes pay_bill_snapshots for all affected targets automatically. Before automation, a curator had to open each job individually and save. For a program with hundreds of active jobs, that was days of manual work with a proportional error surface. Automation made the propagation instantaneous and complete---which meant the human review had to move to the configuration level rather than being implicit in the mechanical slowness of the old system.

The pilot expanded to additional programs in February. Full unification---covering all programs with the unified computation engine and snapshot table---launched April 12, 2023.

The migration bug

The morning after launch, a bug appeared in one program’s billing rules.

The cause was a naming change between the V1 and V8 input schemas. In InputV1, the field charge_rate represented the actual charge rate dollar amount---the total bill rate including any charge premium. In InputV8, the field renamed to represent the charge bonus: the premium over the base rate, which can legitimately be zero if the program doesn’t apply a charge markup.

That program’s billing rules were migrated with a null charge rate---correctly representing a $0 charge bonus under the V8 semantics where EMPTY and $0 are equivalent. The old billing rule validation, written under V1 semantics, still required a non-null value for charge_rate. The two systems had different expectations about the same field name, and the validation was still running during the migration window.

The fix was removing the legacy validation. The bug surfaced the same day and was resolved the same day. But it illustrates the specific failure mode financial system migrations produce: legacy validations encode semantic assumptions that may not be documented anywhere other than the validation itself. Running old and new validation paths simultaneously during a migration is how you discover where those assumptions have diverged.

What it unlocked

The operational change: billing rule updates now propagate automatically. Finance and Billing teams answer historical questions by querying snapshots, not by reconstructing current state and hoping nothing changed.

The architectural change: every computation is now reproducible. A snapshot from 18 months ago stores the exact versioned input struct that produced its pay package, along with the billing attributes and compensation attributes that were active at that time. Calculator.for_version(snapshot.comp_calculator_version).new(snapshot.comp_calculator_inputs).pay_package should return the same result today that it returned then. The computation is deterministic given the inputs, and the inputs are preserved.

The downstream change: everything that generates pay packages---submission automation, AI-assisted curation, contract flows---is now backed by this infrastructure. When those systems compute a pay package, they create a snapshot. When something needs to audit what happened, the snapshot is there.

--- Engineering

← back to posts