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.
