---
title: "Qejaverse Project Anatomy"
space: "Qejaverse Feature Guide"
url: "https://qejaverse-prod.jh.frappe.cloud/qejaverse-project-anatormy"
updated: "2026-08-15"
---

## Components of a QejaVerse Project

**Audience:** new engineers, new AI agents, QA, and product.
**Purpose:** read this instead of reading the codebase. It explains every moving part of a Real Estate Project — what it is, why it exists, what it controls, and where it lives.

**Last verified against code:** 2026-07-29, branch `crm-marketplace-split`.
Companion document: [QA Project Setup Test Plan](./qa/project-setup/README.md).

---

## 1. The one-paragraph version

A **Real Estate Project** is the container for everything a developer sells. It is created by an *activated* Developer Profile, belongs to a **Qejaverse Company** (the developer's firm, or optionally a per-project SPV), and is filled in through an **8-step setup wizard**. Its physical inventory is a four-level hierarchy — **Phase → Building → Floor → Unit**. Its commercial terms live in **commission settings, payment plan templates and transaction fees**. Its legitimacy is proven by **due diligence documents**. Its people are the **Project Team**. A weighted **setup completeness score** decides when it may be **published**; publishing makes it visible to agents. A *separate* gate — per unit-type — decides when it appears on the **public marketplace**.

---

## 2. Entity map

```mermaid
graph TD
  DP[Developer Profile<br/>must be is_activated] --> QC[Qejaverse Company<br/>type = Developer]
  QC --> REP[Real Estate Project]
  QC -.optional SPV.-> SPV[Qejaverse Company<br/>type = Project]
  SPV -.-> REP

  REP --> PH[Project Phase]
  PH --> PC[Property Container<br/>= Building / Block]
  PC --> PSC[Property Sub Container<br/>= Floor / Wing / Section]
  PSC --> PU[Project Unit]
  PC --> PU

  REP --> UTT[Unit Type Template]
  UTT -.applied to.-> PU
  UTT --> TM[Template Media]

  REP --> DD[Due Diligence Documents<br/>child table]
  REP --> AM[Project Amenity<br/>child table]
  REP --> EL[Project External Link<br/>child table]
  REP --> TF[Project Transaction Fee<br/>child table]
  REP --> PM[Project Media<br/>separate doctype]
  REP --> PPT[Payment Plan Template]
  PPT --> MS[Payment Milestone<br/>child table]
  REP --> OLT[Offer Letter Template<br/>= document templates]
  REP --> PT[Project Team]
  REP --> POR[Project Onboarding Record]
  REP --> PCU[Project Customer]
```

---

## 3. Before a project can exist — the preconditions

These are hard gates. Every one of them throws or redirects if unmet.

| # | Requirement | Enforced where | Failure symptom |
|---|---|---|---|
| 1 | User has a **Developer Profile** | `create_project_draft_logic` | 403 "You need a Developer Profile to create a project." |
| 2 | Developer Profile `is_activated = 1` | `RealEstateProject.before_insert` + wizard guard | Redirect to `/manager/company-profile` with toast; API throws ValidationError |
| 3 | Profile has a **Qejaverse Company** (`profile.company`) | `create_project_draft_logic` | 400 "Finish setting up your developer company…" |
| 4 | Developer has accepted the **Developer Platform Agreement** (current version) | Publish gate only (marketplace mode) | Publish 400 with version numbers |
| 5 | **Subscription project slot** available | `doc_events` → `on_project_before_insert` | ValidationError "Subscription Limit Reached" |

> ⚠️ **Free tier on this site allows 1 project per user** (`Subscription Config.free_tier_project_limit = 1`). Free tier also allows 1000 units, 10 buildings, 10 phases. QA needs either a paid subscription or one developer account per project.

Activation itself (out of scope here) creates the Developer-type Qejaverse Company synchronously, so `profile.company` is always populated by the time a project is created.

---

## 4. The Real Estate Project record

**Doctype:** `Real Estate Project` · naming `PRJ-.####` · title field `project_name`
**Controller:** `casahub_app/casahub_app/doctype/real_estate_project/real_estate_project.py`
**Main service:** `services/project_setup.py`
**API surface:** `casahub_app/casahub_app/api.py`

### 4.1 Identity fields

| Field | What it is | Rules |
|---|---|---|
| `project_code` | Public-facing unique code, `QVP-…` | Generated in `before_insert` via `entity_codes.generate_entity_code("QVP")`. Read-only. Disambiguates public URLs. |
| `project_name` | Human name | Required. **Unique per firm**, compared case- and whitespace-insensitively (`firm_has_project_name`). Two different firms may reuse a name. FE validation: 3–200 chars, must contain a letter. |
| `slug` | Cosmetic URL slug | Auto-derived from `project_name` if blank. **Not unique** — public URLs are disambiguated by `project_code`. |
| `developer_profile` | Owning developer | Required. **Immutable after first save** (`validate_developer_profile_lock`) — changing it would orphan clients, contracts and commissions. |
| `developer_company` | The firm's Qejaverse Company | Set once at creation, silently reverted if tampered with. |
| `company` | Company **of record** for this project | Defaults to `developer_company`; can be switched to a per-project SPV. See §5. |
| `created_by` | Creator | Read-only. Used by permission checks. |

### 4.2 Classification fields

| Field | Source | Why it matters |
|---|---|---|
| `property_template` | `System Options` category *Property Template* | **Required**. Drives the default transaction-fee set (`get_default_kenyan_fees`). Options: Residential Apartment, Residential Villa, Commercial Office, Commercial Retail, Mixed-Use, Townhouse, Villa, Commercial, Mixed. |
| `project_category` | `Registry Dictionary Concept` class *Project Categories* | **Drives which due-diligence documents are required.** Selectable: Residential, Commercial, Mixed-Use. Hospitality and Industrial exist as concepts but are not offered — see §6. |
| `development_type` | `Registry Dictionary Concept` class *Development Types* | New Construction, Renovation, Redevelopment, Expansion, High rise. Informational + completeness score. |
| `project_status` | `System Options` category *Project Status* | Planning, PreLaunch, Launched, Construction, Ready, Completed. Displayed publicly. |
| `container_type` | `System Options` category *Container Type* | Default building type. ⚠️ See known gap G-6. |

### 4.3 Read-only rollups

`total_units`, `available_units`, `number_of_phases` are recomputed on every `on_update` by walking `Project → Phase → Container → Unit`. `available_units` counts units whose status option_value is `available`.

`propagate_location_to_units` copies `city/state/country/lat/long/location` down to every `Project Unit` via one bulk SQL UPDATE whenever project location changes — this is what makes marketplace geo-search work.

---

## 5. Company of record and the SPV option

**Why it exists:** developers often sell a project through a special-purpose vehicle (a separate registered company) rather than the parent firm. Contracts, invoices and offer letters must name the correct legal entity.

- Default: `project.company = project.developer_company` (the firm).
- Opt in on **Step 1** of the wizard ("this project has its own company") or later at `/projects/:id/manage/company`.
- `create_project_company()` creates a `Qejaverse Company` with `company_type = "Project"` and `parent_company = <firm>`. It **reuses** an existing same-named Project company under that firm rather than minting duplicates. Logo/country/currency fall back to the firm.
- SPV legal fields: company name, logo, registration number, legal form, KRA PIN, date of incorporation, NCA reg no, VAT reg no, NEMA compliance, office address/phone/email, default bank name / account number / payment instructions.

**Locks:**

| Condition | Effect |
|---|---|
| Project is published **or** has any `Project Customer` | The *choice* of company cannot change at all (`_project_company_editable`). We block; we never retroactively move records to a new entity. |
| Project has any `Project Customer` | The SPV's **identity fields** (`company_name`, `company_registration_number`, `kra_tax_pin`, `legal_form`) are frozen — they appear on signed contracts. Other fields (address, bank, phone) stay editable. |

---

## 6. Due diligence — proving the project is real

**Child table:** `Project Due Diligence Document` on the project
**Catalogue:** `Due Diligence Document Type` → child table `Due Diligence Category Rule`
**Service:** `services/due_diligence_service.py`

### How requirements are derived

A document type is *applicable* to a project when it has a `Due Diligence Category Rule` row matching the project's `project_category`. That same row carries `is_mandatory`. So requirements are **per project category**, not global.

### Current catalogue (12 types × 3 categories)

Seeded by `casahub_app/casahub_app/setup/due_diligence_setup.py`.

| Document type | Residential | Commercial | Mixed-Use | Expires? |
|---|---|---|---|---|
| Title Deed / Lease | Mandatory | Mandatory | Mandatory | No |
| Approved Building Plans | Mandatory | Mandatory | Mandatory | No |
| NEMA EIA License | Mandatory | Mandatory | Mandatory | Yes |
| NCA Building Permit | Mandatory | Mandatory | Mandatory | Yes |
| County Development Permission | Mandatory | Mandatory | Mandatory | Yes |
| Land Search Certificate | Mandatory | Mandatory | Mandatory | Yes |
| Company Registration (CR12) | Mandatory | Mandatory | Mandatory | No |
| KRA Tax Compliance Certificate | Mandatory | Mandatory | Mandatory | Yes |
| Fire Safety Clearance | Optional | **Mandatory** | **Mandatory** | Yes |
| Survey Plan / Beacon Certificate | Optional | Optional | Optional | No |
| Power Supply Approval (KPLC) | Optional | Optional | Optional | No |
| Water & Sewerage Approval | Optional | Optional | Optional | No |

Mandatory counts: **Residential 8, Commercial 9, Mixed-Use 9**. Mixed-Use takes the
Commercial obligations because a mixed-use development contains a commercial component
and so needs fire safety clearance.

> **Only these three categories are selectable.** Because requirements come entirely
> from category rules, a category with no rules would require *nothing* — so the wizard
> offers only the seeded ones (`SUPPORTED_PROJECT_CATEGORIES` in
> `Casahub/src/services/projectSetup.js`). Hospitality and Industrial still exist as
> Registry Dictionary concepts (they remain valid developer *specialisations*) but
> cannot be chosen for a project. To re-enable one, seed its rules in
> `due_diligence_setup.DOCUMENT_TYPES` first, then add it to that constant. Projects
> already saved under a retired category keep it, shown as "*(no longer available)*"
> in the picker so it is never silently cleared.

### Behaviour

- `save_due_diligence_documents_logic` **replaces the whole child table** on every save. It re-derives `is_mandatory` from the category rules — a client cannot forge it.
- A row whose `expiry_date` is in the past is stamped `status = "Expired"` on save.
- `due_diligence_status` rollup: `Complete` (all mandatory have files, none expired) / `Has Expired Documents` / `Incomplete`.
- Once the project is **published**, a mandatory document cannot have its file removed (`validate_mandatory_docs_not_removed`). Unpublish first.
- **Changing `project_category` after uploading changes which documents are mandatory** — the child rows keep their old `is_mandatory` until the DD step is saved again.

---

## 7. Location

Fields: `location` (neighbourhood), `address_line_1/2`, `city`, `state` (county), `postal_code`, `country` (Link to Country), `latitude`, `longitude`.

Why it matters beyond display:
- `city` is a **hard requirement for marketplace listing** (§14).
- Changes cascade to all `Project Unit` rows, which is what the AI search and swipe deck filter on.
- Kenya counties are also available as `Registry Dictionary Concept` class *Kenya Counties* (32 entries).

Completeness: `address_line_1` + `city` + `country` = 5 points each = the whole 15-point Location category.

---

## 8. Media

Two distinct systems — this trips people up.

**A. Fields directly on the project** (wizard Step 4, `update_project_media`)
`cover_image`, `project_logo`, `brochure` (Attach), `video_url`, `virtual_tour_url` (Data).
Files are uploaded first via Frappe's upload API, then the returned `file_url` is written to the field.

**B. `Project Media` doctype** — the gallery (`/manage/media`)
One row per asset: `media_type` (Registry concept: Image, Video, Brochure, Floor Plan, 3D Tour, Document), `title`, `media_file` or `media_url`, `is_cover`, `display_order`, `is_public`. Managed by `services/project_media_service.py`. Setting a cover here also satisfies the completeness check.

**C. `Project External Link`** — child table, up to 15 rows.
Validation: URL **must start with `https://`**, title required, ≤100 chars, HTML stripped. Types: Gallery, Video, 3D Tour, Floor Plans, Documents, Other.

Media completeness (10 pts): logo 3 · cover image *or* a `Project Media` row with `is_cover` 3 · ≥3 gallery media rows 2 · any of brochure/video/tour 2.

---

## 9. Physical structure — the four-level hierarchy

```
Real Estate Project
└── Project Phase          (PHS-####)   "Phase 1", "Tower Release A"
    └── Property Container (CNT-####)   a Building / Block / Tower
        └── Property Sub Container (SUB-####)  a Floor / Wing / Section / Block / Cluster
            └── Project Unit (UNIT-####)       the sellable thing
```

**Service:** `services/project_structure.py`

### 9.1 Project Phase

- Every project gets **"Phase 1" auto-created** in `after_insert` with `is_default = 1`. Default phases are exempt from the subscription phase limit.
- Fields: `phase_name`, `phase_number` (unique within project), `phase_status`, `phase_budget`, `start_date`, `expected_completion_date`, `actual_completion_date`.
- `bulk_create_phases_logic` is **idempotent and additive** — phase numbers that already exist are *skipped*, not errors. The response reports `total_created` vs `total_skipped`.
- FE caps bulk creation at **20 phases per request**.
- **Deletion** is blocked if the phase has any buildings. It is *not* blocked for the default phase.

### 9.2 Property Container (Building / Block)

- Fields: `container_name`, `phase` (required), `container_type` (required), `total_floors`, `container_description`, `building_permit_number`, `architect`, `contractor`, construction dates.
- `bulk_create_containers_logic`:
  - If no phase is supplied it creates/reuses a **"Default Phase"** with a non-clashing phase number.
  - Names like `Building A` / `Building 5` are treated as **disposable placeholders** and replaced with a collision-free `Building <LETTER>` (Excel-style: A…Z, AA, AB…).
  - A *user-typed* name that already exists in the project is **skipped**, not an error.
  - **If `total_floors > 0`, floors are auto-created** as `Floor 1..N` using the `floor` Sub Container Type. A failure here is logged but does not fail the building.
- FE caps bulk creation at **50 buildings per request**.
- **Deletion:** blocked if the building has units; otherwise floors are cascade-deleted with it.
- `move_block_to_phase` re-parents a building.

### 9.3 Property Sub Container (Floor)

- Fields: `sub_container_name`, `container`, `sub_container_type` (floor / wing / section / block / cluster), `floor_number`.
- `bulk_create_floors_logic` skips duplicate floor numbers and reports them.

### 9.4 Project Unit

The single most field-heavy doctype in the app (~90 fields). Grouped:

| Group | Fields |
|---|---|
| Identity | `unit_number` (required, unique per container), `container`, `sub_container`, `project` (read-only), `unit_type`, `unit_type_template`, `status` |
| Specs | `built_up_area`, `carpet_area`, `balcony_area`, `bedrooms`, `bathrooms`, `parking_spaces`, `amenities`, `special_features`, `description` |
| Pricing | `base_price`, `price_per_sqft`, `currency` (default KES), `discount_percentage`, `discounted_price`, `final_price`, `pricing_notes` |
| Sales | `customer`, `booking_date`, `reservation_expiry`, `offer_preparation_expiry`, `offer_dispatched_at`, `reservation_accepted`, `sales_agent`, `referring_agent`, `commission_rate`, `linked_lead`, `sold_offline` |
| Contract | `sale_agreement_signed/date/document`, `last_sale_agreement_request` |
| Payments | `payment_plan`, `schedule_activated`, `schedule_is_custom`, `schedule_is_customized`, `total_amount_paid`, `outstanding_balance`, `payment_completion_percentage`, child tables `unit_payments`, `unit_payment_schedule` |
| Ownership journey | `stamp_duty_*`, `lcb_consent_*`, `transfer_*`, `title_deed_*`, `membership_*` |
| Fees | `transaction_fees` child table, `fees_frozen`, `total_transaction_fees`, `total_cost_of_ownership`, `total_fees_paid`, `total_fees_outstanding`, `fees_payment_complete` |
| Denormalised location | `project_city`, `project_county`, `project_country`, `project_location`, `project_latitude`, `project_longitude` |

**Bulk creation** (`bulk_create_units_logic`):

| Aspect | Behaviour |
|---|---|
| Sync vs async | quantity **≥ 20 → background job** (`long` queue, 1h timeout), returns HTTP 202 + `job_id`; poll `get_bulk_unit_creation_status`. Below 20 it is synchronous and returns the created units. |
| `naming_pattern = floor_based` | Requires floors to exist, else 400. `start_number` encodes floor+unit: `220` = start at floor 2, unit 20. Numbers are `{prefix}{floor}{unit:02d}` → `A-101`, `A-102`, `201`. Max **99 units per floor**. |
| `naming_pattern = sequential` | `{prefix}{start_number + i}` → `Villa-1`, `Villa-2`. Does not need floors. |
| Duplicates | Skipped, reported in `skipped`. |
| Defaults | `status = "Available"`, `unit_type = "TBD"` when not supplied. |
| Template | If `template_id` given, `unit_type` is taken from the template and `unit_type_template` is stamped on each unit. |

**Deletion** is blocked for units whose status is sold / reserved / booked / fully_paid / partially_paid. Others delete along with their `Unit Status Log` rows.

---

## 10. Unit Type Templates

**Doctype:** `Unit Type Template` (`UTP-.####`) · **Service:** `services/template_management.py`

The reusable spec for a kind of unit — "2 Bed Type A". This is the object the **public marketplace lists**, not individual units.

Fields: `template_name` (unique per project), `unit_type` (System Options), `project`, `slug`, `description`, areas, `bedrooms`/`bathrooms`/`parking_spaces`, `base_price`, `price_per_sqft`, `currency`, `pricing_notes`, `amenities`, `special_features`, child table `Template Media`, flags `is_active`, `is_default`, `is_listed_on_marketplace`.

- Applying a template to units: `bulk_apply_template(unit_ids, template_id)` copies specs and pricing onto each unit.
- Deleting a template is guarded by `count_saved_property_references` (wishlist rows point at templates) and invalidates the marketplace caches.
- ⚠️ **Frappe recycles `UTP-` docnames on delete.** Anything referencing a template must use a Link field, never a stored string. (See `SAVED_PROPERTY_TEMPLATE_IDENTITY_PLAN.md`.)

---

## 11. Amenities

`Table MultiSelect` of `Project Amenity` → Link to `System Options` category *Amenity* (34 options: swimming pool, gym, playground, clubhouse, 24/7 security, CCTV, generator, gated community, solar power, smart home, …).

Edited on **Step 1 (Basics)** in the current wizard, but persisted through the **structure** endpoint (`update_project_structure` with `amenities` as a JSON array). Unknown option names are silently dropped. The list is fully replaced on each save.

The FE groups them into 10 display categories by regex on the display name (Wellness & Recreation, Family & Community, Convenience & Services, Green & Sustainability, Security & Safety, Technology & Connectivity, Parking & Transport, Luxury & Premium, Business & Workspaces, Other) — this is cosmetic only.

---

## 12. Financials

### 12.1 Listing mode — the most consequential setting

| Mode | Meaning |
|---|---|
| `marketplace` (default) | QejaVerse generates **leads**; the developer closes the sale offline. Commission trigger is **forced to "On Sale"**. |
| `full` | The full digital sales pipeline: reservations, offer letters, e-signature, payment schedules, client portal. **Requires `Developer Profile.crm_enabled = 1`** — otherwise the doctype throws and the API returns a readable 400. |

### 12.2 Agent commission

| Field | Notes |
|---|---|
| `commission_type` | "" / Percentage / Fixed Amount |
| `default_commission_percentage` | 0–100, validated |
| `default_commission_amount` | ≥ 0, validated |
| `commission_trigger` | On Sale / On Deposit / On Percentage Paid / On Full Payment / On Handover |
| `commission_trigger_percentage` | 0–100, only meaningful with "On Percentage Paid" |

Marketplace-mode projects always get `On Sale` written back regardless of what is sent.

### 12.3 Transaction fees

Child table `Project Transaction Fee`. **Auto-populated at project creation** from `get_default_kenyan_fees(property_template)` — the standard Kenyan set (stamp duty, legal fees, registration, etc.). Editable in the wizard Financials step and at `/manage/financials`.

Per row: `fee_name`, `fee_code`, `calculation_type` (Fixed Amount / Percentage of Purchase Price), `percentage_rate`, `fixed_amount`, `applies_to` (All Unit Types / Specific Unit Type Template), `unit_type_template`, `journey_step` (sale_agreement / stamp_duty / regulatory / transfer / …), `is_mandatory`, `display_order`, `paid_to_type` (Developer / External Party), `external_party_name`.

Fees are **resolved onto a unit** (`resolve_transaction_fees`) and then **frozen** (`freeze_transaction_fees`) once reviewed, so later project-level edits do not change an in-flight sale.

### 12.4 Payment deadlines (client-portal SLAs)

`client_response_deadline_days`, `offer_preparation_deadline_days`, `developer_acceptance_deadline_days`, `offer_signing_first_deposit_deadline_days`, `payment_reminder_days` (comma list, e.g. `7,1`), `payment_grace_period_days`. Each falls back to the matching field on the **QejaVerse Settings** single doctype (defaults 7 / 14 / 2 / 7 / "7,1" / 3).

### 12.5 Application billing

`custom_rate_per_unit` — per-project override of the subscription per-unit rate.

---

## 13. Payment Plan Templates

**Doctype:** `Payment Plan Template` (`PPT-{project}-{####}`) · **Service:** `services/payment_plan_management.py` · **UI:** `/manage/payment-plans`

The offer a buyer chooses. Required fields: `project`, `plan_name`, `payment_plan_type` (Registry concept — 23 options: Cash Payment, Bank Mortgage, Construction-Linked, 50/50 Plan, Post-Handover, Rent-to-Own, …), `down_payment_percentage`, `number_of_installments`, `installment_frequency` (Monthly / Quarterly / Semi-Annually / Annually / Milestone-Based).

Optional: `payment_method_category` (Cash / Mortgage / Flexible / Custom), `linked_financier` (Financier Master), `grace_period_days`, `late_payment_penalty_percentage`, `early_payment_discount`, `is_active`, `is_default`, `terms_and_conditions`, `description`.

Child table `Payment Milestone`: `milestone_number`, `milestone_name`, `milestone_type` (Booking / Construction Stage / Handover / Post-Handover), `percentage_of_total`, `amount`, `due_date_type` (Days from Booking / Specific Date / On Construction Milestone), `days_from_booking`, `specific_date`, `grace_period_days`, `description`.

Downstream: a plan is **activated onto a unit** (`activate_payment_schedule`) which generates the `Unit Payment Schedule` rows. `schedule_is_custom` = built from scratch; `schedule_is_customized` = generated from a template then edited.

---

## 14. Document templates (offer letters and sale agreements)

**Doctype:** `Offer Letter Template` (`OTPL-.####`) · **UI:** `/manage/document-templates`

Per-project rich-text templates. `document_type` ∈ *Offer Letter*, *Sales Agreement*, *Non Disclosure Agreement* (System Options category *Document Template Type*). `expiry_days` defaults to 14.

**Merge placeholders** available in the editor:
`client_name`, `client_email`, `client_id_type`, `client_id_number`, `unit_number`, `unit_type`, `bedrooms`, `bathrooms`, `built_up_area`, `final_price`, `currency`, `project_name`, `project_location`, `developer_name`, `developer_company`, `developer_signatory_name`, `developer_signatory_title`, `developer_reg_number`, `developer_signature`, `buyer_full_name`, `buyer_id_number`, `buyer_signature`, `agent_name`, `payment_plan_name`, `offer_date`, `offer_expiry_date`, `agreement_date`, `agreement_expiry_date`.

`signatory_display_config` (JSON) controls which lines appear under each signature block — Developer can show Title, Organisation, Company Reg No; Buyer can show ID Number only.

Separately, **Agent Contract Templates** (`CTPL-.####`) are developer-wide, not project-scoped, and feed the agent contract flow.

---

## 15. Team, roles and access

**Doctype:** `Project Team` (autoname: hash). One row = one person on one project.
Fields: `project`, `user`, `role` (Link to System Options category *User Role*), `is_active`, `designation`, `is_qeja_preferred`, `assigned_date`, `assigned_by`.

- The **creator is auto-added as `project_manager`** in the same transaction as project creation. If the Project Manager role option is missing the whole creation fails fast with a 500 (deliberate).
- Available project roles in System Options: `project_manager`, `project_developer`, `sales_rep`.
- **Permission matrix** lives in `Project Role Permission` (role × permission_key × is_allowed, 40 keys per role), grouped by Project / Units / Buildings / Phases / Team / Financial / Payment Plans / Templates / Agents / Reports. Rows exist for: **Owner** (everything), **Manager** and **developer** (everything except `delete_project`), **sales manager** (read + export only), **Agent** (read-only view of project/units/buildings/phases/pricing/commissions/payment plans/templates).

  > ⚠️ **The matrix is cosmetic — it enforces nothing.** It is served by `get_project_role_permissions` purely so the frontend knows which tabs and buttons to draw. No API endpoint consults it. There *is* a complete backend enforcement module at `casahub_app/casahub_app/config/project_permissions.py` (`can_perform_action`, `is_admin_role`, …) but **nothing imports it** — it is dead code. Server-side authorisation is done by team membership and `@guard_project_access`, not by project role. Treat the matrix as UI configuration, never as a security boundary.

- **Role name resolution.** Team roles are stored as `System Options` names and normalised in the frontend (`normalizeRole` in `Casahub/src/config/projectPermissions.js`) before matrix lookup. `project_manager`, `project developer` and `project_developer` → `developer`; `project_owner` → `owner`; `sales manager` → `sales_manager`. Every role in live use resolves to a configured entry **except** Sales Manager, whose lookup misses (the matrix is keyed `sales manager` with a space, the lookup asks for `sales_manager`) and silently falls through to `FALLBACK_PERMISSIONS` in the same file. That fallback is currently byte-identical to the database, so behaviour is correct — but the admin permission editor has no effect on that role. See G-5.

  > `viewer` was a `Project Role` option with **zero** permission rows and no fallback entry, so anyone assigned it saw a management screen with no tabs. Deactivated 2026-07-29 (patch `deactivate_viewer_project_role`); **Agent** is the working view-only role. `sales_manager` and `sales manager` remain duplicate options — the invite dropdown shows "Sales Manager" twice.
- **Invitations:** `send_team_invitation(email, project, role, full_name, designation)`. Existing users are added directly and notified; new users get a registration link (`Team Invitation`, expiry from QejaVerse Settings `invitation_expiry_days`, default 7). `batch_invite_agents` does the same for a list. Invitations can be resent or cancelled.
- **Agent access:** `require_contract_for_agent_access` — when on, an agent only gains project access after signing an agent contract. Agents can also self-request access (`Project Access Request`) or be invited.
- Subscription: adding a team member whose role name contains "agent" consumes an **agent slot**.

---

## 16. Compliance (post-construction)

Project-level gates on the **Compliance** tab, written through `update_project_compliance_gate` (allow-list: `land_rent_clearance`, `land_rates_clearance`, `sectional_plan_registered`, each with a paired date field). `lcb_consent_required` is a plain checkbox on the doctype.

These sit above the per-unit ownership-journey gates (stamp duty → LCB consent → transfer lodged → title deed → membership). `get_compliance_impact_count` reports how many units have paid stamp duty but not yet lodged transfer — i.e. how many buyers are waiting on the developer's clearances.

---

## 17. Setup completeness and status tiers

**Service:** `services/project_completeness.py`

Two calculation modes. If any `Project Setup Checklist` rows exist for the project it uses those (weighted per category); otherwise — the normal case — it uses **automatic field-based scoring**:

| Category | Weight | How the points are earned |
|---|---|---|
| Basics | 20 | category 3 · development type 3 · description 3 · total budget 3 · land area 3 · start date 2.5 · completion date 2.5 |
| Location | 15 | address_line_1 5 · city 5 · country 5 |
| Media | 10 | logo 3 · cover 3 · ≥3 gallery items 2 · brochure/video/tour 2 |
| Structure | 20 | ≥1 phase 6 · a phase with budget > 0 4 · ≥1 building 6 · a building with floors > 0 4 |
| Units | 20 | ≥1 unit 10 · ≥5 units 5 · ≥1 unit priced 5 |
| Due Diligence | 15 | all mandatory uploaded 10 · ≥1 optional uploaded 3 · nothing expired 2 |

**Status tiers:** 0–39 Draft · 40–69 Basic Setup · 70–89 Structured · 90–99 Ready for Review · 100 Published.

`update_project_completeness` is called after **every** wizard save and after every structural change (~30 call sites, including doctype hooks on Project Unit, Project Phase, Property Container, Project Media and Project Team).

**Scoring never publishes.** The tier label reads "Published" at 100%, but that is the *completeness tier*, not the `is_published` flag — the two are independent. Publishing only ever happens through `publish_project_logic` (§18). This used to be otherwise; see G-2.

---

## 18. Publishing

`publish_project_logic` — invoked from Step 8 (Review). Gates, in order:

1. Project exists and is not already published.
2. Caller is System Manager **or** `created_by` **or** an active `Project Team` member.
3. **All mandatory due-diligence documents have files** — else 400 listing the missing ones.
4. Completeness ≥ `QejaVerse Settings.minimum_publish_completeness` (**default 70%**).
5. If `listing_mode = "marketplace"`:
   - `listing_confirmed_by_developer` must be ticked (the accuracy attestation on the Review step, stamped with user + timestamp),
   - the project must have a `developer_profile`,
   - that developer must have accepted the **current** version of the Developer Platform Agreement (commission terms, clause 4.4).
6. `company` must be set.

On success: `is_published = 1`, `published_date = now()`, `project_status` set to the *active* option if it was Draft, and (marketplace, first publish only) a "now live on QejaVerse" welcome email is sent to the developer.

The **UI adds one more gate the backend does not have**: the Review step disables Publish unless the **Developer Profile itself is published** (`isDeveloperPublished === true`).

`unpublish_project_logic` reverses it with the same permission check. Unpublished projects remain visible to team members only.

---

## 19. Marketplace listing — a separate, per-unit-type gate

Publishing makes a project visible to **agents**. It does *not* put it on the public marketplace. Each **Unit Type Template** is listed individually via `toggle_template_marketplace_listing`, and listing ON runs a 7-point readiness check (`get_template_marketplace_readiness_logic`):

1. Project is published
2. Developer profile is published
3. Project has a city
4. Template `is_active`
5. Template `base_price > 0`
6. Template name is set
7. ≥1 **available** unit linked to the template

Delisting always succeeds. The public listing query additionally enforces all of the above at read time, and results are cached 5 minutes (`home_listings:*`, invalidated by `cache_invalidation.py`).

---

## 20. Project onboarding (units already sold before joining)

**Doctype:** `Project Onboarding Record` (autoname = project name, so one per project) · **Service:** `services/onboarding_service.py` · **UI:** `/manager/projects/:project/onboarding`

Purpose: a developer joining QejaVerse mid-project already has sold units. They **declare** how many, **attest** to it legally, then run a per-unit wizard to seed each sale.

- `initialize_project_onboarding` — idempotent; rejects a declared count above `total_units`; `lock_period_days` comes from the developer profile (default 90).
- `confirm_attestation` — stores generated attestation text, typed name, user, IP, timestamp. **Immutable once confirmed.**
- Seeded units carry `onboarding_seeded = 1`, which **suppresses** scheduler-driven automation (payment reminders, reservation expiry, e-sign expiry, action items) so historical data does not generate live notifications.
- Units unlock/lock over the lock period; there are pre-lock notifications and an early-lock action.

---

## 21. The 8-step setup wizard

Route: `/projects/setup/new` → `/projects/setup/:projectId` · Component `Casahub/src/projects/ProjectSetupWizard.jsx`

| Step | Name | Writes via | Notes |
|---|---|---|---|
| 1 | Basics | `create_project_draft` then `update_project_basics`; amenities via `update_project_structure`; SPV via `set_project_company` | The **only** step that creates the project. Auto-saves on blur once the project exists. Duplicate-name check on blur. |
| 2 | Due Diligence | `save_due_diligence_documents` (files uploaded first) | Requires a `project_category` to show anything. |
| 3 | Location | `update_project_location` | |
| 4 | Media | `update_project_media` | Uploads cover/logo/brochure then saves URLs. |
| 5 | Structure | `update_project_structure` + `bulk_create_phases` / `bulk_create_containers` / `bulk_create_floors` | Auto-creates `Phase 1..N` if `number_of_phases` exceeds existing count. Phase-creation failure does **not** block navigation. |
| 6 | Units | `bulk_create_units_from_template`, `create_unit` | |
| 7 | Financials | `update_project_financials` | Commission + payment settings + transaction fees in one call. |
| 8 | Review | `confirm_project_listing`, `publish_project` | Shows completeness breakdown and the publish gates. |

The wizard is **non-linear** — the stepper lets you jump to any step. Forward navigation happens through each form's "Save & Continue"; the footer only offers Previous (and ← keyboard).

Team management is **not** in the wizard. It lives in post-setup management.

---

## 22. Post-setup management

Route `/projects/:projectId/manage/<tab>` · `Casahub/src/projects/manage/ManageLayout.jsx`

Tabs, each visibility-gated by a `Project Role Permission` key: **Units · Buildings · Phases · Media · External Links · Compliance · Due Diligence · Document Templates · Templates · Payment Plans · Financials · Company · Team · Settings**.

---

## 23. Known gaps and traps

These are real, reproducible behaviours found while writing this document. Treat them as expected-current-behaviour when testing, and as a backlog.

| ID | Area | Finding | Impact |
|---|---|---|---|
| ~~**G-1**~~ | Due diligence | ✅ **FIXED 2026-07-29.** `Due Diligence Category Rule` rows existed only for Residential and Commercial, so Hospitality, Industrial and Mixed-Use projects required **zero** documents. Mixed-Use rules are now seeded (patch `seed_mixed_use_due_diligence_rules`); Hospitality and Industrial are no longer selectable as project categories. | Was: such a project could publish with no legal documentation and its DD score was permanently 0. |
| ~~**G-2**~~ | Publishing | ✅ **FIXED 2026-07-29.** `update_project_completeness` auto-set `is_published = 1` at 100% + mandatory docs, **bypassing** `publish_project_logic`'s gates (listing-accuracy attestation, platform-agreement check, permission check). The auto-publish block is removed; publishing is now only ever explicit. Guarded by `tests/test_project_completeness.py`. | Was: a marketplace project could go live without the developer's accuracy attestation. |
| **G-3** | Publishing | The Review UI blocks publish unless the **Developer Profile is published**; the backend `publish_project_logic` never checks this. | API callers (or G-2) can publish a project belonging to an unpublished developer. Its unit types still can't reach the marketplace (§19 check 2), so the effect is a half-live project. |
| **G-4** | System Options data | `create_default_phase` looks for Phase Status `option_value = "planning"` and `publish_project_logic` looks for Project Status `option_value = "active"` — **neither value exists** in System Options (phase statuses are cancelled/on_hold/in_progress; project statuses are Planning/PreLaunch/Launched/Construction/Ready/Completed). | Auto-created phases have no status; published projects keep an empty `project_status`. |
| **G-5** | Permissions | Partly **FIXED 2026-07-29**, and the original description was wrong — see §15 for what is actually true. The permission matrix is **cosmetic**: it only decides which tabs and buttons the frontend draws, nothing on the server consults it. `normalizeRole` correctly aliases `project_manager`/`project_developer` → `developer`, so most roles resolve fine. Two real defects: **(a)** `viewer` had no permission rows at all → empty management screen (now deactivated, patch `deactivate_viewer_project_role`); **(b)** `sales_manager` and `sales manager` are duplicate System Options, and the frontend's underscore lookup misses the space-keyed database rows, so Sales Manager silently uses a hardcoded copy in `projectPermissions.js`. | (a) was live for 2 users. (b) is inert *today* — the hardcoded copy is byte-identical to the database — but edits made in the permission admin screen have no effect for that role, and the invite dropdown shows "Sales Manager" twice. |
| **G-6** | Wizard Step 5 | `ProjectStructureForm` sends `container_type` to `update_project_structure`, whose signature does not accept it — Frappe drops unknown kwargs silently. | The "Building Type" chosen on the Structure step is **never saved to the project record** (it is still used for the buildings created in that session). |
| **G-7** | System Options hygiene | Heavy duplication: Unit Type has `1Br`/`1 bedroom`, `4 bedroom`/`4br`, `VillaBungalow`/`villa bungalow`; Unit Status has `sold` + `FullyPaid` + `fully paid`; Container Type has `TownhouseRow`/`townhouse row`/`Town House`. | Dropdowns show near-identical options; filtering and reporting split across synonyms. |
| **G-8** | Phases | Deleting the **default** phase is not blocked. A project can end up with zero phases, which zeroes the Structure score and breaks unit rollups. | |
| **G-9** | Bulk units | `bulk_create_units_logic` writes a full `frappe.log_error` "[DEBUG] Subscription Check" entry on **every** call. | Error-log noise in production. |
| **G-10** | Existing data | Two projects in the current DB are `is_published = 1` at 11% completeness / `Draft` status (`PRJ-0475`, `PRJ-0470`). | Likely fixtures written directly; confirms publish state can diverge from completeness. |

---

## 24. File map (where to look)

| Concern | Path |
|---|---|
| Project doctype + controller | `casahub_app/casahub_app/doctype/real_estate_project/` |
| Create / update / publish | `casahub_app/casahub_app/services/project_setup.py` |
| Completeness scoring | `casahub_app/casahub_app/services/project_completeness.py` |
| Phases / buildings / floors / bulk units | `casahub_app/casahub_app/services/project_structure.py` |
| Unit CRUD, bulk edit, apply template | `casahub_app/casahub_app/services/unit_management.py` |
| Unit type templates | `casahub_app/casahub_app/services/template_management.py` |
| Due diligence | `casahub_app/casahub_app/services/due_diligence_service.py` |
| Transaction fees | `casahub_app/casahub_app/services/transaction_fee_service.py` |
| Payment plans | `casahub_app/casahub_app/services/payment_plan_management.py` |
| Team / access requests / invitations | `casahub_app/casahub_app/services/project_access.py`, `utils/invitation_utils.py` |
| Marketplace listing + public queries | `casahub_app/casahub_app/services/discover_service.py` |
| Onboarding of already-sold units | `casahub_app/casahub_app/services/onboarding_service.py` |
| Subscription limits + hooks | `casahub_app/casahub_app/services/subscription_service.py`, `subscription_hooks.py` |
| All whitelisted endpoints | `casahub_app/casahub_app/api.py` |
| Doc events wiring | `casahub_app/hooks.py` |
| Wizard | `Casahub/src/projects/ProjectSetupWizard.jsx` + `Casahub/src/projects/forms/` |
| Management tabs | `Casahub/src/projects/manage/` |
| FE API layer | `Casahub/src/services/projectSetup.js`, `projects.js`, `templates.js`, `paymentPlans.js` |
