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.