diff --git a/estate/__init__.py b/estate/__init__.py
new file mode 100644
index 00000000000..d6210b1285d
--- /dev/null
+++ b/estate/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/estate/__manifest__.py b/estate/__manifest__.py
new file mode 100644
index 00000000000..6e4d64ac3be
--- /dev/null
+++ b/estate/__manifest__.py
@@ -0,0 +1,25 @@
+{
+ "name": "Real Estate",
+ "category": "Real Estate/Brokerage",
+ "summary": "Manage real estate properties, offers, and sales with ease",
+ "description": "A demo application for managing real estate listings, tracking offers, and streamlining property sales workflows. Designed to showcase essential features for real estate and brokerage operations within Odoo",
+ "installable": True,
+ "depends": ["base"],
+ "application": True,
+ "license": "AGPL-3",
+ "data": [
+ "security/estate_security.xml",
+ "security/ir.model.access.csv",
+ "views/estate_property_offer_views.xml",
+ "views/estate_property_views.xml",
+ "views/estate_settings_views.xml",
+ "views/estate_property_tags_views.xml",
+ "views/res_users_views.xml",
+ "views/estate_menus.xml",
+ "data/estate.property.type.csv",
+ ],
+ "demo": [
+ "demo/estate_property_demo.xml",
+ "demo/estate_offer_demo.xml",
+ ],
+}
diff --git a/estate/data/estate.property.type.csv b/estate/data/estate.property.type.csv
new file mode 100644
index 00000000000..6069981e33c
--- /dev/null
+++ b/estate/data/estate.property.type.csv
@@ -0,0 +1,5 @@
+id,name
+property_type_residential,Residential
+property_type_commercial,Commercial
+property_type_industrial,Industrial
+property_type_land,Land
diff --git a/estate/demo/estate_offer_demo.xml b/estate/demo/estate_offer_demo.xml
new file mode 100644
index 00000000000..48772b2a25d
--- /dev/null
+++ b/estate/demo/estate_offer_demo.xml
@@ -0,0 +1,43 @@
+
+
+
+
+ 1500000
+ 14
+
+
+
+
+
+
+
+ 1500001
+ 14
+
+
+
+
+
+
+
+ 1500002
+ 14
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/demo/estate_property_demo.xml b/estate/demo/estate_property_demo.xml
new file mode 100644
index 00000000000..cd241795d51
--- /dev/null
+++ b/estate/demo/estate_property_demo.xml
@@ -0,0 +1,114 @@
+
+
+
+ Big Villa
+ new
+ A nice and big villa
+ 12345
+ 2020-02-02
+ 1600000
+ 0
+ 6
+ 100
+ 4
+ true
+ true
+ 100000
+ south
+
+
+
+
+ Trailer home
+ cancelled
+ Home in a trailer park
+ 54321
+ 1970-01-01
+ 100000
+ 120000
+ 1
+ 10
+ 4
+ false
+ false
+ 0
+
+
+
+
+ Small House
+ new
+ A compact house perfect for small families.
+ 98765
+
+ 250000
+ 0
+ 3
+ 80
+ 2
+ true
+ true
+ 50
+ east
+
+
+
+
+
+ 250000
+ 30
+
+
+
+
+ 280000
+ 15
+
+
+
+
+
+
+ Very Small House
+ new
+ A nice and small villa
+ 12345
+
+ 1600000
+ 0
+ 6
+ 100
+ 4
+ true
+ true
+ 100000
+ south
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/models/__init__.py b/estate/models/__init__.py
new file mode 100644
index 00000000000..35928a71f7e
--- /dev/null
+++ b/estate/models/__init__.py
@@ -0,0 +1,7 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import estate_property
+from . import estate_property_type
+from . import estate_property_tag
+from . import estate_property_offer
+from . import inherited_model
diff --git a/estate/models/estate_property.py b/estate/models/estate_property.py
new file mode 100644
index 00000000000..afc841fa355
--- /dev/null
+++ b/estate/models/estate_property.py
@@ -0,0 +1,129 @@
+from dateutil.relativedelta import relativedelta
+from odoo import fields, models, api, _
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools.float_utils import float_compare, float_is_zero
+
+
+class EstateProperty(models.Model):
+ _name = "estate.property"
+ _description = "estate property revenue plans"
+ _order = "id desc"
+
+ name = fields.Char(required=True)
+ description = fields.Text(string="Description")
+ postcode = fields.Char(string="Postcode")
+ date_availability = fields.Date(
+ string="Available From",
+ copy=False,
+ default=lambda self: fields.Date.today() + relativedelta(months=3),
+ )
+ expected_price = fields.Float(string="Expected Price", required=True)
+ selling_price = fields.Float(string="Selling Price", readonly=True, copy=False)
+ bedrooms = fields.Integer(string="Bedrooms", default=2)
+ living_area = fields.Integer(string="Living Area (sqm)")
+ facades = fields.Integer(string="Number of Facades")
+ garage = fields.Boolean(string="Garage")
+ garden = fields.Boolean(string="Garden")
+ garden_area = fields.Integer(string="Garden Area (sqm)")
+ garden_orientation = fields.Selection(
+ selection=[
+ ("north", "North"),
+ ("south", "South"),
+ ("east", "East"),
+ ("west", "West"),
+ ],
+ string="Garden Orientation",
+ help="Orientation of the garden relative to the property",
+ )
+ active = fields.Boolean(default=True)
+ state = fields.Selection(
+ selection=[
+ ("new", "NEW"),
+ ("offer_received", "Offer Received"),
+ ("offer_accepted", "Offer Accepted"),
+ ("sold", "Sold"),
+ ("cancelled", "Cancelled"),
+ ],
+ string="Status",
+ required=True,
+ default="new",
+ copy=False,
+ )
+
+ total_area = fields.Integer(string="Total Area", compute="_compute_total_area")
+ best_price = fields.Float(string="Best Offer", compute="_compute_best_price")
+ property_type_id = fields.Many2one("estate.property.type", string="Property Type")
+ buyer_id = fields.Many2one("res.partner", string="Buyer", copy=False)
+ user_id = fields.Many2one(
+ "res.users", string="Salesman", copy=False, default=lambda self: self.env.user
+ )
+ tag_id = fields.Many2many("estate.property.tag", string="Tags", copy=False)
+ offer_id = fields.One2many("estate.property.offer", "property_id", string="Offer")
+ company_id = fields.Many2one("res.company", string="Company", required=True, default=lambda self: self.env.company)
+
+ _sql_constraints = [
+ (
+ "check_expected_price",
+ "CHECK(expected_price > 0)",
+ "The Expected price of a property should be strictly positive.",
+ ),
+ (
+ "check_selling_price",
+ "CHECK(selling_price IS NULL OR selling_price >= 0)",
+ "Selling price must be positive when set.",
+ ),
+ ]
+
+ @api.depends("living_area", "garden_area")
+ def _compute_total_area(self):
+ for area in self:
+ area.total_area = area.living_area + area.garden_area
+
+ @api.depends("offer_id.price")
+ def _compute_best_price(self):
+ for record in self:
+ # Get all prices from offer_ids
+ offer_prices = record.offer_id.mapped("price")
+ record.best_price = max(offer_prices) if offer_prices else 0.0
+
+ @api.onchange("garden")
+ def _onchange_garden(self):
+ if self.garden:
+ self.garden_orientation = "north"
+ self.garden_area = 10
+ else:
+ self.garden_orientation = False
+ self.garden_area = 0
+
+ def action_property_sold(self):
+ for prop in self:
+ if prop.state == "cancelled":
+ raise UserError(_("Cancelled properties cannot be sold."))
+ prop.state = "sold"
+ return True
+
+ def action_property_cancel(self):
+ for prop in self:
+ if prop.state == "sold":
+ raise UserError(_("Sold properties cannot be cancelled."))
+ prop.state = "cancelled"
+ return True
+
+ @api.constrains("selling_price", "expected_price")
+ def _check_selling_price(self):
+ for record in self:
+ if float_is_zero(record.selling_price, precision_digits=2):
+ continue
+ min_allowed_price = record.expected_price * 0.9
+
+ if (float_compare(record.selling_price, min_allowed_price, precision_digits=2) < 0):
+ raise ValidationError(
+ "Selling price cannot be lower than 90% of the expected price. "
+ f"(Minimum allowed: {min_allowed_price:.2f})"
+ )
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_except_state_not_new(self):
+ for rec in self:
+ if rec.state not in ["new", "cancelled"]:
+ raise UserError(_("You cannot delete a property unless its state is 'New' or 'Cancelled'."))
diff --git a/estate/models/estate_property_offer.py b/estate/models/estate_property_offer.py
new file mode 100644
index 00000000000..26bfacd298b
--- /dev/null
+++ b/estate/models/estate_property_offer.py
@@ -0,0 +1,98 @@
+from odoo import fields, models, api, exceptions, _
+from dateutil.relativedelta import relativedelta
+from odoo.exceptions import UserError
+
+
+class EstatePropertyOffer(models.Model):
+ _name = "estate.property.offer"
+ _description = "Offers of Estate property "
+ _order = "price desc"
+
+ price = fields.Float(string="Price")
+ status = fields.Selection(
+ selection=[("accepted", "Accepted"), ("refused", "Refused")],
+ copy=False,
+ string="Status",
+ )
+
+ validity = fields.Integer(string="Validity", default=7)
+ date_deadline = fields.Date(
+ string="Deadline",
+ compute="_compute_deadline_date",
+ inverse="_inverse_deadline_date",
+ )
+
+ partner_id = fields.Many2one(
+ "res.partner", string="Partner", copy=False, required=True, store=True
+ )
+ property_id = fields.Many2one("estate.property", copy=False, required=True)
+ property_type_id = fields.Many2one(
+ "estate.property.type",
+ related="property_id.property_type_id",
+ store=True,
+ readonly=False,
+ )
+ _sql_constraints = [
+ (
+ "check_offer_price",
+ "CHECK(price > 0)",
+ "The Offer price must be strictly positive",
+ )
+ ]
+
+ @api.depends("create_date", "validity")
+ def _compute_deadline_date(self):
+ for offer in self:
+ create_dt = offer.create_date or fields.Date.today()
+ offer.date_deadline = create_dt + relativedelta(days=offer.validity)
+
+ def _inverse_deadline_date(self):
+ for offer in self:
+ if offer.date_deadline:
+ create_dt = (offer.create_date or fields.Datetime.now()).date()
+ delta = (offer.date_deadline - create_dt).days
+ offer.validity = delta
+ else:
+ offer.validity = 0
+
+ def action_accept(self):
+ for offer in self:
+ # Allow only if property has no accepted offer
+ if offer.property_id.buyer_id:
+ raise UserError(
+ _("An offer has already been accepted for this property.")
+ )
+ # Set buyer and selling price
+ offer.property_id.buyer_id = offer.partner_id
+ offer.property_id.selling_price = offer.price
+ offer.status = "accepted"
+ offer.property_id.state = "offer_accepted"
+ # Setting remaining Offer as refused
+ other_offers = offer.property_id.offer_id - offer
+ other_offers.write({"status": "refused"})
+
+ return True
+
+ def action_refused(self):
+ for offer in self:
+ offer.status = "refused"
+ return True
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ new_records = []
+ for vals in vals_list:
+ property_id = vals.get("property_id")
+
+ property_rec = self.env["estate.property"].browse(property_id)
+ if property_rec.state == "new":
+ property_rec.state = "offer_received"
+
+ if "price" in vals and vals["price"] < property_rec.best_price:
+ raise exceptions.ValidationError(
+ "Offer price must be higher than existing offers."
+ )
+
+ new_records.append(vals)
+
+ return super().create(new_records)
diff --git a/estate/models/estate_property_tag.py b/estate/models/estate_property_tag.py
new file mode 100644
index 00000000000..09db9eec7f7
--- /dev/null
+++ b/estate/models/estate_property_tag.py
@@ -0,0 +1,18 @@
+from odoo import fields, models
+
+
+class EstatePropertyTag(models.Model):
+ _name = "estate.property.tag"
+ _description = "Tags of Estate property "
+ _order = "name asc"
+
+ name = fields.Char(required=True, string="Name")
+ color = fields.Integer(default=3)
+
+ _sql_constraints = [
+ (
+ "unique_property_tag_name",
+ "UNIQUE(name)",
+ "A property tag name must be unique.",
+ ),
+ ]
diff --git a/estate/models/estate_property_type.py b/estate/models/estate_property_type.py
new file mode 100644
index 00000000000..b1a0d6e4e24
--- /dev/null
+++ b/estate/models/estate_property_type.py
@@ -0,0 +1,33 @@
+from odoo import fields, models, api
+
+
+class EstatePropertyType(models.Model):
+ _name = "estate.property.type"
+ _description = "Types of Estate property "
+ _order = "sequence, name asc" # Order by sequence first
+
+ name = fields.Char(required=True)
+ sequence = fields.Integer(
+ "Sequence", default=10, help="Used to order property types."
+ )
+
+ property_ids = fields.One2many("estate.property", "property_type_id")
+ offer_ids = fields.One2many(
+ "estate.property.offer", "property_type_id", string="Offers"
+ )
+ offer_count = fields.Integer(
+ string="Offer Count", compute="_compute_offer_count", store=True
+ )
+
+ _sql_constraints = [
+ (
+ "unique_property_type_name",
+ "UNIQUE(name)",
+ "A property type name must be unique.",
+ ),
+ ]
+
+ @api.depends("offer_ids")
+ def _compute_offer_count(self):
+ for rec in self:
+ rec.offer_count = len(rec.offer_ids)
diff --git a/estate/models/inherited_model.py b/estate/models/inherited_model.py
new file mode 100644
index 00000000000..e7460290d2c
--- /dev/null
+++ b/estate/models/inherited_model.py
@@ -0,0 +1,12 @@
+from odoo import models, fields
+
+
+class ResUsers(models.Model):
+ _inherit = "res.users"
+
+ property_ids = fields.One2many(
+ comodel_name="estate.property",
+ inverse_name="user_id",
+ string="Properties",
+ domain=[("state", "in", ["new", "offer_received"])],
+ )
diff --git a/estate/security/estate_security.xml b/estate/security/estate_security.xml
new file mode 100644
index 00000000000..88955319c3b
--- /dev/null
+++ b/estate/security/estate_security.xml
@@ -0,0 +1,38 @@
+
+
+
+ Agent
+
+
+
+
+ Manager
+
+
+
+
+
+
+ Estate: Agent see own or unassigned properties
+
+ ['|', ('user_id', '=', False), ('user_id', '=', user.id)]
+
+
+
+
+ Estate: Manager see all properties
+
+
+
+
+
+ Estate Property: Multi-company
+
+ ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]
+
+
+
+
+
+
+
diff --git a/estate/security/ir.model.access.csv b/estate/security/ir.model.access.csv
new file mode 100644
index 00000000000..beaea9ae523
--- /dev/null
+++ b/estate/security/ir.model.access.csv
@@ -0,0 +1,8 @@
+id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
+estate_access_property_manager,Estate Manager: property,model_estate_property,estate.estate_group_manager,1,1,1,1
+estate_access_property_agent,Estate Agent: property,model_estate_property,estate.estate_group_user,1,1,1,0
+estate_access_type_manager,Estate Manager: type,model_estate_property_type,estate.estate_group_manager,1,1,1,1
+estate_access_type_agent,Estate Agent: type,model_estate_property_type,estate.estate_group_user,1,0,0,0
+estate_access_tag_manager,Estate Manager: tag,model_estate_property_tag,estate.estate_group_manager,1,1,1,1
+estate_access_tag_agent,Estate Agent: tag,model_estate_property_tag,estate.estate_group_user,1,0,0,0
+access_estate_model_offer,estate.property.offer,model_estate_property_offer,base.group_user,1,1,1,1
diff --git a/estate/views/estate_menus.xml b/estate/views/estate_menus.xml
new file mode 100644
index 00000000000..285e43cb8d0
--- /dev/null
+++ b/estate/views/estate_menus.xml
@@ -0,0 +1,25 @@
+
+
+
diff --git a/estate/views/estate_property_offer_views.xml b/estate/views/estate_property_offer_views.xml
new file mode 100644
index 00000000000..9da71bcc15d
--- /dev/null
+++ b/estate/views/estate_property_offer_views.xml
@@ -0,0 +1,24 @@
+
+
+ Offers
+ estate.property.offer
+ list
+ [('property_type_id', '=', active_id)]
+
+
+
+
+ estate.property.offer.list
+ estate.property.offer
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/views/estate_property_tags_views.xml b/estate/views/estate_property_tags_views.xml
new file mode 100644
index 00000000000..da6830b2fc2
--- /dev/null
+++ b/estate/views/estate_property_tags_views.xml
@@ -0,0 +1,24 @@
+
+
+ Property Tag
+ estate.property.tag
+ list,form
+
+
+
+ estate.property.tag.form
+ estate.property.tag
+
+
+
+
+
+
diff --git a/estate/views/estate_property_views.xml b/estate/views/estate_property_views.xml
new file mode 100644
index 00000000000..7b48d35f19f
--- /dev/null
+++ b/estate/views/estate_property_views.xml
@@ -0,0 +1,135 @@
+
+
+ Properties
+ estate.property
+ list,form
+ {'search_default_filter_available': 1}
+
+
+
+
+ estate.property.list
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ estate.property.form
+ estate.property
+
+
+
+
+
+
+ estate.property.search
+ estate.property
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate/views/estate_settings_views.xml b/estate/views/estate_settings_views.xml
new file mode 100644
index 00000000000..6f9faca2fd4
--- /dev/null
+++ b/estate/views/estate_settings_views.xml
@@ -0,0 +1,58 @@
+
+
+
+ Property Types
+ estate.property.type
+ list,form
+
+
+
+ estate.property.type.list
+ estate.property.type
+
+
+
+
+
+
+
+
+
+ estate.property.type.form
+ estate.property.type
+
+
+
+
+
diff --git a/estate/views/res_users_views.xml b/estate/views/res_users_views.xml
new file mode 100644
index 00000000000..d62fd6a1315
--- /dev/null
+++ b/estate/views/res_users_views.xml
@@ -0,0 +1,15 @@
+
+
+ res.users.form.inherit.estate
+ res.users
+
+
+
+
+
+
+
+
+
+
+
diff --git a/estate_account/__init__.py b/estate_account/__init__.py
new file mode 100644
index 00000000000..d6210b1285d
--- /dev/null
+++ b/estate_account/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/estate_account/__manifest__.py b/estate_account/__manifest__.py
new file mode 100644
index 00000000000..a5edaebf1fc
--- /dev/null
+++ b/estate_account/__manifest__.py
@@ -0,0 +1,9 @@
+{
+ "name": "Estate Account",
+ "summary": "Integrate real estate operations with accounting",
+ "description": "Extends the Real Estate app to handle financial operations. Automates invoicing, tracks commissions, and links property sales with accounting workflows for seamless integration",
+ "depends": ["base", "estate", "account"],
+ "category": "Tutorials",
+ "installable": True,
+ "license": "AGPL-3",
+}
diff --git a/estate_account/models/__init__.py b/estate_account/models/__init__.py
new file mode 100644
index 00000000000..09b94f90f8d
--- /dev/null
+++ b/estate_account/models/__init__.py
@@ -0,0 +1,3 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import estate_property
diff --git a/estate_account/models/estate_property.py b/estate_account/models/estate_property.py
new file mode 100644
index 00000000000..3a1c611cdf2
--- /dev/null
+++ b/estate_account/models/estate_property.py
@@ -0,0 +1,51 @@
+from odoo import models, Command
+from odoo.exceptions import UserError, AccessError
+
+
+class EstateProperty(models.Model):
+ _inherit = "estate.property"
+
+ def action_property_sold(self):
+ res = super().action_property_sold()
+ journal = self.env["account.journal"].sudo().search(
+ [("type", "=", "sale")], limit=1
+ )
+
+ if not journal:
+ raise UserError("No sales journal found!")
+
+ for property in self:
+ if not property.buyer_id:
+ continue
+
+ commission = property.selling_price * 0.06
+ admin_fee = 100.00
+ try:
+ property.check_access('write')
+ except AccessError as e:
+ raise UserError(f"You don't have permission to modify {property.name}") from e
+
+ self.env["account.move"].sudo().create({
+ "partner_id": property.buyer_id.id,
+ "move_type": "out_invoice",
+ "company_id": property.company_id.id,
+ "journal_id": journal.id,
+ "invoice_line_ids": [
+ Command.create(
+ {
+ "name": "Commission (6%)",
+ "quantity": 1,
+ "price_unit": commission,
+ }
+ ),
+ Command.create(
+ {
+ "name": "Administrative fees",
+ "quantity": 1,
+ "price_unit": admin_fee,
+ }
+ ),
+ ],
+ })
+
+ return res