Sunday, September 14, 2025

Enriching Property Data for Better Advertising and Compliance

Paul (Founder)
Development
Development team workspace showing collaborative property data enhancement work

Property portals like Rightmove don't just want basic information—they expect comprehensive data about every property feature tenants care about. Does it have a garden? What parking is available? What's the council tax band? How much deposit is required? Without this data, properties rank lower in search results and receive fewer enquiries.

Week 37 enhanced LetAdmin's property data model with fields that improve advertising effectiveness whilst ensuring legal compliance around deposit calculations. This article explores how we implemented these enhancements with automatic validation, real-time calculation, and portal synchronisation.

What Your Team Will Notice

The property editing interface now captures details that were previously tracked manually in spreadsheets or notes. When editing a property's advertising details, staff see dropdown menus for outside space ("Private garden", "Communal garden", "Patio/Terrace", "Balcony") and parking arrangements ("On-street", "Off-street allocated", "Off-street unallocated"). Council tax band selects from A through I with an optional "To Be Confirmed" during initial property setup.

Deposit management becomes automatic. Enter the monthly rent, and the system immediately calculates the maximum legal deposit following Tenant Fees Act 2019 regulations. Properties with annual rent under £50,000 allow five weeks' deposit; those above allow six weeks. The calculation happens in real-time as you type, with visual indicators showing whether your entered deposit complies with legal limits.

These fields populate automatically to Rightmove when properties sync, improving search visibility. Tenants filtering for "Properties with Gardens" or "Off-street Parking" now see your properties in results, driving more qualified enquiries without additional work.

Under the Bonnet: Deposit Calculation Compliance

The Tenant Fees Act 2019 caps security deposits at five or six weeks' rent depending on annual rent amount. Exceeding these limits creates legal liability for letting agencies. Our implementation ensures compliance automatically:

class Property
  # Deposit calculation following Tenant Fees Act 2019
  def calculate_max_legal_deposit
    return nil unless price.present?

    annual_rent = price * 12
    weeks = annual_rent <= 50_000 ? 5 : 6

    # Calculate weekly rent and multiply by allowed weeks
    weekly_rent = price * 12 / 52.0
    max_deposit = weekly_rent * weeks

    # Always round DOWN to ensure we stay within legal limits
    max_deposit.floor(2)
  end

  def deposit_compliant?
    return true unless deposit_required.present?
    deposit_required <= calculate_max_legal_deposit
  end
end

This calculation runs server-side for validation and client-side for real-time feedback. The JavaScript implementation matches the Ruby logic exactly:

function calculateMaxLegalDeposit(monthlyRent) {
  if (!monthlyRent || monthlyRent <= 0) return null;

  const annualRent = monthlyRent * 12;
  const weeks = annualRent <= 50000 ? 5 : 6;
  const weeklyRent = (monthlyRent * 12) / 52.0;
  const maxDeposit = weeklyRent * weeks;

  // Floor to 2 decimal places to match Ruby behaviour
  return Math.floor(maxDeposit * 100) / 100;
}

The Alpine.js reactive system updates deposit validation instantly as rent amounts change:

<div x-data="{
  monthlyRent: <%= @property.price %>,
  depositRequired: <%= @property.deposit_required %>,
  maxLegalDeposit() {
    return calculateMaxLegalDeposit(this.monthlyRent);
  },
  isCompliant() {
    return !this.depositRequired ||
           this.depositRequired <= this.maxLegalDeposit();
  }
}">
  <input type="number"
         x-model="monthlyRent"
         @input="depositRequired = maxLegalDeposit()" />

  <input type="number"
         x-model="depositRequired"
         :class="{ 'border-red-500': !isCompliant() }" />

  <p x-show="!isCompliant()" class="text-red-600">
    Maximum legal deposit: £<span x-text="maxLegalDeposit()"></span>
  </p>
</div>

This real-time validation prevents compliance issues before they occur, protecting agencies from regulatory penalties.

Outside Space Classification

Rightmove categorises outside space into specific types, each with numeric codes for API submission:

class Property
  OUTSIDE_SPACE_OPTIONS = [
    ['None', nil],
    ['Private garden/s', 'private_garden'],
    ['Communal garden/s', 'communal_garden'],
    ['Patio/Terrace', 'patio_terrace'],
    ['Balcony', 'balcony']
  ].freeze

  validates :outside_space,
            inclusion: { in: OUTSIDE_SPACE_OPTIONS.map(&:last) },
            allow_nil: true
end

The Rightmove serialiser maps these to Rightmove's expected format:

class RightmovePropertySerializer
  def build_outside_space
    return [] unless @property.outside_space.present?

    case @property.outside_space
    when 'private_garden'
      [33]  # Private Garden
    when 'communal_garden'
      [30]  # Communal Garden
    when 'patio_terrace'
      [36, 35]  # Patio and Terrace
    when 'balcony'
      [35]  # Terrace (closest Rightmove match)
    else
      []
    end
  end
end

This mapping ensures consistent categorisation across platforms. A property marked "Private garden" in LetAdmin displays as "Private Garden" on Rightmove, maintaining data integrity throughout the advertising workflow.

Parking Arrangements

Parking options are similarly detailed, reflecting the variety of parking situations in UK lettings:

class Property
  PARKING_OPTIONS = [
    ['None', nil],
    ['On-street parking', 'on_street'],
    ['Off-street parking', 'off_street'],
    ['Off-street parking (allocated)', 'off_street_allocated'],
    ['Off-street parking (unallocated)', 'off_street_unallocated']
  ].freeze

  validates :parking,
            inclusion: { in: PARKING_OPTIONS.map(&:last) },
            allow_nil: true
end

Rightmove parking codes require careful mapping to match their specific categorisation:

def build_parking
  return [] unless @property.parking.present?

  case @property.parking
  when 'on_street'
    [20]  # On Street
  when 'off_street'
    [19]  # Off Street
  when 'off_street_allocated'
    [13, 19]  # Allocated Parking, Off Street
  when 'off_street_unallocated'
    [19, 55]  # Off Street, Not Allocated
  when nil
    [56]  # No Parking Available
  else
    []
  end
end

The double codes for allocated and unallocated parking provide maximum search visibility—properties appear in both general "off-street parking" searches and more specific "allocated parking" searches.

Council Tax Banding

Council tax bands (A through I) help tenants estimate monthly living costs. The implementation is straightforward but requires validation:

class Property
  COUNCIL_TAX_BANDS = %w[A B C D E F G H I TBC].freeze

  validates :council_tax_band,
            inclusion: { in: COUNCIL_TAX_BANDS },
            allow_nil: true

  def council_tax_band_confirmed?
    council_tax_band.present? && council_tax_band != 'TBC'
  end
end

The Rightmove serialiser excludes unconfirmed bands to maintain data quality:

def build_council_tax
  return nil unless @property.council_tax_band_confirmed?
  @property.council_tax_band
end

This ensures only verified council tax information appears on property portals, avoiding confusion from placeholder values.

Responsive Form Layout

All new fields integrate into the property editing modal with a responsive three-column layout that adapts to screen size:

<!-- Desktop: 3 columns, Mobile: 1 column -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
  <!-- Deposit column -->
  <div>
    <label>Deposit Required</label>
    <input type="number"
           name="property[deposit_required]"
           x-model="depositRequired"
           step="0.01" />
    <p class="text-sm text-gray-600">
      Max legal: £<span x-text="maxLegalDeposit()"></span>
    </p>
  </div>

  <!-- Council tax column -->
  <div>
    <label>Council Tax Band</label>
    <select name="property[council_tax_band]">
      <option value="">Select...</option>
      <% Property::COUNCIL_TAX_BANDS.each do |band| %>
        <option value="<%= band %>"
                <%= 'selected' if @property.council_tax_band == band %>>
          <%= band %>
        </option>
      <% end %>
    </select>
  </div>

  <!-- Outside space column -->
  <div>
    <label>Outside Space</label>
    <select name="property[outside_space]">
      <option value="">None</option>
      <% Property::OUTSIDE_SPACE_OPTIONS.each do |label, value| %>
        <option value="<%= value %>"
                <%= 'selected' if @property.outside_space == value %>>
          <%= label %>
        </option>
      <% end %>
    </select>
  </div>
</div>

On mobile, fields stack vertically for comfortable thumb-friendly interaction. On desktop, the three-column layout makes efficient use of screen space whilst maintaining visual clarity.

Webhook Integration

All new fields trigger webhooks when updated, ensuring external systems stay synchronised:

class Property
  WEBHOOK_ATTRIBUTES = %w[
    reference headline description price beds bathrooms
    deposit_required council_tax_band outside_space parking
  ].freeze
end

When an agency updates a property's parking information, subscribed webhooks fire automatically. External systems like accounting software or agency websites receive these updates within seconds, maintaining consistent property data across platforms without manual synchronisation.

Testing Enhanced Data Models

Comprehensive tests verify both business logic and Rightmove integration:

RSpec.describe Property do
  describe "deposit calculation" do
    it "allows 5 weeks for annual rent ≤£50,000" do
      property = build(:property, price: 1000)  # £12k annual
      expect(property.calculate_max_legal_deposit).to eq(1153.84)
    end

    it "allows 6 weeks for annual rent >£50,000" do
      property = build(:property, price: 5000)  # £60k annual
      expect(property.calculate_max_legal_deposit).to eq(6923.07)
    end

    it "flags non-compliant deposits" do
      property = build(:property, price: 1000, deposit_required: 2000)
      expect(property).not_to be_deposit_compliant
    end
  end

  describe "outside space validation" do
    it "accepts valid outside space types" do
      property = build(:property, outside_space: 'private_garden')
      expect(property).to be_valid
    end

    it "rejects invalid outside space types" do
      property = build(:property, outside_space: 'swimming_pool')
      expect(property).not_to be_valid
    end
  end
end

RSpec.describe RightmovePropertySerializer do
  it "maps outside space to Rightmove codes correctly" do
    property = build(:property, outside_space: 'private_garden')
    serializer = described_class.new(property, 'BRANCH123')

    expect(serializer.build_outside_space).to eq([33])
  end

  it "excludes unconfirmed council tax bands" do
    property = build(:property, council_tax_band: 'TBC')
    serializer = described_class.new(property, 'BRANCH123')

    expect(serializer.build_council_tax).to be_nil
  end
end

These tests ensure legal compliance, data validation, and correct portal synchronisation—critical for avoiding regulatory issues and maintaining listing quality.

What's Next

These data model enhancements establish a foundation for increasingly sophisticated property advertising. Future enhancements might include EPC (Energy Performance Certificate) integration with automatic rating imports, furnished status tracking with detailed inventory specifications, and tenure information (assured shorthold tenancy details, break clauses, etc.).

The pattern established here—capture data once in LetAdmin, validate automatically, synchronise to portals seamlessly—extends naturally to additional property attributes as portal requirements evolve and tenant search behaviours change.

By enriching property data whilst ensuring legal compliance and maintaining data quality, these enhancements help letting agencies advertise properties more effectively, attract more qualified enquiries, and operate with confidence that their systems prevent regulatory missteps.