Thursday, November 6, 2025

Managing Utility Meter Readings for Property Handovers

Paul (Founder)
Development

Property handovers generate disputes when meter readings are unclear. Tenants claim they shouldn't pay for previous occupant's consumption, landlords question whether final bills reflect actual usage, and agencies lack evidence when challenged months later about who recorded which reading when. Paper slips recording readings vanish, photographs of meters live scattered across phones, and reconstructing accurate handover data becomes impossible.

Week 45 implemented comprehensive utility meter readings tracking digitally throughout property lifecycles. The system stores multiple meters per property (electricity, gas, water), records readings with timestamps and optional photo evidence, calculates consumption between readings automatically, and prepares for future smart meter API integration whilst working perfectly for manual readings today. This article explores the meter readings data model, reading capture workflow, consumption calculation logic, and photo evidence storage.

What Your Team Will Notice

Each property now displays a "Utility Meters" section showing all configured meters: "Electricity (Main)", "Gas (Combi Boiler)", "Water (Cold)". Each meter shows the latest reading with date, historical readings in chronological order, and calculated consumption between readings highlighting unusual spikes.

Recording new readings requires minimal input: click "Record Reading" beside the relevant meter, enter the reading value and date, optionally photograph the meter display using mobile camera, and submit. The system validates readings increment chronologically (preventing accidental entry of lower values), calculates consumption since last reading automatically, and displays the new reading instantly across all staff viewing the property.

Move-in and move-out readings get special treatment: flagging them as tenancy events associates readings with specific tenancies, providing clear evidence for deposit disputes or billing queries. When a tenant moves out, recording final readings automatically generates a consumption summary comparing move-in to move-out values showing total usage during occupancy.

Photo evidence provides crucial documentation: photographing meter displays creates timestamped visual proof of readings, photos attach to reading records appearing alongside numeric values, and zoom functionality enables inspecting meter serial numbers or tariff information clearly. This documentation proves invaluable when tenants dispute final bills claiming readings were inaccurate.

The meters interface shows consumption patterns at a glance: colour-coded indicators highlight unusual usage (consumption 50% above average shows amber, 100% above shows red), trend graphs display usage over time helping identify seasonal patterns or problems, and export functionality generates CSV reports for landlords or billing reconciliation.

Under the Bonnet: Meter Readings Data Model

The data model separates meter inventory from reading history:

# db/migrate/...create_utility_meters.rb
class CreateUtilityMeters < ActiveRecord::Migration[7.0]
  def change
    create_table :utility_meters do |t|
      t.references :property, null: false, foreign_key: true
      t.string :meter_type, null: false
      t.string :meter_label
      t.string :serial_number
      t.string :location
      t.string :unit, default: 'kWh'
      t.text :notes
      t.boolean :active, default: true

      t.timestamps
    end

    add_index :utility_meters, [:property_id, :meter_type]
    add_index :utility_meters, :serial_number
  end
end

# db/migrate/...create_meter_readings.rb
class CreateMeterReadings < ActiveRecord::Migration[7.0]
  def change
    create_table :meter_readings do |t|
      t.references :utility_meter, null: false, foreign_key: true
      t.references :tenancy, foreign_key: true
      t.decimal :reading_value, precision: 10, scale: 2, null: false
      t.date :reading_date, null: false
      t.string :reading_type
      t.references :recorded_by, null: false, foreign_key: { to_table: :users }
      t.text :notes
      t.string :photo_key

      t.timestamps
    end

    add_index :meter_readings, [:utility_meter_id, :reading_date]
    add_index :meter_readings, :reading_type
  end
end

This structure separates meters (which persist across tenancies) from readings (which create historical records), enabling accurate tracking of consumption patterns over long periods whilst associating specific readings with tenancies when relevant.

Utility Meter Model

The model handles business logic and consumption calculations:

# app/models/utility_meter.rb
class UtilityMeter < ApplicationRecord
  belongs_to :property
  has_many :meter_readings, dependent: :destroy
  has_one :latest_reading, -> { order(reading_date: :desc) }, class_name: 'MeterReading'

  validates :meter_type, presence: true, inclusion: {
    in: %w[electricity gas water],
    message: "%{value} is not a valid meter type"
  }
  validates :unit, presence: true

  scope :active, -> { where(active: true) }
  scope :by_type, ->(type) { where(meter_type: type) }

  after_create :broadcast_meter_added

  def display_name
    meter_label.presence || "#{meter_type.titleize} Meter"
  end

  def current_reading
    latest_reading&.reading_value
  end

  def reading_history(limit: 10)
    meter_readings.order(reading_date: :desc).limit(limit)
  end

  def consumption_between(start_date, end_date)
    readings = meter_readings
                 .where('reading_date >= ? AND reading_date <= ?', start_date, end_date)
                 .order(:reading_date)

    return nil if readings.count < 2

    first_reading = readings.first.reading_value
    last_reading = readings.last.reading_value

    {
      consumption: last_reading - first_reading,
      start_date: readings.first.reading_date,
      end_date: readings.last.reading_date,
      start_reading: first_reading,
      end_reading: last_reading,
      days: (readings.last.reading_date - readings.first.reading_date).to_i,
      unit: unit
    }
  end

  def average_daily_consumption(days: 90)
    consumption = consumption_between(days.days.ago.to_date, Date.current)
    return nil unless consumption

    (consumption[:consumption] / consumption[:days].to_f).round(2)
  end

  private

  def broadcast_meter_added
    ActionCable.server.broadcast(
      "property_#{property_id}_meters",
      {
        event: 'meter_added',
        meter: as_json(include: :latest_reading)
      }
    )
  end
end

This model provides consumption calculation utilities, reading history access, and automatic real-time broadcasting when meters are added to properties.

Meter Reading Model

The reading model enforces data integrity:

# app/models/meter_reading.rb
class MeterReading < ApplicationRecord
  belongs_to :utility_meter
  belongs_to :tenancy, optional: true
  belongs_to :recorded_by, class_name: 'User'

  has_one_attached :photo

  validates :reading_value, presence: true, numericality: { greater_than_or_equal_to: 0 }
  validates :reading_date, presence: true
  validate :reading_must_increase
  validate :reading_date_not_in_future

  after_create :broadcast_reading_added
  after_create :calculate_consumption

  scope :for_tenancy, ->(tenancy_id) { where(tenancy_id: tenancy_id) }
  scope :move_in, -> { where(reading_type: 'move_in') }
  scope :move_out, -> { where(reading_type: 'move_out') }

  def consumption_since_previous
    previous_reading = utility_meter
                        .meter_readings
                        .where('reading_date < ?', reading_date)
                        .order(reading_date: :desc)
                        .first

    return nil unless previous_reading

    {
      consumption: reading_value - previous_reading.reading_value,
      previous_reading: previous_reading.reading_value,
      current_reading: reading_value,
      days: (reading_date - previous_reading.reading_date).to_i,
      unit: utility_meter.unit
    }
  end

  def daily_consumption
    consumption = consumption_since_previous
    return nil unless consumption

    (consumption[:consumption] / consumption[:days].to_f).round(2)
  end

  def unusual_consumption?
    return false unless daily_consumption

    average = utility_meter.average_daily_consumption
    return false unless average

    daily_consumption > (average * 1.5)
  end

  private

  def reading_must_increase
    previous = utility_meter
                .meter_readings
                .where('reading_date <= ?', reading_date)
                .where.not(id: id)
                .order(reading_date: :desc)
                .first

    if previous && reading_value < previous.reading_value
      errors.add(:reading_value, "must be greater than previous reading (#{previous.reading_value})")
    end
  end

  def reading_date_not_in_future
    if reading_date && reading_date > Date.current
      errors.add(:reading_date, 'cannot be in the future')
    end
  end

  def broadcast_reading_added
    ActionCable.server.broadcast(
      "property_#{utility_meter.property_id}_meters",
      {
        event: 'reading_added',
        meter_id: utility_meter_id,
        reading: as_json(include: [:recorded_by])
      }
    )
  end

  def calculate_consumption
    # Trigger consumption recalculation for the meter
    utility_meter.touch
  end
end

This model validates readings logically (increasing values, not future-dated), calculates consumption automatically, and detects unusual usage patterns helping identify potential meter faults or tenant disputes early.

Reading Capture Workflow

The controller handles reading creation:

# app/controllers/meter_readings_controller.rb
class MeterReadingsController < ApplicationController
  before_action :set_utility_meter

  def create
    @reading = @utility_meter.meter_readings.build(reading_params)
    @reading.recorded_by = current_user

    if @reading.save
      respond_to do |format|
        format.html {
          redirect_to property_path(@utility_meter.property),
                     notice: 'Meter reading recorded successfully'
        }
        format.json {
          render json: @reading.as_json(
            include: {
              recorded_by: { only: [:id, :name] }
            },
            methods: [:consumption_since_previous, :daily_consumption]
          ), status: :created
        }
      end
    else
      respond_to do |format|
        format.html {
          redirect_to property_path(@utility_meter.property),
                     alert: "Failed to record reading: #{@reading.errors.full_messages.join(', ')}"
        }
        format.json {
          render json: { errors: @reading.errors.full_messages },
                 status: :unprocessable_entity
        }
      end
    end
  end

  def tenancy_summary
    @tenancy = Tenancy.find(params[:tenancy_id])
    @meters = @tenancy.property.utility_meters.active

    @summaries = @meters.map do |meter|
      move_in_reading = meter.meter_readings.for_tenancy(@tenancy).move_in.first
      move_out_reading = meter.meter_readings.for_tenancy(@tenancy).move_out.first

      {
        meter: meter,
        move_in: move_in_reading,
        move_out: move_out_reading,
        consumption: calculate_tenancy_consumption(move_in_reading, move_out_reading)
      }
    end

    render :tenancy_summary
  end

  private

  def set_utility_meter
    @utility_meter = UtilityMeter.find(params[:utility_meter_id])
  end

  def reading_params
    params.require(:meter_reading).permit(
      :reading_value,
      :reading_date,
      :reading_type,
      :tenancy_id,
      :notes,
      :photo
    )
  end

  def calculate_tenancy_consumption(move_in, move_out)
    return nil unless move_in && move_out

    {
      total: move_out.reading_value - move_in.reading_value,
      days: (move_out.reading_date - move_in.reading_date).to_i,
      daily_average: ((move_out.reading_value - move_in.reading_value) /
                      (move_out.reading_date - move_in.reading_date).to_f).round(2)
    }
  end
end

This controller handles both HTML and JSON responses enabling both traditional form submissions and API access for future mobile applications or smart meter integrations.

Photo Evidence Integration

Photo attachment uses ActiveStorage:

# app/models/meter_reading.rb (addition)
class MeterReading < ApplicationRecord
  has_one_attached :photo do |attachable|
    attachable.variant :thumb, resize_to_limit: [200, 200]
    attachable.variant :medium, resize_to_limit: [800, 800]
  end

  def photo_url(size: :medium)
    return nil unless photo.attached?

    Rails.application.routes.url_helpers.rails_blob_url(
      photo.variant(size),
      only_path: true
    )
  end
end

# app/views/meter_readings/_reading_card.html.erb
<div class="reading-card">
  <div class="reading-value">
    <strong><%= reading.reading_value %></strong>
    <span class="unit"><%= reading.utility_meter.unit %></span>
  </div>

  <div class="reading-meta">
    <span class="date"><%= l(reading.reading_date, format: :long) %></span>
    <span class="recorder">Recorded by <%= reading.recorded_by.name %></span>
  </div>

  <% if reading.consumption_since_previous %>
    <div class="consumption">
      <span class="label">Consumption since previous:</span>
      <span class="value <%= 'unusual' if reading.unusual_consumption? %>">
        <%= reading.consumption_since_previous[:consumption] %>
        <%= reading.utility_meter.unit %>
      </span>
      <span class="daily">
        (<%= reading.daily_consumption %> <%= reading.utility_meter.unit %>/day)
      </span>
    </div>
  <% end %>

  <% if reading.photo.attached? %>
    <div class="photo-evidence">
      <a href="<%= reading.photo_url(size: :original) %>"
         data-lightbox="meter-reading-<%= reading.id %>">
        <img src="<%= reading.photo_url(size: :thumb) %>"
             alt="Meter reading photo"
             class="thumbnail">
      </a>
    </div>
  <% end %>

  <% if reading.notes.present? %>
    <div class="notes">
      <%= simple_format(reading.notes) %>
    </div>
  <% end %>
</div>

This view shows comprehensive reading information: value with unit, consumption calculations, visual warnings for unusual usage, photo evidence with zoom, and notes for context.

Alpine.js Real-Time Updates

Client-side updates handle new readings:

// app/javascript/controllers/utility_meters_controller.js
import consumer from '../channels/consumer'

export function utilityMeters(propertyId) {
  return {
    meters: [],
    subscription: null,

    init() {
      this.loadMeters()
      this.subscribeToUpdates()
    },

    destroy() {
      if (this.subscription) {
        this.subscription.unsubscribe()
      }
    },

    async loadMeters() {
      const response = await fetch(`/properties/${propertyId}/utility_meters.json`)
      this.meters = await response.json()
    },

    subscribeToUpdates() {
      this.subscription = consumer.subscriptions.create(
        {
          channel: 'PropertyMetersChannel',
          property_id: propertyId
        },
        {
          received: (data) => {
            this.handleUpdate(data)
          }
        }
      )
    },

    handleUpdate(data) {
      switch (data.event) {
        case 'meter_added':
          this.addMeter(data.meter)
          break
        case 'reading_added':
          this.addReading(data.meter_id, data.reading)
          break
      }
    },

    addMeter(meterData) {
      this.meters.push(meterData)
    },

    addReading(meterId, readingData) {
      const meter = this.meters.find(m => m.id === meterId)
      if (meter) {
        if (!meter.readings) meter.readings = []
        meter.readings.unshift(readingData)
        meter.latest_reading = readingData
        this.flashUpdateIndicator(meter)
      }
    },

    flashUpdateIndicator(meter) {
      const element = this.$refs[`meter_${meter.id}`]
      if (element) {
        element.classList.add('flash-update')
        setTimeout(() => {
          element.classList.remove('flash-update')
        }, 1000)
      }
    },

    consumptionBadgeClass(reading) {
      if (!reading.unusual_consumption) return 'badge-success'
      if (reading.daily_consumption > reading.average * 2) return 'badge-danger'
      return 'badge-warning'
    }
  }
}

This component maintains reactive meter state, receives new readings via WebSocket, and highlights updates providing immediate feedback when readings are recorded elsewhere.

Testing Meter Readings

Testing verifies consumption calculations and validations:

RSpec.describe MeterReading do
  let(:meter) { create(:utility_meter) }
  let(:user) { create(:user) }

  describe 'validations' do
    it 'prevents readings lower than previous reading' do
      create(:meter_reading, utility_meter: meter, reading_value: 1000, reading_date: 1.week.ago)
      reading = build(:meter_reading, utility_meter: meter, reading_value: 500, reading_date: Date.current)

      expect(reading).not_to be_valid
      expect(reading.errors[:reading_value]).to include('must be greater than previous reading (1000)')
    end

    it 'prevents future-dated readings' do
      reading = build(:meter_reading, utility_meter: meter, reading_date: 1.week.from_now)

      expect(reading).not_to be_valid
      expect(reading.errors[:reading_date]).to include('cannot be in the future')
    end
  end

  describe '#consumption_since_previous' do
    it 'calculates consumption correctly' do
      create(:meter_reading, utility_meter: meter, reading_value: 1000, reading_date: 30.days.ago)
      reading = create(:meter_reading, utility_meter: meter, reading_value: 1500, reading_date: Date.current)

      consumption = reading.consumption_since_previous

      expect(consumption[:consumption]).to eq(500)
      expect(consumption[:days]).to eq(30)
    end
  end

  describe '#unusual_consumption?' do
    it 'detects consumption significantly above average' do
      # Create baseline readings
      30.times do |i|
        create(:meter_reading,
               utility_meter: meter,
               reading_value: 1000 + (i * 30),
               reading_date: (30 - i).days.ago)
      end

      # Create unusual reading
      reading = create(:meter_reading,
                      utility_meter: meter,
                      reading_value: meter.current_reading + 100,
                      reading_date: Date.current)

      expect(reading.unusual_consumption?).to be true
    end
  end
end

These tests verify validation logic prevents invalid readings, consumption calculations produce accurate results, and unusual usage detection flags anomalies appropriately.

What's Next

The meter readings foundation enables sophisticated features: smart meter API integration polling readings automatically eliminating manual recording, automated billing reconciliation comparing expected costs against actual consumption, predictive analytics forecasting future usage helping agencies advise tenants on expected bills, and carbon footprint tracking converting consumption to environmental impact metrics.

Future enhancements might include tariff tracking storing unit costs alongside readings for accurate billing estimates, export to accounting systems generating invoices automatically based on consumption, consumption benchmarking comparing properties showing which use utilities efficiently, and tenant portals allowing residents to view their consumption patterns encouraging conservation.

By implementing comprehensive meter readings tracking with photo evidence and consumption analysis, LetAdmin provides letting agencies with accurate handover documentation, dispute prevention through timestamped evidence, and visibility into property utility usage patterns for operational insights and tenant support.