From ff045930b68c42948f05da98446a61a8b59e5d89 Mon Sep 17 00:00:00 2001 From: Lacikika <114676193+Lacikika@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:39:25 +0000 Subject: [PATCH] feat: implement property zoning and building permits (Migration 067) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- ...7_property_zoning_and_building_permits.sql | 92 +++++ FiveM/nyx_core/sql/migrations/index.json | 3 +- .../client/building_permits.lua | 73 ++++ FiveM/nyx_properties/fxmanifest.lua | 5 +- .../server/building_permits.lua | 343 +++++++++++++++++ .../shared/building_permits.lua | 316 +++++++++++++++ ...7_property_zoning_and_building_permits.sql | 92 +++++ ...7_property_zoning_and_building_permits.sql | 92 +++++ ...st_property_zoning_and_building_permits.py | 363 ++++++++++++++++++ 9 files changed, 1377 insertions(+), 2 deletions(-) create mode 100644 FiveM/nyx_core/sql/migrations/067_property_zoning_and_building_permits.sql create mode 100644 FiveM/nyx_properties/client/building_permits.lua create mode 100644 FiveM/nyx_properties/server/building_permits.lua create mode 100644 FiveM/nyx_properties/shared/building_permits.lua create mode 100644 FiveM/nyx_properties/sql/migrations/067_property_zoning_and_building_permits.sql create mode 100644 FiveM/nyx_treasury/sql/migrations/067_property_zoning_and_building_permits.sql create mode 100644 tests/test_property_zoning_and_building_permits.py diff --git a/FiveM/nyx_core/sql/migrations/067_property_zoning_and_building_permits.sql b/FiveM/nyx_core/sql/migrations/067_property_zoning_and_building_permits.sql new file mode 100644 index 0000000..f16fcf8 --- /dev/null +++ b/FiveM/nyx_core/sql/migrations/067_property_zoning_and_building_permits.sql @@ -0,0 +1,92 @@ +-- ============================================================================ +-- Nyxframework: nyx_properties, nyx_treasury, nyx_factions & nyx_parliament +-- Migration: 067_property_zoning_and_building_permits.sql +-- Description: Commercial renovation approvals, municipal inspection fee routing, +-- architectural blueprint validation, statutory zoning rules, +-- and environmental impact surcharges. +-- Engine: InnoDB | Charset: utf8mb4 | Collation: utf8mb4_unicode_ci +-- ============================================================================ + +SET FOREIGN_KEY_CHECKS = 0; + +-- 1. Create Property Zoning Rules Table +CREATE TABLE IF NOT EXISTS `nyx_property_zoning_rules` ( + `zoning_type` VARCHAR(32) NOT NULL PRIMARY KEY, + `label` VARCHAR(128) NOT NULL, + `max_floor_area_sqm` INT NOT NULL DEFAULT 2000, + `max_height_meters` DECIMAL(6, 2) NOT NULL DEFAULT 25.00, + `base_permit_fee` DECIMAL(10, 2) NOT NULL DEFAULT 500.00, + `inspection_fee_flat` DECIMAL(10, 2) NOT NULL DEFAULT 250.00, + `environmental_surcharge_rate` DECIMAL(5, 4) NOT NULL DEFAULT 0.0200, + `requires_certified_blueprint` TINYINT(1) NOT NULL DEFAULT 1, + `auto_approval_eligible` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_zoning_auto` (`auto_approval_eligible`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. Create Architectural Blueprints Table +CREATE TABLE IF NOT EXISTS `nyx_architectural_blueprints` ( + `blueprint_id` VARCHAR(64) NOT NULL PRIMARY KEY, + `architect_identifier` VARCHAR(64) NOT NULL, + `title` VARCHAR(128) NOT NULL, + `target_zoning` VARCHAR(32) NOT NULL, + `floor_area_sqm` INT NOT NULL DEFAULT 250, + `structural_tier` INT NOT NULL DEFAULT 1, + `blueprint_cost` DECIMAL(10, 2) NOT NULL DEFAULT 1000.00, + `is_certified` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_bp_architect` (`architect_identifier`), + INDEX `idx_bp_zoning` (`target_zoning`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. Create Building Permits Table +CREATE TABLE IF NOT EXISTS `nyx_building_permits` ( + `permit_id` VARCHAR(32) NOT NULL PRIMARY KEY, + `property_id` VARCHAR(64) NOT NULL, + `applicant_identifier` VARCHAR(64) NOT NULL, + `applicant_faction` VARCHAR(64) NULL, + `zoning_type` VARCHAR(32) NOT NULL, + `renovation_type` ENUM('interior_remodel', 'facade_modernization', 'structural_expansion', 'safety_upgrade', 'industrial_conversion') NOT NULL DEFAULT 'interior_remodel', + `blueprint_id` VARCHAR(64) NOT NULL, + `floor_area_sqm` INT NOT NULL DEFAULT 150, + `base_permit_fee` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `inspection_fee` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `environmental_surcharge` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `blueprint_cost` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `total_cost` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `status` ENUM('SUBMITTED', 'INSPECTION_PENDING', 'APPROVED', 'REJECTED', 'COMPLETED', 'REVOKED') NOT NULL DEFAULT 'SUBMITTED', + `inspector_identifier` VARCHAR(64) NULL, + `rejection_reason` TEXT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `approved_at` TIMESTAMP NULL DEFAULT NULL, + `completed_at` TIMESTAMP NULL DEFAULT NULL, + INDEX `idx_permit_prop` (`property_id`), + INDEX `idx_permit_applicant` (`applicant_identifier`), + INDEX `idx_permit_status` (`status`), + INDEX `idx_permit_zoning` (`zoning_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 4. Seed Statutory Property Zoning Rules +INSERT INTO `nyx_property_zoning_rules` + (`zoning_type`, `label`, `max_floor_area_sqm`, `max_height_meters`, `base_permit_fee`, `inspection_fee_flat`, `environmental_surcharge_rate`, `requires_certified_blueprint`, `auto_approval_eligible`) +VALUES + ('retail_storefront', 'Retail Commercial District', 1500, 20.00, 450.00, 200.00, 0.0150, 1, 1), + ('corporate_office', 'Corporate Executive District', 3000, 60.00, 850.00, 350.00, 0.0200, 1, 0), + ('industrial_warehouse', 'Industrial Logistics Zone', 10000, 35.00, 1200.00, 500.00, 0.0350, 1, 0), + ('hospitality_venue', 'Commercial Entertainment Sector', 2500, 25.00, 650.00, 300.00, 0.0250, 1, 0), + ('medical_clinic', 'Public Health & Clinical Complex', 2000, 30.00, 750.00, 400.00, 0.0100, 1, 0) +ON DUPLICATE KEY UPDATE `label` = VALUES(`label`); + +-- 5. Seed Standard Architectural Blueprints +INSERT INTO `nyx_architectural_blueprints` + (`blueprint_id`, `architect_identifier`, `title`, `target_zoning`, `floor_area_sqm`, `structural_tier`, `blueprint_cost`, `is_certified`) +VALUES + ('bp_retail_mod_v1', 'arch_city_planning', 'Standard Retail Interior Modernization', 'retail_storefront', 250, 1, 750.00, 1), + ('bp_office_expansion_v1', 'arch_city_planning', 'Executive Office Suite Expansion', 'corporate_office', 600, 2, 1500.00, 1), + ('bp_warehouse_mezzanine_v1', 'arch_city_planning', 'Heavy Industrial Mezzanine Blueprint', 'industrial_warehouse', 2500, 3, 3000.00, 1), + ('bp_hospitality_lounge_v1', 'arch_city_planning', 'Commercial Dining & Lounge Layout', 'hospitality_venue', 500, 2, 1250.00, 1) +ON DUPLICATE KEY UPDATE `title` = VALUES(`title`); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/FiveM/nyx_core/sql/migrations/index.json b/FiveM/nyx_core/sql/migrations/index.json index e5081f1..8fb980e 100644 --- a/FiveM/nyx_core/sql/migrations/index.json +++ b/FiveM/nyx_core/sql/migrations/index.json @@ -64,5 +64,6 @@ "063_pharmaceutical_refining_and_player_chemical_labs.sql", "064_industrial_refining_and_functional_parts_manufacturing.sql", "065_judicial_bail_bonds_surety_escrow_and_fugitive_recovery.sql", - "066_municipal_toll_booths_and_commercial_weigh_stations.sql" + "066_municipal_toll_booths_and_commercial_weigh_stations.sql", + "067_property_zoning_and_building_permits.sql" ] diff --git a/FiveM/nyx_properties/client/building_permits.lua b/FiveM/nyx_properties/client/building_permits.lua new file mode 100644 index 0000000..bf77e7c --- /dev/null +++ b/FiveM/nyx_properties/client/building_permits.lua @@ -0,0 +1,73 @@ +--[[ + Nyxframework: nyx_properties + File: client/building_permits.lua + Description: Client Spatial Interactions & NUI Bridge for Property Zoning & Building Permits. +]] + +Nyx = Nyx or {} +Nyx.BuildingPermits = Nyx.BuildingPermits or {} + +-- Municipal Planning & Zoning Office Coordinates (City Hall / Public Works) +local PLANNING_OFFICE_COORDS = vector3(-542.12, -208.55, 37.65) + +-- Register Spatial Interactions via nyx_interact if present +CreateThread(function() + if exports["nyx_interact"] and exports["nyx_interact"].AddInteractionPoint then + exports["nyx_interact"]:AddInteractionPoint("zoning_planning_office", { + coords = PLANNING_OFFICE_COORDS, + radius = 2.5, + label = "City Planning & Zoning Terminal", + icon = "fa-solid fa-city", + onInteract = function() + Nyx.BuildingPermits.OpenPlanningOfficeUI() + end + }) + end +end) + +function Nyx.BuildingPermits.OpenPlanningOfficeUI(propertyId) + local targetProperty = propertyId or "prop_default_storefront" + if exports["nyx_ui"] and exports["nyx_ui"].OpenView then + exports["nyx_ui"]:OpenView("zoning_permits_portal", { + propertyId = targetProperty, + zoningRules = Nyx.BuildingPermits.ZoningRules, + renovationTypes = Nyx.BuildingPermits.RenovationTypes + }) + else + TriggerEvent("chat:addMessage", { + color = { 0, 200, 255 }, + multiline = true, + args = { "ZONING", "City Planning & Building Permit Terminal Accessed." } + }) + end +end + +-- Response Net Listeners +RegisterNetEvent("nyx_properties:permitSubmittedResponse", function(success, message, permitData) + if success then + TriggerEvent("nyx_ui:notify", { type = "success", title = "Building Permit", message = message }) + else + TriggerEvent("nyx_ui:notify", { type = "error", title = "Building Permit Rejected", message = message }) + end +end) + +RegisterNetEvent("nyx_properties:permitInspectedResponse", function(success, message, permitData) + if success then + TriggerEvent("nyx_ui:notify", { type = "info", title = "Inspection Updated", message = message }) + else + TriggerEvent("nyx_ui:notify", { type = "error", title = "Inspection Failed", message = message }) + end +end) + +RegisterNetEvent("nyx_properties:renovationCompletedResponse", function(success, message, bonusData) + if success then + TriggerEvent("nyx_ui:notify", { type = "success", title = "Renovation Complete", message = message }) + else + TriggerEvent("nyx_ui:notify", { type = "error", title = "Renovation Error", message = message }) + end +end) + +-- Exports +exports("OpenBuildingPermitTerminal", function(propertyId) + Nyx.BuildingPermits.OpenPlanningOfficeUI(propertyId) +end) diff --git a/FiveM/nyx_properties/fxmanifest.lua b/FiveM/nyx_properties/fxmanifest.lua index 17222f9..01a9ab7 100644 --- a/FiveM/nyx_properties/fxmanifest.lua +++ b/FiveM/nyx_properties/fxmanifest.lua @@ -22,7 +22,8 @@ shared_scripts { 'shared/taxation.lua', 'shared/commercial_leases.lua', 'shared/inactivity.lua', - 'shared/portal_ui.lua' + 'shared/portal_ui.lua', + 'shared/building_permits.lua' } server_scripts { @@ -34,6 +35,7 @@ server_scripts { 'server/leases.lua', 'server/inactivity.lua', 'server/billing.lua', + 'server/building_permits.lua', 'server/exports.lua' } @@ -45,6 +47,7 @@ client_scripts { 'client/inactivity.lua', 'client/interact.lua', 'client/portal_ui.lua', + 'client/building_permits.lua', 'client/exports.lua' } diff --git a/FiveM/nyx_properties/server/building_permits.lua b/FiveM/nyx_properties/server/building_permits.lua new file mode 100644 index 0000000..dd28bd5 --- /dev/null +++ b/FiveM/nyx_properties/server/building_permits.lua @@ -0,0 +1,343 @@ +--[[ + Nyxframework: nyx_properties + File: server/building_permits.lua + Description: Authoritative Server State Manager for Property Zoning & Building Permits. + Handles oxmysql persistence, treasury fee deduction, municipal inspector + approval workflows, and commercial renovation completions. +]] + +Nyx = Nyx or {} +Nyx.BuildingPermits = Nyx.BuildingPermits or {} + +local isMySQLReady = false + +if MySQL and MySQL.ready then + MySQL.ready(function() + isMySQLReady = true + end) +end + +-- In-memory runtime cache for building permits +local activePermits = {} + +-- ---------------------------------------------------------------------------- +-- Helper Functions +-- ---------------------------------------------------------------------------- +local function generatePermitId() + local timestamp = os.time() + local rand = math.random(1000, 9999) + return string.format("PERMIT-%d-%d", timestamp, rand) +end + +local function auditLog(action, source, details) + print(string.format("^3[Nyx:BuildingPermits:Audit]^7 [%s] Source: %s | Details: %s", action, tostring(source), json.encode(details))) + if Nyx.Treasury and Nyx.Treasury.RecordAuditTrail then + pcall(Nyx.Treasury.RecordAuditTrail, source, "BUILDING_PERMIT_" .. action, details) + end +end + +-- ---------------------------------------------------------------------------- +-- 1. Fetch Property Zoning & Blueprint DB Records +-- ---------------------------------------------------------------------------- +function Nyx.BuildingPermits.GetZoningRule(zoningType) + if isMySQLReady then + local rows = MySQL.query.await("SELECT * FROM nyx_property_zoning_rules WHERE zoning_type = ?", { zoningType }) + if rows and #rows > 0 then + return rows[1] + end + end + return Nyx.BuildingPermits.ZoningRules[zoningType] or Nyx.BuildingPermits.ZoningRules.retail_storefront +end + +function Nyx.BuildingPermits.GetBlueprint(blueprintId) + if isMySQLReady then + local rows = MySQL.query.await("SELECT * FROM nyx_architectural_blueprints WHERE blueprint_id = ?", { blueprintId }) + if rows and #rows > 0 then + return rows[1] + end + end + return { + blueprint_id = blueprintId, + architect_identifier = "arch_city_planning", + title = "Standard Blueprint", + target_zoning = "retail_storefront", + floor_area_sqm = 250, + structural_tier = 1, + blueprint_cost = 750.00, + is_certified = 1 + } +end + +-- ---------------------------------------------------------------------------- +-- 2. Submit Building Permit Application +-- ---------------------------------------------------------------------------- +function Nyx.BuildingPermits.SubmitPermitApplication(source, propertyId, renovationType, blueprintId, playerMoney) + local player = Nyx.GetPlayerFromId and Nyx.GetPlayerFromId(source) + local citizenId = player and player.citizenId or ("CITIZEN-" .. tostring(source)) + + -- Fetch Property + local property = nil + if isMySQLReady then + local props = MySQL.query.await("SELECT * FROM nyx_properties WHERE property_id = ?", { propertyId }) + if props and #props > 0 then + property = props[1] + end + end + + local zoningType = property and property.commercial_zoning or "retail_storefront" + if zoningType == "none" or not zoningType then + zoningType = "retail_storefront" + end + + local blueprint = Nyx.BuildingPermits.GetBlueprint(blueprintId) + local isValidBp, bpErr = Nyx.BuildingPermits.ValidateBlueprint(blueprint, zoningType, nil) + if not isValidBp then + return false, bpErr, nil + end + + local feeSummary = Nyx.BuildingPermits.CalculatePermitFees( + zoningType, + renovationType, + blueprint.floor_area_sqm or 250, + blueprint.blueprint_cost or 0.00, + nil + ) + + local totalCost = feeSummary.totalProjectCost + + -- Funds verification & deduction + local availableMoney = playerMoney or (player and player.getBank and player.getBank() or 100000.00) + if availableMoney < totalCost then + return false, string.format("Insufficient funds for permit application ($%.2f required, $%.2f available)", totalCost, availableMoney), nil + end + + if player and player.removeBank then + player.removeBank(totalCost) + end + + -- Treasury Fee Allocations + if Nyx.Treasury and Nyx.Treasury.DepositDepartmentFund then + pcall(Nyx.Treasury.DepositDepartmentFund, "treasury_civil_works", feeSummary.treasurySplit.civilWorksFund, "BUILDING_PERMIT_FEE") + pcall(Nyx.Treasury.DepositDepartmentFund, "treasury_inspection_pool", feeSummary.treasurySplit.inspectionPool, "BUILDING_INSPECTION_FEE") + end + + local permitId = generatePermitId() + local isAutoApproved = Nyx.BuildingPermits.EvaluateAutoApproval({ + zoningType = zoningType, + renovationType = renovationType, + floorAreaSqm = blueprint.floor_area_sqm, + blueprintCertified = (blueprint.is_certified == 1 or blueprint.is_certified == true) + }) + + local initialStatus = isAutoApproved and "APPROVED" or "INSPECTION_PENDING" + + local permitRecord = { + permit_id = permitId, + property_id = propertyId, + applicant_identifier = citizenId, + applicant_faction = nil, + zoning_type = zoningType, + renovation_type = renovationType, + blueprint_id = blueprintId, + floor_area_sqm = blueprint.floor_area_sqm or 250, + base_permit_fee = feeSummary.basePermitFee, + inspection_fee = feeSummary.inspectionFee, + environmental_surcharge = feeSummary.environmentalSurcharge, + blueprint_cost = feeSummary.blueprintCost, + total_cost = totalCost, + status = initialStatus, + inspector_identifier = isAutoApproved and "MUNICIPAL_AUTO_APPROVER" or nil, + rejection_reason = nil + } + + if isMySQLReady then + MySQL.insert.await([[ + INSERT INTO nyx_building_permits + (permit_id, property_id, applicant_identifier, applicant_faction, zoning_type, renovation_type, blueprint_id, floor_area_sqm, base_permit_fee, inspection_fee, environmental_surcharge, blueprint_cost, total_cost, status, inspector_identifier, approved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + permitId, propertyId, citizenId, nil, zoningType, renovationType, blueprintId, + blueprint.floor_area_sqm or 250, feeSummary.basePermitFee, feeSummary.inspectionFee, + feeSummary.environmentalSurcharge, feeSummary.blueprintCost, totalCost, + initialStatus, isAutoApproved and "MUNICIPAL_AUTO_APPROVER" or nil, + isAutoApproved and os.date("!%Y-%m-%d %H:%M:%S") or nil + }) + end + + activePermits[permitId] = permitRecord + + auditLog("APPLICATION_SUBMITTED", source, { + permitId = permitId, + propertyId = propertyId, + totalCost = totalCost, + status = initialStatus + }) + + return true, "Permit application submitted successfully", permitRecord +end + +-- ---------------------------------------------------------------------------- +-- 3. Inspect & Approve / Reject Building Permit +-- ---------------------------------------------------------------------------- +function Nyx.BuildingPermits.InspectPermit(source, permitId, action, rejectionReason) + local inspectorPlayer = Nyx.GetPlayerFromId and Nyx.GetPlayerFromId(source) + local inspectorId = inspectorPlayer and inspectorPlayer.citizenId or ("INSPECTOR-" .. tostring(source)) + + local permit = activePermits[permitId] + if not permit and isMySQLReady then + local rows = MySQL.query.await("SELECT * FROM nyx_building_permits WHERE permit_id = ?", { permitId }) + if rows and #rows > 0 then + permit = rows[1] + end + end + + if not permit then + return false, "Permit record not found" + end + + local newStatus, err = Nyx.BuildingPermits.TransitionPermitState(permit.status, action, nil) + if err then + return false, err + end + + permit.status = newStatus + permit.inspector_identifier = inspectorId + if action == "REJECT" then + permit.rejection_reason = rejectionReason or "Inspection failed statutory code compliance" + end + + if isMySQLReady then + MySQL.update.await([[ + UPDATE nyx_building_permits + SET status = ?, inspector_identifier = ?, rejection_reason = ?, approved_at = CASE WHEN ? = 'APPROVED' THEN CURRENT_TIMESTAMP ELSE approved_at END + WHERE permit_id = ? + ]], { newStatus, inspectorId, permit.rejection_reason, newStatus, permitId }) + end + + activePermits[permitId] = permit + + auditLog("INSPECTION_PROCESSED", source, { + permitId = permitId, + action = action, + newStatus = newStatus, + inspectorId = inspectorId + }) + + return true, string.format("Permit status updated to '%s'", newStatus), permit +end + +-- ---------------------------------------------------------------------------- +-- 4. Complete Commercial Renovation +-- ---------------------------------------------------------------------------- +function Nyx.BuildingPermits.CompleteRenovation(source, permitId) + local permit = activePermits[permitId] + if not permit and isMySQLReady then + local rows = MySQL.query.await("SELECT * FROM nyx_building_permits WHERE permit_id = ?", { permitId }) + if rows and #rows > 0 then + permit = rows[1] + end + end + + if not permit then + return false, "Permit record not found" + end + + local newStatus, err = Nyx.BuildingPermits.TransitionPermitState(permit.status, "COMPLETE", nil) + if err then + return false, err + end + + permit.status = newStatus + + -- Fetch existing property capacity + local currentStashKg = 1000 + local currentRegisters = 2 + + if isMySQLReady then + local props = MySQL.query.await("SELECT stash_capacity_kg, register_count FROM nyx_properties WHERE property_id = ?", { permit.property_id }) + if props and #props > 0 then + currentStashKg = props[1].stash_capacity_kg or 1000 + currentRegisters = props[1].register_count or 2 + end + end + + local bonus = Nyx.BuildingPermits.CalculateRenovationPropertyBonus(permit.renovation_type, currentStashKg, currentRegisters) + + if isMySQLReady then + MySQL.update.await([[ + UPDATE nyx_properties + SET stash_capacity_kg = ?, register_count = ? + WHERE property_id = ? + ]], { bonus.newStashCapacityKg, bonus.newRegisterSlots, permit.property_id }) + + MySQL.update.await([[ + UPDATE nyx_building_permits + SET status = 'COMPLETED', completed_at = CURRENT_TIMESTAMP + WHERE permit_id = ? + ]], { permitId }) + end + + activePermits[permitId] = permit + + auditLog("RENOVATION_COMPLETED", source, { + permitId = permitId, + propertyId = permit.property_id, + newStashCapacityKg = bonus.newStashCapacityKg, + newRegisterSlots = bonus.newRegisterSlots + }) + + return true, "Renovation completed successfully; property attributes upgraded", bonus +end + +-- ---------------------------------------------------------------------------- +-- 5. Query Active Property Permits +-- ---------------------------------------------------------------------------- +function Nyx.BuildingPermits.GetPropertyPermits(propertyId) + if isMySQLReady then + return MySQL.query.await("SELECT * FROM nyx_building_permits WHERE property_id = ? ORDER BY created_at DESC", { propertyId }) + end + + local result = {} + for _, p in pairs(activePermits) do + if p.property_id == propertyId then + table.insert(result, p) + end + end + return result +end + +-- Net Events / RPC Handlers +RegisterNetEvent("nyx_properties:submitPermit", function(propertyId, renovationType, blueprintId) + local src = source + local ok, msg, data = Nyx.BuildingPermits.SubmitPermitApplication(src, propertyId, renovationType, blueprintId) + TriggerClientEvent("nyx_properties:permitSubmittedResponse", src, ok, msg, data) +end) + +RegisterNetEvent("nyx_properties:inspectPermit", function(permitId, action, rejectionReason) + local src = source + local ok, msg, data = Nyx.BuildingPermits.InspectPermit(src, permitId, action, rejectionReason) + TriggerClientEvent("nyx_properties:permitInspectedResponse", src, ok, msg, data) +end) + +RegisterNetEvent("nyx_properties:completeRenovation", function(permitId) + local src = source + local ok, msg, data = Nyx.BuildingPermits.CompleteRenovation(src, permitId) + TriggerClientEvent("nyx_properties:renovationCompletedResponse", src, ok, msg, data) +end) + +-- Exports +exports("SubmitBuildingPermit", function(source, propertyId, renovationType, blueprintId) + return Nyx.BuildingPermits.SubmitPermitApplication(source, propertyId, renovationType, blueprintId) +end) + +exports("InspectBuildingPermit", function(source, permitId, action, rejectionReason) + return Nyx.BuildingPermits.InspectPermit(source, permitId, action, rejectionReason) +end) + +exports("CompletePropertyRenovation", function(source, permitId) + return Nyx.BuildingPermits.CompleteRenovation(source, permitId) +end) + +exports("CalculatePermitFees", function(zoningType, renovationType, floorAreaSqm, blueprintCost) + return Nyx.BuildingPermits.CalculatePermitFees(zoningType, renovationType, floorAreaSqm, blueprintCost) +end) diff --git a/FiveM/nyx_properties/shared/building_permits.lua b/FiveM/nyx_properties/shared/building_permits.lua new file mode 100644 index 0000000..341a70d --- /dev/null +++ b/FiveM/nyx_properties/shared/building_permits.lua @@ -0,0 +1,316 @@ +--[[ + Nyxframework: nyx_properties + File: shared/building_permits.lua + Description: Deterministic Commercial Property Zoning, Architectural Blueprint Validation, + Permit Fee Calculation Engine, Treasury Split Math & Permit State Machine. +]] + +Nyx = Nyx or {} +Nyx.BuildingPermits = Nyx.BuildingPermits or {} + +-- Cent-precision rounding helper +local function roundToCent(val) + val = tonumber(val) or 0.0 + return math.floor(val * 100.0 + 0.5) / 100.0 +end + +Nyx.BuildingPermits.RoundToCent = roundToCent + +-- ---------------------------------------------------------------------------- +-- 1. Statutory Zoning Rules Taxonomy +-- ---------------------------------------------------------------------------- +Nyx.BuildingPermits.ZoningRules = { + retail_storefront = { + label = "Retail Commercial District", + maxFloorAreaSqm = 1500, + maxHeightMeters = 20.00, + basePermitFee = 450.00, + inspectionFeeFlat = 200.00, + environmentalSurchargeRate = 0.0150, + requiresCertifiedBlueprint = true, + autoApprovalEligible = true + }, + corporate_office = { + label = "Corporate Executive District", + maxFloorAreaSqm = 3000, + maxHeightMeters = 60.00, + basePermitFee = 850.00, + inspectionFeeFlat = 350.00, + environmentalSurchargeRate = 0.0200, + requiresCertifiedBlueprint = true, + autoApprovalEligible = false + }, + industrial_warehouse = { + label = "Industrial Logistics Zone", + maxFloorAreaSqm = 10000, + maxHeightMeters = 35.00, + basePermitFee = 1200.00, + inspectionFeeFlat = 500.00, + environmentalSurchargeRate = 0.0350, + requiresCertifiedBlueprint = true, + autoApprovalEligible = false + }, + hospitality_venue = { + label = "Commercial Entertainment Sector", + maxFloorAreaSqm = 2500, + maxHeightMeters = 25.00, + basePermitFee = 650.00, + inspectionFeeFlat = 300.00, + environmentalSurchargeRate = 0.0250, + requiresCertifiedBlueprint = true, + autoApprovalEligible = false + }, + medical_clinic = { + label = "Public Health & Clinical Complex", + maxFloorAreaSqm = 2000, + maxHeightMeters = 30.00, + basePermitFee = 750.00, + inspectionFeeFlat = 400.00, + environmentalSurchargeRate = 0.0100, + requiresCertifiedBlueprint = true, + autoApprovalEligible = false + } +} + +-- ---------------------------------------------------------------------------- +-- 2. Renovation Types Taxonomy +-- ---------------------------------------------------------------------------- +Nyx.BuildingPermits.RenovationTypes = { + interior_remodel = { + label = "Interior Storefront Remodel", + rateMultiplier = 1.00, + capacityBonusKg = 250, + registerSlotBonus = 1, + minStructuralTier = 1 + }, + facade_modernization = { + label = "Exterior Facade Modernization", + rateMultiplier = 1.20, + capacityBonusKg = 100, + registerSlotBonus = 0, + minStructuralTier = 1 + }, + structural_expansion = { + label = "Structural Mezzanine Expansion", + rateMultiplier = 1.80, + capacityBonusKg = 1000, + registerSlotBonus = 2, + minStructuralTier = 2 + }, + safety_upgrade = { + label = "Fire & Safety System Retrofit", + rateMultiplier = 0.80, + capacityBonusKg = 0, + registerSlotBonus = 0, + minStructuralTier = 1 + }, + industrial_conversion = { + label = "Heavy Industrial Storage Conversion", + rateMultiplier = 2.20, + capacityBonusKg = 3000, + registerSlotBonus = 3, + minStructuralTier = 3 + } +} + +-- ---------------------------------------------------------------------------- +-- 3. Pure Deterministic Fee Calculation +-- ---------------------------------------------------------------------------- +--- Calculates building permit fees, environmental surcharges, and treasury splits +-- @param zoningType string +-- @param renovationType string +-- @param floorAreaSqm number +-- @param blueprintCost number +-- @param lawsCatalog table|nil +-- @return table +function Nyx.BuildingPermits.CalculatePermitFees(zoningType, renovationType, floorAreaSqm, blueprintCost, lawsCatalog) + local zoningRule = Nyx.BuildingPermits.ZoningRules[zoningType] or Nyx.BuildingPermits.ZoningRules.retail_storefront + local renovationRule = Nyx.BuildingPermits.RenovationTypes[renovationType] or Nyx.BuildingPermits.RenovationTypes.interior_remodel + local area = math.max(1, tonumber(floorAreaSqm) or 100) + local bpCost = math.max(0.0, roundToCent(blueprintCost or 0.0)) + + local basePermitFee = roundToCent(zoningRule.basePermitFee * renovationRule.rateMultiplier) + local inspectionFee = roundToCent(zoningRule.inspectionFeeFlat) + + local envRate = zoningRule.environmentalSurchargeRate + if lawsCatalog and lawsCatalog["zoning:industrial_environmental_surcharge"] then + local lawData = lawsCatalog["zoning:industrial_environmental_surcharge"] + if type(lawData) == "table" and lawData.surcharge then + envRate = tonumber(lawData.surcharge) or envRate + end + end + + local envSurcharge = roundToCent(area * envRate * 10.0) + local totalPermitFee = roundToCent(basePermitFee + inspectionFee + envSurcharge) + local totalProjectCost = roundToCent(totalPermitFee + bpCost) + + -- Statutory Treasury Splits + local civilWorksSplit = roundToCent(basePermitFee + envSurcharge) + local inspectionPoolSplit = roundToCent(inspectionFee) + + return { + zoningType = zoningType, + renovationType = renovationType, + floorAreaSqm = area, + basePermitFee = basePermitFee, + inspectionFee = inspectionFee, + environmentalSurcharge = envSurcharge, + environmentalSurchargeRate = envRate, + blueprintCost = bpCost, + totalPermitFee = totalPermitFee, + totalProjectCost = totalProjectCost, + treasurySplit = { + civilWorksFund = civilWorksSplit, + inspectionPool = inspectionPoolSplit, + blueprintEscrow = bpCost + } + } +end + +-- ---------------------------------------------------------------------------- +-- 4. Architectural Blueprint Validation +-- ---------------------------------------------------------------------------- +--- Validates whether an architectural blueprint is eligible for a property renovation +-- @param blueprintData table +-- @param propertyZoning string +-- @param propertyLimits table|nil +-- @return boolean, string|nil +function Nyx.BuildingPermits.ValidateBlueprint(blueprintData, propertyZoning, propertyLimits) + if not blueprintData or type(blueprintData) ~= "table" then + return false, "Missing or invalid architectural blueprint payload" + end + + local targetZoning = blueprintData.target_zoning or blueprintData.targetZoning + if targetZoning and targetZoning ~= propertyZoning then + return false, string.format("Blueprint target zoning '%s' does not match property zoning '%s'", targetZoning, propertyZoning) + end + + local zoningRule = Nyx.BuildingPermits.ZoningRules[propertyZoning] or Nyx.BuildingPermits.ZoningRules.retail_storefront + local maxArea = propertyLimits and propertyLimits.maxFloorAreaSqm or zoningRule.maxFloorAreaSqm + local bpArea = tonumber(blueprintData.floor_area_sqm or blueprintData.floorAreaSqm) or 0 + + if bpArea > maxArea then + return false, string.format("Blueprint floor area (%d sqm) exceeds statutory maximum (%d sqm)", bpArea, maxArea) + end + + if zoningRule.requiresCertifiedBlueprint then + local isCertified = blueprintData.is_certified + if isCertified == nil then + isCertified = blueprintData.isCertified + end + if isCertified == 0 or isCertified == false or isCertified == nil then + return false, "Property zoning requires a certified architectural blueprint" + end + end + + return true, nil +end + +-- ---------------------------------------------------------------------------- +-- 5. Auto-Approval Evaluation +-- ---------------------------------------------------------------------------- +--- Evaluates whether a permit application qualifies for automated municipal approval +-- @param permitData table +-- @return boolean +function Nyx.BuildingPermits.EvaluateAutoApproval(permitData) + if not permitData or type(permitData) ~= "table" then + return false + end + + local zoningRule = Nyx.BuildingPermits.ZoningRules[permitData.zoningType] + if not zoningRule or not zoningRule.autoApprovalEligible then + return false + end + + local renovationRule = Nyx.BuildingPermits.RenovationTypes[permitData.renovationType] + if not renovationRule or renovationRule.minStructuralTier > 1 then + return false + end + + local area = tonumber(permitData.floorAreaSqm) or 0 + if area > 500 then + return false + end + + return true +end + +-- ---------------------------------------------------------------------------- +-- 6. Permit Life-Cycle State Machine +-- ---------------------------------------------------------------------------- +--- Pure transition state machine for building permit workflow +-- @param currentStatus string +-- @param action string +-- @param context table|nil +-- @return string, string|nil +function Nyx.BuildingPermits.TransitionPermitState(currentStatus, action, context) + action = string.upper(action or "") + currentStatus = string.upper(currentStatus or "SUBMITTED") + + if action == "AUTO_APPROVE" then + if currentStatus == "SUBMITTED" then + return "APPROVED", nil + else + return currentStatus, "Auto-approval only valid from SUBMITTED state" + end + elseif action == "REQUEST_INSPECTION" then + if currentStatus == "SUBMITTED" then + return "INSPECTION_PENDING", nil + else + return currentStatus, "Inspection request only valid from SUBMITTED state" + end + elseif action == "APPROVE" then + if currentStatus == "SUBMITTED" or currentStatus == "INSPECTION_PENDING" then + return "APPROVED", nil + else + return currentStatus, string.format("Cannot approve permit from '%s' status", currentStatus) + end + elseif action == "REJECT" then + if currentStatus == "SUBMITTED" or currentStatus == "INSPECTION_PENDING" then + return "REJECTED", nil + else + return currentStatus, string.format("Cannot reject permit from '%s' status", currentStatus) + end + elseif action == "COMPLETE" then + if currentStatus == "APPROVED" then + return "COMPLETED", nil + else + return currentStatus, string.format("Cannot complete renovation; permit status is '%s', expected 'APPROVED'", currentStatus) + end + elseif action == "REVOKE" then + if currentStatus == "APPROVED" or currentStatus == "INSPECTION_PENDING" or currentStatus == "SUBMITTED" then + return "REVOKED", nil + else + return currentStatus, string.format("Cannot revoke permit from '%s' status", currentStatus) + end + end + + return currentStatus, string.format("Invalid permit state action '%s'", action) +end + +-- ---------------------------------------------------------------------------- +-- 7. Renovation Property Bonus Calculation +-- ---------------------------------------------------------------------------- +--- Calculates updated stash capacity and register slot totals after renovation completion +-- @param renovationType string +-- @param currentStashKg number +-- @param currentRegisters number +-- @return table +function Nyx.BuildingPermits.CalculateRenovationPropertyBonus(renovationType, currentStashKg, currentRegisters) + local reno = Nyx.BuildingPermits.RenovationTypes[renovationType] or Nyx.BuildingPermits.RenovationTypes.interior_remodel + local baseStash = math.max(0, tonumber(currentStashKg) or 1000) + local baseRegs = math.max(1, tonumber(currentRegisters) or 2) + + local newStash = baseStash + reno.capacityBonusKg + local newRegs = baseRegs + reno.registerSlotBonus + + return { + renovationType = renovationType, + previousStashCapacityKg = baseStash, + newStashCapacityKg = newStash, + previousRegisterSlots = baseRegs, + newRegisterSlots = newRegs, + bonusStashKg = reno.capacityBonusKg, + bonusRegisters = reno.registerSlotBonus + } +end diff --git a/FiveM/nyx_properties/sql/migrations/067_property_zoning_and_building_permits.sql b/FiveM/nyx_properties/sql/migrations/067_property_zoning_and_building_permits.sql new file mode 100644 index 0000000..f16fcf8 --- /dev/null +++ b/FiveM/nyx_properties/sql/migrations/067_property_zoning_and_building_permits.sql @@ -0,0 +1,92 @@ +-- ============================================================================ +-- Nyxframework: nyx_properties, nyx_treasury, nyx_factions & nyx_parliament +-- Migration: 067_property_zoning_and_building_permits.sql +-- Description: Commercial renovation approvals, municipal inspection fee routing, +-- architectural blueprint validation, statutory zoning rules, +-- and environmental impact surcharges. +-- Engine: InnoDB | Charset: utf8mb4 | Collation: utf8mb4_unicode_ci +-- ============================================================================ + +SET FOREIGN_KEY_CHECKS = 0; + +-- 1. Create Property Zoning Rules Table +CREATE TABLE IF NOT EXISTS `nyx_property_zoning_rules` ( + `zoning_type` VARCHAR(32) NOT NULL PRIMARY KEY, + `label` VARCHAR(128) NOT NULL, + `max_floor_area_sqm` INT NOT NULL DEFAULT 2000, + `max_height_meters` DECIMAL(6, 2) NOT NULL DEFAULT 25.00, + `base_permit_fee` DECIMAL(10, 2) NOT NULL DEFAULT 500.00, + `inspection_fee_flat` DECIMAL(10, 2) NOT NULL DEFAULT 250.00, + `environmental_surcharge_rate` DECIMAL(5, 4) NOT NULL DEFAULT 0.0200, + `requires_certified_blueprint` TINYINT(1) NOT NULL DEFAULT 1, + `auto_approval_eligible` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_zoning_auto` (`auto_approval_eligible`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. Create Architectural Blueprints Table +CREATE TABLE IF NOT EXISTS `nyx_architectural_blueprints` ( + `blueprint_id` VARCHAR(64) NOT NULL PRIMARY KEY, + `architect_identifier` VARCHAR(64) NOT NULL, + `title` VARCHAR(128) NOT NULL, + `target_zoning` VARCHAR(32) NOT NULL, + `floor_area_sqm` INT NOT NULL DEFAULT 250, + `structural_tier` INT NOT NULL DEFAULT 1, + `blueprint_cost` DECIMAL(10, 2) NOT NULL DEFAULT 1000.00, + `is_certified` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_bp_architect` (`architect_identifier`), + INDEX `idx_bp_zoning` (`target_zoning`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. Create Building Permits Table +CREATE TABLE IF NOT EXISTS `nyx_building_permits` ( + `permit_id` VARCHAR(32) NOT NULL PRIMARY KEY, + `property_id` VARCHAR(64) NOT NULL, + `applicant_identifier` VARCHAR(64) NOT NULL, + `applicant_faction` VARCHAR(64) NULL, + `zoning_type` VARCHAR(32) NOT NULL, + `renovation_type` ENUM('interior_remodel', 'facade_modernization', 'structural_expansion', 'safety_upgrade', 'industrial_conversion') NOT NULL DEFAULT 'interior_remodel', + `blueprint_id` VARCHAR(64) NOT NULL, + `floor_area_sqm` INT NOT NULL DEFAULT 150, + `base_permit_fee` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `inspection_fee` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `environmental_surcharge` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `blueprint_cost` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `total_cost` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `status` ENUM('SUBMITTED', 'INSPECTION_PENDING', 'APPROVED', 'REJECTED', 'COMPLETED', 'REVOKED') NOT NULL DEFAULT 'SUBMITTED', + `inspector_identifier` VARCHAR(64) NULL, + `rejection_reason` TEXT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `approved_at` TIMESTAMP NULL DEFAULT NULL, + `completed_at` TIMESTAMP NULL DEFAULT NULL, + INDEX `idx_permit_prop` (`property_id`), + INDEX `idx_permit_applicant` (`applicant_identifier`), + INDEX `idx_permit_status` (`status`), + INDEX `idx_permit_zoning` (`zoning_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 4. Seed Statutory Property Zoning Rules +INSERT INTO `nyx_property_zoning_rules` + (`zoning_type`, `label`, `max_floor_area_sqm`, `max_height_meters`, `base_permit_fee`, `inspection_fee_flat`, `environmental_surcharge_rate`, `requires_certified_blueprint`, `auto_approval_eligible`) +VALUES + ('retail_storefront', 'Retail Commercial District', 1500, 20.00, 450.00, 200.00, 0.0150, 1, 1), + ('corporate_office', 'Corporate Executive District', 3000, 60.00, 850.00, 350.00, 0.0200, 1, 0), + ('industrial_warehouse', 'Industrial Logistics Zone', 10000, 35.00, 1200.00, 500.00, 0.0350, 1, 0), + ('hospitality_venue', 'Commercial Entertainment Sector', 2500, 25.00, 650.00, 300.00, 0.0250, 1, 0), + ('medical_clinic', 'Public Health & Clinical Complex', 2000, 30.00, 750.00, 400.00, 0.0100, 1, 0) +ON DUPLICATE KEY UPDATE `label` = VALUES(`label`); + +-- 5. Seed Standard Architectural Blueprints +INSERT INTO `nyx_architectural_blueprints` + (`blueprint_id`, `architect_identifier`, `title`, `target_zoning`, `floor_area_sqm`, `structural_tier`, `blueprint_cost`, `is_certified`) +VALUES + ('bp_retail_mod_v1', 'arch_city_planning', 'Standard Retail Interior Modernization', 'retail_storefront', 250, 1, 750.00, 1), + ('bp_office_expansion_v1', 'arch_city_planning', 'Executive Office Suite Expansion', 'corporate_office', 600, 2, 1500.00, 1), + ('bp_warehouse_mezzanine_v1', 'arch_city_planning', 'Heavy Industrial Mezzanine Blueprint', 'industrial_warehouse', 2500, 3, 3000.00, 1), + ('bp_hospitality_lounge_v1', 'arch_city_planning', 'Commercial Dining & Lounge Layout', 'hospitality_venue', 500, 2, 1250.00, 1) +ON DUPLICATE KEY UPDATE `title` = VALUES(`title`); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/FiveM/nyx_treasury/sql/migrations/067_property_zoning_and_building_permits.sql b/FiveM/nyx_treasury/sql/migrations/067_property_zoning_and_building_permits.sql new file mode 100644 index 0000000..f16fcf8 --- /dev/null +++ b/FiveM/nyx_treasury/sql/migrations/067_property_zoning_and_building_permits.sql @@ -0,0 +1,92 @@ +-- ============================================================================ +-- Nyxframework: nyx_properties, nyx_treasury, nyx_factions & nyx_parliament +-- Migration: 067_property_zoning_and_building_permits.sql +-- Description: Commercial renovation approvals, municipal inspection fee routing, +-- architectural blueprint validation, statutory zoning rules, +-- and environmental impact surcharges. +-- Engine: InnoDB | Charset: utf8mb4 | Collation: utf8mb4_unicode_ci +-- ============================================================================ + +SET FOREIGN_KEY_CHECKS = 0; + +-- 1. Create Property Zoning Rules Table +CREATE TABLE IF NOT EXISTS `nyx_property_zoning_rules` ( + `zoning_type` VARCHAR(32) NOT NULL PRIMARY KEY, + `label` VARCHAR(128) NOT NULL, + `max_floor_area_sqm` INT NOT NULL DEFAULT 2000, + `max_height_meters` DECIMAL(6, 2) NOT NULL DEFAULT 25.00, + `base_permit_fee` DECIMAL(10, 2) NOT NULL DEFAULT 500.00, + `inspection_fee_flat` DECIMAL(10, 2) NOT NULL DEFAULT 250.00, + `environmental_surcharge_rate` DECIMAL(5, 4) NOT NULL DEFAULT 0.0200, + `requires_certified_blueprint` TINYINT(1) NOT NULL DEFAULT 1, + `auto_approval_eligible` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_zoning_auto` (`auto_approval_eligible`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. Create Architectural Blueprints Table +CREATE TABLE IF NOT EXISTS `nyx_architectural_blueprints` ( + `blueprint_id` VARCHAR(64) NOT NULL PRIMARY KEY, + `architect_identifier` VARCHAR(64) NOT NULL, + `title` VARCHAR(128) NOT NULL, + `target_zoning` VARCHAR(32) NOT NULL, + `floor_area_sqm` INT NOT NULL DEFAULT 250, + `structural_tier` INT NOT NULL DEFAULT 1, + `blueprint_cost` DECIMAL(10, 2) NOT NULL DEFAULT 1000.00, + `is_certified` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_bp_architect` (`architect_identifier`), + INDEX `idx_bp_zoning` (`target_zoning`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. Create Building Permits Table +CREATE TABLE IF NOT EXISTS `nyx_building_permits` ( + `permit_id` VARCHAR(32) NOT NULL PRIMARY KEY, + `property_id` VARCHAR(64) NOT NULL, + `applicant_identifier` VARCHAR(64) NOT NULL, + `applicant_faction` VARCHAR(64) NULL, + `zoning_type` VARCHAR(32) NOT NULL, + `renovation_type` ENUM('interior_remodel', 'facade_modernization', 'structural_expansion', 'safety_upgrade', 'industrial_conversion') NOT NULL DEFAULT 'interior_remodel', + `blueprint_id` VARCHAR(64) NOT NULL, + `floor_area_sqm` INT NOT NULL DEFAULT 150, + `base_permit_fee` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `inspection_fee` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `environmental_surcharge` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `blueprint_cost` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `total_cost` DECIMAL(10, 2) NOT NULL DEFAULT 0.00, + `status` ENUM('SUBMITTED', 'INSPECTION_PENDING', 'APPROVED', 'REJECTED', 'COMPLETED', 'REVOKED') NOT NULL DEFAULT 'SUBMITTED', + `inspector_identifier` VARCHAR(64) NULL, + `rejection_reason` TEXT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `approved_at` TIMESTAMP NULL DEFAULT NULL, + `completed_at` TIMESTAMP NULL DEFAULT NULL, + INDEX `idx_permit_prop` (`property_id`), + INDEX `idx_permit_applicant` (`applicant_identifier`), + INDEX `idx_permit_status` (`status`), + INDEX `idx_permit_zoning` (`zoning_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 4. Seed Statutory Property Zoning Rules +INSERT INTO `nyx_property_zoning_rules` + (`zoning_type`, `label`, `max_floor_area_sqm`, `max_height_meters`, `base_permit_fee`, `inspection_fee_flat`, `environmental_surcharge_rate`, `requires_certified_blueprint`, `auto_approval_eligible`) +VALUES + ('retail_storefront', 'Retail Commercial District', 1500, 20.00, 450.00, 200.00, 0.0150, 1, 1), + ('corporate_office', 'Corporate Executive District', 3000, 60.00, 850.00, 350.00, 0.0200, 1, 0), + ('industrial_warehouse', 'Industrial Logistics Zone', 10000, 35.00, 1200.00, 500.00, 0.0350, 1, 0), + ('hospitality_venue', 'Commercial Entertainment Sector', 2500, 25.00, 650.00, 300.00, 0.0250, 1, 0), + ('medical_clinic', 'Public Health & Clinical Complex', 2000, 30.00, 750.00, 400.00, 0.0100, 1, 0) +ON DUPLICATE KEY UPDATE `label` = VALUES(`label`); + +-- 5. Seed Standard Architectural Blueprints +INSERT INTO `nyx_architectural_blueprints` + (`blueprint_id`, `architect_identifier`, `title`, `target_zoning`, `floor_area_sqm`, `structural_tier`, `blueprint_cost`, `is_certified`) +VALUES + ('bp_retail_mod_v1', 'arch_city_planning', 'Standard Retail Interior Modernization', 'retail_storefront', 250, 1, 750.00, 1), + ('bp_office_expansion_v1', 'arch_city_planning', 'Executive Office Suite Expansion', 'corporate_office', 600, 2, 1500.00, 1), + ('bp_warehouse_mezzanine_v1', 'arch_city_planning', 'Heavy Industrial Mezzanine Blueprint', 'industrial_warehouse', 2500, 3, 3000.00, 1), + ('bp_hospitality_lounge_v1', 'arch_city_planning', 'Commercial Dining & Lounge Layout', 'hospitality_venue', 500, 2, 1250.00, 1) +ON DUPLICATE KEY UPDATE `title` = VALUES(`title`); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/tests/test_property_zoning_and_building_permits.py b/tests/test_property_zoning_and_building_permits.py new file mode 100644 index 0000000..4417adc --- /dev/null +++ b/tests/test_property_zoning_and_building_permits.py @@ -0,0 +1,363 @@ +""" +Nyxframework: Property Zoning & Building Permits Test Suite +Tests fee calculations, treasury splits, blueprint validation, auto-approval thresholds, +state machine transitions, and renovation property capacity upgrades. +""" + +import math +import os +import pytest + + +class BuildingPermitEngine: + ZoningRules = { + "retail_storefront": { + "label": "Retail Commercial District", + "maxFloorAreaSqm": 1500, + "maxHeightMeters": 20.00, + "basePermitFee": 450.00, + "inspectionFeeFlat": 200.00, + "environmentalSurchargeRate": 0.0150, + "requiresCertifiedBlueprint": True, + "autoApprovalEligible": True, + }, + "corporate_office": { + "label": "Corporate Executive District", + "maxFloorAreaSqm": 3000, + "maxHeightMeters": 60.00, + "basePermitFee": 850.00, + "inspectionFeeFlat": 350.00, + "environmentalSurchargeRate": 0.0200, + "requiresCertifiedBlueprint": True, + "autoApprovalEligible": False, + }, + "industrial_warehouse": { + "label": "Industrial Logistics Zone", + "maxFloorAreaSqm": 10000, + "maxHeightMeters": 35.00, + "basePermitFee": 1200.00, + "inspectionFeeFlat": 500.00, + "environmentalSurchargeRate": 0.0350, + "requiresCertifiedBlueprint": True, + "autoApprovalEligible": False, + }, + "hospitality_venue": { + "label": "Commercial Entertainment Sector", + "maxFloorAreaSqm": 2500, + "maxHeightMeters": 25.00, + "basePermitFee": 650.00, + "inspectionFeeFlat": 300.00, + "environmentalSurchargeRate": 0.0250, + "requiresCertifiedBlueprint": True, + "autoApprovalEligible": False, + }, + "medical_clinic": { + "label": "Public Health & Clinical Complex", + "maxFloorAreaSqm": 2000, + "maxHeightMeters": 30.00, + "basePermitFee": 750.00, + "inspectionFeeFlat": 400.00, + "environmentalSurchargeRate": 0.0100, + "requiresCertifiedBlueprint": True, + "autoApprovalEligible": False, + }, + } + + RenovationTypes = { + "interior_remodel": { + "label": "Interior Storefront Remodel", + "rateMultiplier": 1.00, + "capacityBonusKg": 250, + "registerSlotBonus": 1, + "minStructuralTier": 1, + }, + "facade_modernization": { + "label": "Exterior Facade Modernization", + "rateMultiplier": 1.20, + "capacityBonusKg": 100, + "registerSlotBonus": 0, + "minStructuralTier": 1, + }, + "structural_expansion": { + "label": "Structural Mezzanine Expansion", + "rateMultiplier": 1.80, + "capacityBonusKg": 1000, + "registerSlotBonus": 2, + "minStructuralTier": 2, + }, + "safety_upgrade": { + "label": "Fire & Safety System Retrofit", + "rateMultiplier": 0.80, + "capacityBonusKg": 0, + "registerSlotBonus": 0, + "minStructuralTier": 1, + }, + "industrial_conversion": { + "label": "Heavy Industrial Storage Conversion", + "rateMultiplier": 2.20, + "capacityBonusKg": 3000, + "registerSlotBonus": 3, + "minStructuralTier": 3, + }, + } + + @staticmethod + def round_to_cent(val): + return math.floor(float(val) * 100.0 + 0.5) / 100.0 + + @classmethod + def calculate_permit_fees(cls, zoning_type, renovation_type, floor_area_sqm, blueprint_cost, laws_catalog=None): + zoning_rule = cls.ZoningRules.get(zoning_type, cls.ZoningRules["retail_storefront"]) + renovation_rule = cls.RenovationTypes.get(renovation_type, cls.RenovationTypes["interior_remodel"]) + area = max(1, int(floor_area_sqm or 100)) + bp_cost = max(0.0, cls.round_to_cent(blueprint_cost or 0.0)) + + base_permit_fee = cls.round_to_cent(zoning_rule["basePermitFee"] * renovation_rule["rateMultiplier"]) + inspection_fee = cls.round_to_cent(zoning_rule["inspectionFeeFlat"]) + + env_rate = zoning_rule["environmentalSurchargeRate"] + if laws_catalog and "zoning:industrial_environmental_surcharge" in laws_catalog: + law_data = laws_catalog["zoning:industrial_environmental_surcharge"] + if isinstance(law_data, dict) and "surcharge" in law_data: + env_rate = float(law_data["surcharge"]) + + env_surcharge = cls.round_to_cent(area * env_rate * 10.0) + total_permit_fee = cls.round_to_cent(base_permit_fee + inspection_fee + env_surcharge) + total_project_cost = cls.round_to_cent(total_permit_fee + bp_cost) + + civil_works_split = cls.round_to_cent(base_permit_fee + env_surcharge) + inspection_pool_split = cls.round_to_cent(inspection_fee) + + return { + "zoningType": zoning_type, + "renovationType": renovation_type, + "floorAreaSqm": area, + "basePermitFee": base_permit_fee, + "inspectionFee": inspection_fee, + "environmentalSurcharge": env_surcharge, + "environmentalSurchargeRate": env_rate, + "blueprintCost": bp_cost, + "totalPermitFee": total_permit_fee, + "totalProjectCost": total_project_cost, + "treasurySplit": { + "civilWorksFund": civil_works_split, + "inspectionPool": inspection_pool_split, + "blueprintEscrow": bp_cost, + }, + } + + @classmethod + def validate_blueprint(cls, blueprint_data, property_zoning, property_limits=None): + if not blueprint_data or not isinstance(blueprint_data, dict): + return False, "Missing or invalid architectural blueprint payload" + + target_zoning = blueprint_data.get("target_zoning") or blueprint_data.get("targetZoning") + if target_zoning and target_zoning != property_zoning: + return False, f"Blueprint target zoning '{target_zoning}' does not match property zoning '{property_zoning}'" + + zoning_rule = cls.ZoningRules.get(property_zoning, cls.ZoningRules["retail_storefront"]) + max_area = property_limits.get("maxFloorAreaSqm") if property_limits else zoning_rule["maxFloorAreaSqm"] + bp_area = int(blueprint_data.get("floor_area_sqm") or blueprint_data.get("floorAreaSqm") or 0) + + if bp_area > max_area: + return False, f"Blueprint floor area ({bp_area} sqm) exceeds statutory maximum ({max_area} sqm)" + + if zoning_rule["requiresCertifiedBlueprint"]: + is_certified = blueprint_data.get("is_certified") + if is_certified is None: + is_certified = blueprint_data.get("isCertified") + if is_certified is False or is_certified == 0 or is_certified is None: + return False, "Property zoning requires a certified architectural blueprint" + + return True, None + + @classmethod + def evaluate_auto_approval(cls, permit_data): + if not permit_data or not isinstance(permit_data, dict): + return False + + zoning_rule = cls.ZoningRules.get(permit_data.get("zoningType")) + if not zoning_rule or not zoning_rule.get("autoApprovalEligible"): + return False + + renovation_rule = cls.RenovationTypes.get(permit_data.get("renovationType")) + if not renovation_rule or renovation_rule.get("minStructuralTier", 1) > 1: + return False + + area = int(permit_data.get("floorAreaSqm") or 0) + if area > 500: + return False + + return True + + @classmethod + def transition_permit_state(cls, current_status, action, context=None): + action = str(action or "").upper() + current_status = str(current_status or "SUBMITTED").upper() + + if action == "AUTO_APPROVE": + if current_status == "SUBMITTED": + return "APPROVED", None + return current_status, "Auto-approval only valid from SUBMITTED state" + elif action == "REQUEST_INSPECTION": + if current_status == "SUBMITTED": + return "INSPECTION_PENDING", None + return current_status, "Inspection request only valid from SUBMITTED state" + elif action == "APPROVE": + if current_status in ["SUBMITTED", "INSPECTION_PENDING"]: + return "APPROVED", None + return current_status, f"Cannot approve permit from '{current_status}' status" + elif action == "REJECT": + if current_status in ["SUBMITTED", "INSPECTION_PENDING"]: + return "REJECTED", None + return current_status, f"Cannot reject permit from '{current_status}' status" + elif action == "COMPLETE": + if current_status == "APPROVED": + return "COMPLETED", None + return current_status, f"Cannot complete renovation; permit status is '{current_status}', expected 'APPROVED'" + elif action == "REVOKE": + if current_status in ["APPROVED", "INSPECTION_PENDING", "SUBMITTED"]: + return "REVOKED", None + return current_status, f"Cannot revoke permit from '{current_status}' status" + + return current_status, f"Invalid permit state action '{action}'" + + @classmethod + def calculate_renovation_property_bonus(cls, renovation_type, current_stash_kg, current_registers): + reno = cls.RenovationTypes.get(renovation_type, cls.RenovationTypes["interior_remodel"]) + base_stash = max(0, int(current_stash_kg or 1000)) + base_regs = max(1, int(current_registers or 2)) + + new_stash = base_stash + reno["capacityBonusKg"] + new_regs = base_regs + reno["registerSlotBonus"] + + return { + "renovationType": renovation_type, + "previousStashCapacityKg": base_stash, + "newStashCapacityKg": new_stash, + "previousRegisterSlots": base_regs, + "newRegisterSlots": new_regs, + "bonusStashKg": reno["capacityBonusKg"], + "bonusRegisters": reno["registerSlotBonus"], + } + + +# ---------------------------------------------------------------------------- +# Test Cases +# ---------------------------------------------------------------------------- + +def test_permit_fee_calculations(): + fees = BuildingPermitEngine.calculate_permit_fees("retail_storefront", "interior_remodel", 250, 750.00) + assert fees["basePermitFee"] == 450.00 + assert fees["inspectionFee"] == 200.00 + assert fees["environmentalSurcharge"] == 37.50 + assert fees["blueprintCost"] == 750.00 + assert fees["totalPermitFee"] == 687.50 + assert fees["totalProjectCost"] == 1437.50 + assert fees["treasurySplit"]["civilWorksFund"] == 487.50 + assert fees["treasurySplit"]["inspectionPool"] == 200.00 + + +def test_permit_fee_calculations_heavy_industrial(): + fees = BuildingPermitEngine.calculate_permit_fees("industrial_warehouse", "industrial_conversion", 2500, 3000.00) + # basePermitFee = 1200 * 2.2 = 2640.00 + assert fees["basePermitFee"] == 2640.00 + assert fees["inspectionFee"] == 500.00 + # envSurcharge = 2500 * 0.035 * 10 = 875.00 + assert fees["environmentalSurcharge"] == 875.00 + assert fees["totalPermitFee"] == 4015.00 + assert fees["totalProjectCost"] == 7015.00 + + +def test_blueprint_validation_success(): + bp = { + "blueprint_id": "bp_retail_mod_v1", + "target_zoning": "retail_storefront", + "floor_area_sqm": 250, + "is_certified": True, + } + is_valid, err = BuildingPermitEngine.validate_blueprint(bp, "retail_storefront") + assert is_valid is True + assert err is None + + +def test_blueprint_validation_mismatched_zoning(): + bp = { + "blueprint_id": "bp_office_expansion_v1", + "target_zoning": "corporate_office", + "floor_area_sqm": 600, + "is_certified": True, + } + is_valid, err = BuildingPermitEngine.validate_blueprint(bp, "retail_storefront") + assert is_valid is False + assert "target zoning 'corporate_office' does not match" in err + + +def test_blueprint_validation_uncertified(): + bp = { + "blueprint_id": "bp_uncertified_v1", + "target_zoning": "retail_storefront", + "floor_area_sqm": 200, + "is_certified": False, + } + is_valid, err = BuildingPermitEngine.validate_blueprint(bp, "retail_storefront") + assert is_valid is False + assert "requires a certified architectural blueprint" in err + + +def test_auto_approval_evaluation(): + # Eligible: retail_storefront, interior_remodel (tier 1), floorArea <= 500 + permit = { + "zoningType": "retail_storefront", + "renovationType": "interior_remodel", + "floorAreaSqm": 250, + "blueprintCertified": True, + } + assert BuildingPermitEngine.evaluate_auto_approval(permit) is True + + # Ineligible: corporate_office does not allow auto-approval + permit["zoningType"] = "corporate_office" + assert BuildingPermitEngine.evaluate_auto_approval(permit) is False + + # Ineligible: structural_expansion is tier 2 + permit["zoningType"] = "retail_storefront" + permit["renovationType"] = "structural_expansion" + assert BuildingPermitEngine.evaluate_auto_approval(permit) is False + + +def test_permit_state_machine_transitions(): + state, err = BuildingPermitEngine.transition_permit_state("SUBMITTED", "REQUEST_INSPECTION") + assert state == "INSPECTION_PENDING" + assert err is None + + state, err = BuildingPermitEngine.transition_permit_state("INSPECTION_PENDING", "APPROVE") + assert state == "APPROVED" + assert err is None + + state, err = BuildingPermitEngine.transition_permit_state("APPROVED", "COMPLETE") + assert state == "COMPLETED" + assert err is None + + # Invalid completion from SUBMITTED + state, err = BuildingPermitEngine.transition_permit_state("SUBMITTED", "COMPLETE") + assert state == "SUBMITTED" + assert "expected 'APPROVED'" in err + + +def test_renovation_property_bonuses(): + bonus = BuildingPermitEngine.calculate_renovation_property_bonus("industrial_conversion", 10000, 2) + assert bonus["newStashCapacityKg"] == 13000 + assert bonus["newRegisterSlots"] == 5 + assert bonus["bonusStashKg"] == 3000 + assert bonus["bonusRegisters"] == 3 + + +def test_sql_migration_files_exist(): + migration_name = "067_property_zoning_and_building_permits.sql" + paths = [ + os.path.join("FiveM", "nyx_core", "sql", "migrations", migration_name), + os.path.join("FiveM", "nyx_properties", "sql", "migrations", migration_name), + os.path.join("FiveM", "nyx_treasury", "sql", "migrations", migration_name), + ] + for path in paths: + assert os.path.exists(path), f"Migration file missing: {path}"