Series 5 — Part 4 of 7

Legal billing rates have a five-level hierarchy: proceeding-specific per-lawyer, proceeding-default, matter-specific per-lawyer, matter-default, and user default. The resolution algorithm walks this chain and returns the first non-null value. This article implements the full hierarchy.

The Five-Level Rate Schema

CREATE TABLE billing_rate_configs (
  id              INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  team_id         INT UNSIGNED NOT NULL,
  -- Scope: all fields nullable; specificity increases with more non-null fields
  matter_id       BIGINT UNSIGNED DEFAULT NULL,
  proceeding_id   BIGINT UNSIGNED DEFAULT NULL,
  user_id         INT UNSIGNED DEFAULT NULL,  -- lawyer
  -- Rate definitions
  rate_per_hour   DECIMAL(10,2) DEFAULT NULL,
  fixed_per_session DECIMAL(10,2) DEFAULT NULL,
  fixed_per_hearing DECIMAL(10,2) DEFAULT NULL,
  -- Temporal: rates can change on a date
  effective_from  DATE NOT NULL DEFAULT (CURRENT_DATE),
  effective_to    DATE DEFAULT NULL,
  FOREIGN KEY (team_id)       REFERENCES teams(id),
  FOREIGN KEY (matter_id)     REFERENCES matters(id),
  FOREIGN KEY (proceeding_id) REFERENCES proceedings(id),
  FOREIGN KEY (user_id)       REFERENCES users(id)
);

The Resolution Algorithm

class BillingRateResolver
{
    /** @return array{rate_per_hour: float|null, fixed_per_session: float|null} */
    public function resolve(
        int      $teamId,
        int      $userId,
        int      $matterId,
        ?int     $proceedingId,
        \DateTimeInterface $on,
        PDO      $pdo
    ): array {
        $dateStr = $on->format('Y-m-d');

        // Ordered from most specific to least specific
        $candidates = array_filter([
            $proceedingId ? ['proceeding_id' => $proceedingId, 'user_id' => $userId]    : null,
            $proceedingId ? ['proceeding_id' => $proceedingId, 'user_id' => null]        : null,
            ['matter_id' => $matterId, 'user_id' => $userId],
            ['matter_id' => $matterId, 'user_id' => null],
            ['matter_id' => null,      'user_id' => $userId],  // user default
        ]);

        foreach ($candidates as $scope) {
            $where   = ['team_id = ?', 'effective_from <= ?', '(effective_to IS NULL OR effective_to >= ?)'];
            $params  = [$teamId, $dateStr, $dateStr];

            foreach (['matter_id', 'proceeding_id', 'user_id'] as $col) {
                if (isset($scope[$col])) {
                    $where[]  = "{$col} = ?";
                    $params[] = $scope[$col];
                } else {
                    $where[] = "{$col} IS NULL";
                }
            }

            $sql  = 'SELECT * FROM billing_rate_configs WHERE ' . implode(' AND ', $where)
                  . ' ORDER BY effective_from DESC LIMIT 1';
            $stmt = $pdo->prepare($sql);
            $stmt->execute($params);
            $row  = $stmt->fetch();

            if ($row && ($row['rate_per_hour'] !== null || $row['fixed_per_session'] !== null)) {
                return $row;
            }
        }

        return ['rate_per_hour' => null, 'fixed_per_session' => null];
    }
}

What to Watch For

  • Effective date gaps — If a rate config expires (effective_to in the past) and no successor exists, the resolver returns null. Build an alert for expiring configs 30 days in advance.
  • Retroactive rate changes — A new config with an effective_from in the past will change the resolved rate for all historical billing entries that have not yet been invoiced. Warn admins before saving such a change.
  • Currency assumptions — If you expand to multiple jurisdictions, store currency with the rate. Do not assume a single currency at the schema level.