Thursday, October 2, 2025

Managing Joint Property Ownership with Percentage Tracking

Paul (Founder)
Development
Developer working through complex ownership logic and percentage calculations at kitchen table

Many properties have multiple owners: married couples holding property jointly, siblings who inherited family homes, business partners investing in rental portfolios, or families pooling resources for property purchases. Each ownership structure has unique requirements for tracking ownership shares, distributing rental income, and managing decision-making authority.

Week 40 implemented comprehensive joint ownership management in LetAdmin, moving beyond the simple "one property, one landlord" model to support complex ownership structures with percentage tracking and flexible ownership assignments. This article explores how we built this system whilst maintaining interface simplicity and data integrity.

What Your Team Will Notice

The property detail page now displays all owners when properties have joint ownership, showing each landlord's name alongside their ownership percentage: "Jane Smith (50%), John Smith (50%)" for equal partners, or "Jane Smith (60%), John Smith (30%), James Smith (10%)" for unequal splits.

A dedicated "Manage Ownership" modal centralizes ownership editing. Click the button, and a dynamic interface appears allowing you to add or remove landlords, adjust ownership percentages with real-time validation, and see instant feedback when totals don't equal 100%. The interface prevents saving invalid ownership structures, ensuring data integrity without requiring manual percentage calculations.

For properties with sole ownership, the interface remains simple—one landlord, implicitly 100% ownership, no percentage displays cluttering the UI. The system adapts presentation based on ownership complexity, showing detail only when necessary.

When viewing a landlord's profile, all properties they own appear with ownership percentages listed. Staff can immediately see which properties are wholly owned versus jointly held, helping prioritize communications and understand each landlord's portfolio structure.

Under the Bonnet: Ownership Data Model

Joint ownership requires a junction table connecting properties and landlords with additional ownership percentage data:

class PropertyOwnership < ApplicationRecord
  belongs_to :property
  belongs_to :landlord
  belongs_to :agency
  acts_as_tenant :agency

  validates :ownership_percentage,
            presence: true,
            numericality: {
              greater_than: 0,
              less_than_or_equal_to: 100,
              only_integer: true
            }

  # Validation at model level
  validate :total_ownership_equals_100

  private

  def total_ownership_equals_100
    return unless property.present?

    total = property.property_ownerships
                   .where.not(id: id) # Exclude self when updating
                   .sum(:ownership_percentage)
    total += ownership_percentage.to_i

    unless total == 100
      errors.add(:ownership_percentage,
                "total ownership must equal 100% (currently #{total}%)")
    end
  end
end

This model enforces several critical business rules: percentages must be positive integers (no decimals complicating calculations), percentages can't exceed 100%, and total ownership across all records for a property must equal exactly 100%.

Property Associations

The Property model gains rich ownership querying capabilities:

class Property
  has_many :property_ownerships, dependent: :destroy
  has_many :landlords, through: :property_ownerships

  # Primary landlord for compatibility
  # (highest percentage owner, or first alphabetically if tied)
  def primary_landlord
    property_ownerships.order(ownership_percentage: :desc, created_at: :asc)
                      .first
                      &.landlord
  end

  def jointly_owned?
    property_ownerships.count > 1
  end

  def sole_owned?
    property_ownerships.count == 1
  end

  # Formatted ownership string for display
  def ownership_display
    return primary_landlord.full_name if sole_owned?

    property_ownerships
      .includes(:landlord)
      .order(ownership_percentage: :desc)
      .map { |po| "#{po.landlord.full_name} (#{po.ownership_percentage}%)" }
      .join(', ')
  end
end

These methods provide convenient access patterns throughout the application, from list views to reports to API responses.

Dynamic Ownership Management Interface

The ownership modal uses Alpine.js for reactive percentage validation:

<div x-data="ownershipManager()" x-init="initializeOwnership()">
  <h2>Manage Property Ownership</h2>

  <template x-for="(owner, index) in owners" :key="index">
    <div class="owner-row">
      <!-- Landlord selection -->
      <select x-model="owner.landlord_id" @change="recalculateTotal()">
        <option value="">Select landlord...</option>
        <% @landlords.each do |landlord| %>
          <option value="<%= landlord.id %>"><%= landlord.full_name %></option>
        <% end %>
      </select>

      <!-- Ownership percentage -->
      <input type="number"
             x-model.number="owner.percentage"
             @input="recalculateTotal()"
             min="1" max="100" step="1"
             placeholder="%" />

      <!-- Remove button (if more than one owner) -->
      <button x-show="owners.length > 1"
              @click="removeOwner(index)"
              type="button">
        Remove
      </button>
    </div>
  </template>

  <!-- Add another owner -->
  <button @click="addOwner()" type="button">
    Add Another Owner
  </button>

  <!-- Total validation display -->
  <div class="ownership-total" :class="{ 'valid': isValid, 'invalid': !isValid }">
    <span>Total Ownership:</span>
    <span x-text="totalPercentage + '%'"></span>
    <span x-show="!isValid" class="error-message">
      Must equal 100%
    </span>
  </div>

  <!-- Save button (disabled if invalid) -->
  <button @click="saveOwnership()"
          :disabled="!isValid || !allLandlordsSelected()"
          class="btn btn-primary">
    Save Ownership
  </button>
</div>

<script>
function ownershipManager() {
  return {
    owners: [],
    totalPercentage: 0,

    initializeOwnership() {
      // Load existing ownership from property
      const existingOwnership = <%= raw @property.property_ownerships.to_json(
        include: { landlord: { only: [:id, :full_name] } }
      ) %>;

      if (existingOwnership.length > 0) {
        this.owners = existingOwnership.map(po => ({
          landlord_id: po.landlord_id,
          percentage: Math.round(po.ownership_percentage) // Whole numbers
        }));
      } else {
        // Default: single owner at 100%
        this.owners = [{ landlord_id: null, percentage: 100 }];
      }

      this.recalculateTotal();
    },

    addOwner() {
      // Calculate remaining percentage for new owner
      const remaining = 100 - this.totalPercentage;
      this.owners.push({
        landlord_id: null,
        percentage: Math.max(1, remaining)
      });
      this.recalculateTotal();
    },

    removeOwner(index) {
      this.owners.splice(index, 1);
      this.recalculateTotal();
    },

    recalculateTotal() {
      this.totalPercentage = this.owners.reduce((sum, owner) => {
        return sum + (owner.percentage || 0);
      }, 0);
    },

    get isValid() {
      return this.totalPercentage === 100;
    },

    allLandlordsSelected() {
      return this.owners.every(owner => owner.landlord_id !== null);
    },

    saveOwnership() {
      if (!this.isValid || !this.allLandlordsSelected()) return;

      // POST to ownership endpoint
      fetch('<%= property_ownerships_path(@property) %>', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
        },
        body: JSON.stringify({ ownerships: this.owners })
      }).then(response => {
        if (response.ok) {
          window.location.reload();
        }
      });
    }
  };
}
</script>

This interface provides immediate feedback as staff adjust percentages, preventing submission of invalid data whilst remaining flexible for various ownership structures.

Backend Ownership Replacement

The controller endpoint handling ownership updates uses a replacement strategy rather than incremental updates:

class PropertiesController
  def add_landlord_ownership
    @property = Property.find(params[:id])
    ownerships_params = params[:ownerships]

    ActiveRecord::Base.transaction do
      # Remove existing ownership records
      @property.property_ownerships.destroy_all

      # Create new ownership records from submitted data
      ownerships_params.each do |ownership_data|
        @property.property_ownerships.create!(
          landlord_id: ownership_data[:landlord_id],
          ownership_percentage: ownership_data[:percentage],
          agency: current_tenant
        )
      end
    end

    redirect_to @property, notice: 'Ownership updated successfully'
  rescue ActiveRecord::RecordInvalid => e
    redirect_to @property, alert: "Error updating ownership: #{e.message}"
  end
end

The replacement approach (delete all, then create) simplifies logic compared to diffing changes and applying updates. Since ownership changes are infrequent operations, the performance difference is negligible.

Handling Sole vs. Joint Ownership Display

The property card adapts based on ownership structure:

<div class="property-card-section">
  <% if @property.jointly_owned? %>
    <h3>Landlords</h3>
    <ul class="landlord-list">
      <% @property.property_ownerships.includes(:landlord).each do |ownership| %>
        <li>
          <%= link_to ownership.landlord.full_name, landlord_path(ownership.landlord) %>
          <span class="ownership-percentage">
            (<%= ownership.ownership_percentage %>%)
          </span>
        </li>
      <% end %>
    </ul>
  <% else %>
    <h3>Landlord</h3>
    <p>
      <%= link_to @property.primary_landlord.full_name,
                  landlord_path(@property.primary_landlord) %>
    </p>
  <% end %>

  <%= link_to 'Manage Ownership',
              '#',
              data: { action: 'click->modal#open',
                      modal_target: 'ownership' },
              class: 'btn btn-sm btn-outline' %>
</div>

This conditional rendering keeps interfaces clean: sole ownership shows simple "Landlord" heading without percentages; joint ownership shows "Landlords" with detailed percentage breakdown.

Testing Ownership Validation

Comprehensive tests verify ownership business rules:

RSpec.describe PropertyOwnership do
  describe "validations" do
    it "requires ownership percentage" do
      ownership = build(:property_ownership, ownership_percentage: nil)
      expect(ownership).not_to be_valid
    end

    it "requires percentage to be positive integer" do
      ownership = build(:property_ownership, ownership_percentage: 0)
      expect(ownership).not_to be_valid

      ownership.ownership_percentage = 50.5
      expect(ownership).not_to be_valid
    end

    it "prevents ownership exceeding 100%" do
      property = create(:property)
      create(:property_ownership, property: property, ownership_percentage: 60)

      ownership = build(:property_ownership,
                       property: property,
                       ownership_percentage: 50) # Total would be 110%

      expect(ownership).not_to be_valid
      expect(ownership.errors[:ownership_percentage]).to include(/must equal 100%/)
    end

    it "allows exactly 100% total ownership" do
      property = create(:property)
      create(:property_ownership, property: property, ownership_percentage: 60)

      ownership = build(:property_ownership,
                       property: property,
                       ownership_percentage: 40)

      expect(ownership).to be_valid
    end
  end

  describe "property associations" do
    it "identifies jointly owned properties" do
      property = create(:property)
      create(:property_ownership, property: property, ownership_percentage: 50)
      create(:property_ownership, property: property, ownership_percentage: 50)

      expect(property.jointly_owned?).to be true
      expect(property.sole_owned?).to be false
    end

    it "formats ownership display correctly" do
      property = create(:property)
      landlord1 = create(:landlord, first_name: 'Jane', last_name: 'Smith')
      landlord2 = create(:landlord, first_name: 'John', last_name: 'Smith')

      create(:property_ownership,
            property: property,
            landlord: landlord1,
            ownership_percentage: 60)
      create(:property_ownership,
            property: property,
            landlord: landlord2,
            ownership_percentage: 40)

      expect(property.ownership_display).to eq('Jane Smith (60%), John Smith (40%)')
    end
  end
end

These tests ensure ownership logic remains correct as the system evolves.

What's Next

The ownership foundation enables sophisticated features: rental income distribution calculating each owner's share automatically, ownership transfer workflows for property sales or inheritance situations, and ownership history tracking showing how ownership evolved over time.

Future enhancements might include fractional ownership support (for property syndicates owning small percentages), beneficial ownership tracking (distinguishing legal owners from beneficial owners for compliance), and ownership document management (storing title deeds and ownership agreements digitally).

By modeling joint ownership explicitly with percentage tracking and validation, LetAdmin accurately represents complex ownership structures common in UK property letting, enabling agencies to manage landlord relationships effectively whilst maintaining data integrity and clarity.