diff --git a/FiveM/nyx_core/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql b/FiveM/nyx_core/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql new file mode 100644 index 0000000..6041cf8 --- /dev/null +++ b/FiveM/nyx_core/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql @@ -0,0 +1,105 @@ +-- ============================================================================ +-- Nyxframework: nyx_parliament, nyx_treasury, nyx_ui +-- Migration: 067_campaign_finance_and_citizen_os_polling.sql +-- Description: Political Action Committees (PACs), statutory campaign finance donation +-- caps, independent PAC compliance auditing & statutory fines, and +-- Citizen OS mobile opinion surveys & voter sentiment polling. +-- Engine: InnoDB | Charset: utf8mb4 | Collation: utf8mb4_unicode_ci +-- ============================================================================ + +SET FOREIGN_KEY_CHECKS = 0; + +-- 1. Political Action Committees (PACs) Registry +CREATE TABLE IF NOT EXISTS `nyx_parliament_pacs` ( + `pac_id` VARCHAR(64) PRIMARY KEY, + `candidate_id` VARCHAR(64) NULL, + `office_id` VARCHAR(32) NULL, + `title` VARCHAR(128) NOT NULL, + `treasurer_citizen_id` VARCHAR(16) NOT NULL, + `balance` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `total_donations_collected` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `status` ENUM('active', 'audited', 'suspended', 'dissolved') NOT NULL DEFAULT 'active', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_pac_candidate` (`candidate_id`), + INDEX `idx_pac_office` (`office_id`), + INDEX `idx_pac_treasurer` (`treasurer_citizen_id`), + INDEX `idx_pac_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. Campaign Finance Donations Ledger +CREATE TABLE IF NOT EXISTS `nyx_parliament_donations` ( + `donation_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `donation_uuid` VARCHAR(36) NOT NULL UNIQUE, + `pac_id` VARCHAR(64) NOT NULL, + `donor_citizen_id` VARCHAR(16) NOT NULL, + `donor_faction_id` VARCHAR(64) NULL, + `amount` DECIMAL(15, 2) NOT NULL, + `cap_limit_applied` DECIMAL(15, 2) NOT NULL DEFAULT 5000.00, + `compliance_status` ENUM('compliant', 'flagged_over_cap', 'refunded', 'audited') NOT NULL DEFAULT 'compliant', + `treasury_tx_uuid` VARCHAR(36) NULL, + `donated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_donation_pac` (`pac_id`), + INDEX `idx_donation_donor` (`donor_citizen_id`), + INDEX `idx_donation_compliance` (`compliance_status`), + INDEX `idx_donation_created` (`donated_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. PAC Statutory Compliance Audits Ledger +CREATE TABLE IF NOT EXISTS `nyx_parliament_pac_audits` ( + `audit_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `audit_uuid` VARCHAR(36) NOT NULL UNIQUE, + `pac_id` VARCHAR(64) NOT NULL, + `auditor_citizen_id` VARCHAR(16) NOT NULL DEFAULT 'SYSTEM_PAC_AUDITOR', + `total_audited_contributions` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `flagged_excess_amount` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `fine_assessed` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `audit_status` ENUM('passed', 'violations_found', 'fines_assessed', 'cleared') NOT NULL DEFAULT 'passed', + `notes` VARCHAR(255) NULL, + `audited_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_pac_audit_pac` (`pac_id`), + INDEX `idx_pac_audit_status` (`audit_status`), + INDEX `idx_pac_audit_created` (`audited_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 4. Citizen OS Mobile Opinion Surveys & Sentiment Polls +CREATE TABLE IF NOT EXISTS `nyx_parliament_polls` ( + `poll_id` VARCHAR(64) PRIMARY KEY, + `title` VARCHAR(128) NOT NULL, + `description` TEXT NOT NULL, + `creator_citizen_id` VARCHAR(16) NOT NULL DEFAULT 'SYSTEM_GOV', + `poll_type` ENUM('approval_rating', 'policy_survey', 'civic_referendum') NOT NULL DEFAULT 'policy_survey', + `office_id` VARCHAR(32) NULL, + `options_json` JSON NOT NULL, + `total_responses` INT UNSIGNED NOT NULL DEFAULT 0, + `starts_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `ends_at` TIMESTAMP NOT NULL, + `status` ENUM('active', 'concluded', 'archived') NOT NULL DEFAULT 'active', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_poll_status` (`status`), + INDEX `idx_poll_office` (`office_id`), + INDEX `idx_poll_ends` (`ends_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 5. Citizen OS Mobile Poll Responses Registry +CREATE TABLE IF NOT EXISTS `nyx_parliament_poll_responses` ( + `response_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `poll_id` VARCHAR(64) NOT NULL, + `citizen_id` VARCHAR(16) NOT NULL, + `voter_hash` VARCHAR(64) NOT NULL, + `selected_option` VARCHAR(64) NOT NULL, + `device_os` VARCHAR(32) NOT NULL DEFAULT 'CitizenOS_Mobile', + `submitted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_poll_citizen` (`poll_id`, `citizen_id`), + UNIQUE KEY `uk_poll_hash` (`poll_id`, `voter_hash`), + INDEX `idx_poll_resp_poll` (`poll_id`), + INDEX `idx_poll_resp_citizen` (`citizen_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 6. Seed Campaign Finance PAC Escrow Account in nyx_treasury_accounts +INSERT INTO `nyx_treasury_accounts` + (`account_id`, `label`, `balance`, `type`, `signatories_required`) +VALUES + ('gov:escrow:pac', 'Municipal Campaign Finance PAC Reserve Account', 0.00, 'reserve', 1) +ON DUPLICATE KEY UPDATE `label` = VALUES(`label`); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/FiveM/nyx_core/sql/migrations/index.json b/FiveM/nyx_core/sql/migrations/index.json index 56b42d6..4e5cd6d 100644 --- a/FiveM/nyx_core/sql/migrations/index.json +++ b/FiveM/nyx_core/sql/migrations/index.json @@ -65,5 +65,5 @@ "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", - "067_municipal_water_utility_and_hydrant_pressure.sql" + "067_campaign_finance_and_citizen_os_polling.sql" ] diff --git a/FiveM/nyx_parliament/client/pacs.lua b/FiveM/nyx_parliament/client/pacs.lua new file mode 100644 index 0000000..a50cc1e --- /dev/null +++ b/FiveM/nyx_parliament/client/pacs.lua @@ -0,0 +1,37 @@ +--[[ + Nyxframework: nyx_parliament + File: client/pacs.lua + Description: Client-side Political Action Committee (PAC) interactions & campaign finance bridge. +]] + +Nyx = Nyx or {} +Nyx.Parliament = Nyx.Parliament or {} + +-- Donate to PAC Client Method +function Nyx.Parliament.DonateToPAC(pacId, amount, donorFactionId) + local res = Nyx.RPC.Call("parliament:donateToPAC", { + pacId = pacId, + amount = amount, + donorFactionId = donorFactionId + }) + return res +end + +-- Register PAC Client Method +function Nyx.Parliament.RegisterPAC(candidateId, officeId, title) + local res = Nyx.RPC.Call("parliament:registerPAC", { + candidateId = candidateId, + officeId = officeId, + title = title + }) + return res +end + +-- Exports +exports('DonateToPAC', function(pacId, amount, donorFactionId) + return Nyx.Parliament.DonateToPAC(pacId, amount, donorFactionId) +end) + +exports('RegisterPAC', function(candidateId, officeId, title) + return Nyx.Parliament.RegisterPAC(candidateId, officeId, title) +end) diff --git a/FiveM/nyx_parliament/client/polling.lua b/FiveM/nyx_parliament/client/polling.lua new file mode 100644 index 0000000..5f27461 --- /dev/null +++ b/FiveM/nyx_parliament/client/polling.lua @@ -0,0 +1,34 @@ +--[[ + Nyxframework: nyx_parliament + File: client/polling.lua + Description: Client-side Citizen OS mobile opinion survey notification handlers & NUI bridge. +]] + +Nyx = Nyx or {} +Nyx.Parliament = Nyx.Parliament or {} + +-- New Mobile Poll Broadcast Handler +RegisterNetEvent("nyx:parliament:client:newMobilePoll", function(pollData) + if type(pollData) == "table" then + TriggerEvent("nyx:core:client:notify", + string.format("CITIZEN OS POLL: New mobile opinion survey: '%s'. Open Smartphone to respond.", pollData.title or "Public Poll"), + "info" + ) + end +end) + +-- Submit Mobile Poll Response via RPC +function Nyx.Parliament.SubmitMobilePollResponse(pollId, selectedOption) + local voterHash = string.format("hw_%d_%s", GetGameTimer(), tostring(GetPlayerServerId(PlayerId()))) + local res = Nyx.RPC.Call("parliament:submitPollResponse", { + pollId = pollId, + selectedOption = selectedOption, + voterHash = voterHash + }) + return res +end + +-- Export for Citizen OS Smartphone UI +exports('SubmitMobilePollResponse', function(pollId, selectedOption) + return Nyx.Parliament.SubmitMobilePollResponse(pollId, selectedOption) +end) diff --git a/FiveM/nyx_parliament/config.lua b/FiveM/nyx_parliament/config.lua index b95885b..ab0eab3 100644 --- a/FiveM/nyx_parliament/config.lua +++ b/FiveM/nyx_parliament/config.lua @@ -18,6 +18,12 @@ Config.VotingWindowHours = 72 -- Hours allowed for active voting Config.MaxCandidatesPerSession = 10 -- Max candidate ceiling per election session Config.EnforceBoothProximity = false -- When true, requires physical presence at a booth to cast ballot +-- Statutory Campaign Finance & Citizen OS Polling +Config.StatutoryDonationCap = 5000.00 -- Max statutory donation cap per donor per election cycle ($) +Config.PACRegistrationFee = 1000.00 -- Collateral fee to register a Political Action Committee ($) +Config.PACAuditFineMultiplier = 1.5 -- Statutory fine multiplier for over-cap PAC contributions (150%) +Config.MobilePollDurationHours = 72 -- Default duration for Citizen OS mobile opinion surveys (hours) + -- Elective Municipal Offices Config.Offices = { ['mayor'] = { diff --git a/FiveM/nyx_parliament/fxmanifest.lua b/FiveM/nyx_parliament/fxmanifest.lua index 281e6e0..0d89c6b 100644 --- a/FiveM/nyx_parliament/fxmanifest.lua +++ b/FiveM/nyx_parliament/fxmanifest.lua @@ -18,6 +18,7 @@ shared_scripts { 'shared/irv.lua', 'shared/legislation_ui.lua', 'shared/elections.lua', + 'shared/campaign_finance.lua', 'shared/statutory_enforcement.lua' } @@ -26,6 +27,8 @@ server_scripts { 'server/statutes.lua', 'server/enactment.lua', 'server/elections.lua', + 'server/campaign_finance.lua', + 'server/polling.lua', 'server/tabulation.lua', 'server/voting.lua', 'server/main.lua', @@ -35,6 +38,8 @@ server_scripts { client_scripts { 'client/booths.lua', 'client/legislation_ui.lua', + 'client/polling.lua', + 'client/pacs.lua', 'client/main.lua', 'client/exports.lua' } diff --git a/FiveM/nyx_parliament/server/campaign_finance.lua b/FiveM/nyx_parliament/server/campaign_finance.lua new file mode 100644 index 0000000..172ee28 --- /dev/null +++ b/FiveM/nyx_parliament/server/campaign_finance.lua @@ -0,0 +1,333 @@ +--[[ + Nyxframework: nyx_parliament + File: server/campaign_finance.lua + Description: Authoritative server logic for Political Action Committee (PAC) registration, + statutory donation caps, treasury transfers, and compliance audits. +]] + +Nyx = Nyx or {} +Nyx.Parliament = Nyx.Parliament or {} + +-- 1. Register a Political Action Committee (PAC) +function Nyx.Parliament.RegisterPAC(src, candidateId, officeId, title) + local player = exports['nyx_core']:GetPlayer(src) + if not player then return { success = false, error = "UNAUTHORIZED" } end + + if not title or type(title) ~= "string" or #title < 3 then + return { success = false, error = "INVALID_PAC_TITLE" } + end + + local citId = player.citizenId + local fee = Config.PACRegistrationFee or 1000.00 + + if fee > 0.0 then + local debitAccount = "char:" .. citId .. ":bank" + local creditAccount = "gov:escrow:pac" + + local balance = exports['nyx_treasury']:GetAccountBalance(debitAccount) or 0.0 + if balance < fee then + return { success = false, error = "INSUFFICIENT_FUNDS_FOR_REGISTRATION", required = fee } + end + + local okTransfer, transferErr = exports['nyx_treasury']:ExecuteTransfer( + debitAccount, + creditAccount, + fee, + "pac_registration_fee", + "parliament:pac_reg:" .. citId, + string.format("PAC Registration Fee: %s by %s", title, citId), + 0.0 + ) + + if not okTransfer then + return { success = false, error = "REGISTRATION_ESCROW_FAILED", detail = transferErr } + end + end + + local sanitizeTitle = title:gsub("[^%w%s]", ""):gsub("%s+", "_"):lower() + local pacId = string.format("pac:%s_%s", sanitizeTitle:sub(1, 24), Nyx.RPC.GenerateNonce():sub(1, 6)) + + -- Create dedicated PAC treasury account + pcall(function() + MySQL.prepare.await([[ + INSERT INTO `nyx_treasury_accounts` + (`account_id`, `label`, `balance`, `type`, `signatories_required`) + VALUES (?, ?, 0.00, 'reserve', 1) + ON DUPLICATE KEY UPDATE `label` = VALUES(`label`) + ]], { "account:" .. pacId, string.format("PAC Reserve: %s", title) }) + end) + + local inserted = MySQL.insert.await([[ + INSERT INTO `nyx_parliament_pacs` + (`pac_id`, `candidate_id`, `office_id`, `title`, `treasurer_citizen_id`, `balance`, `total_donations_collected`, `status`) + VALUES (?, ?, ?, ?, ?, 0.00, 0.00, 'active') + ]], { + pacId, + candidateId, + officeId, + title, + citId + }) + + if not inserted then + return { success = false, error = "DATABASE_INSERTION_FAILED" } + end + + print(string.format("^2[Nyx:Parliament] PAC '%s' (%s) registered by treasurer %s (Fee: $%.2f)^7", + title, pacId, citId, fee)) + + return { + success = true, + pacId = pacId, + title = title, + treasurerCitizenId = citId, + balance = 0.00 + } +end + +-- 2. Donate to a Political Action Committee with Statutory Cap Enforcement +function Nyx.Parliament.DonateToPAC(src, pacId, amount, donorFactionId) + local player = exports['nyx_core']:GetPlayer(src) + if not player then return { success = false, error = "UNAUTHORIZED" } end + + if not pacId or type(pacId) ~= "string" then + return { success = false, error = "INVALID_PAC_ID" } + end + + local amt = tonumber(amount) or 0.0 + if amt <= 0.0 then + return { success = false, error = "INVALID_DONATION_AMOUNT" } + end + + local pac = MySQL.single.await([[ + SELECT * FROM `nyx_parliament_pacs` WHERE `pac_id` = ? + ]], { pacId }) + + if not pac then + return { success = false, error = "PAC_NOT_FOUND" } + end + + if pac.status ~= "active" and pac.status ~= "audited" then + return { success = false, error = "PAC_NOT_ACTIVE" } + end + + local citId = player.citizenId + local cap = Config.StatutoryDonationCap or 5000.00 + + -- Query donor's cumulative existing contributions to this PAC + local priorTotal = MySQL.scalar.await([[ + SELECT COALESCE(SUM(`amount`), 0.0) + FROM `nyx_parliament_donations` + WHERE `pac_id` = ? AND `donor_citizen_id` = ? AND `compliance_status` != 'refunded' + ]], { pacId, citId }) or 0.0 + + local okElig, evalReason, evalData = Nyx.CampaignFinance.EvaluateDonationEligibility( + citId, + priorTotal, + amt, + cap, + donorFactionId ~= nil + ) + + if not okElig then + return { success = false, error = evalReason, detail = evalData } + end + + local debitAccount = "char:" .. citId .. ":bank" + if donorFactionId and type(donorFactionId) == "string" and #donorFactionId > 0 then + debitAccount = "faction:" .. donorFactionId .. ":vault" + end + + local creditAccount = "gov:escrow:pac" + + local balance = exports['nyx_treasury']:GetAccountBalance(debitAccount) or 0.0 + if balance < amt then + return { success = false, error = "INSUFFICIENT_FUNDS_FOR_DONATION", required = amt } + end + + local okTransfer, txResult = exports['nyx_treasury']:ExecuteTransfer( + debitAccount, + creditAccount, + amt, + "campaign_donation", + "parliament:" .. pacId, + string.format("Campaign Finance Donation to %s by %s", pac.title, citId), + 0.0 + ) + + if not okTransfer then + return { success = false, error = "TREASURY_TRANSFER_FAILED", detail = txResult } + end + + local txUuid = type(txResult) == "table" and txResult.txId or tostring(txResult or "TX-DONATION") + local donationUuid = string.format("don_%s_%s", citId, Nyx.RPC.GenerateNonce():sub(1, 8)) + local complianceStatus = evalData and evalData.complianceStatus or "compliant" + + MySQL.insert.await([[ + INSERT INTO `nyx_parliament_donations` + (`donation_uuid`, `pac_id`, `donor_citizen_id`, `donor_faction_id`, `amount`, `cap_limit_applied`, `compliance_status`, `treasury_tx_uuid`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { + donationUuid, + pacId, + citId, + donorFactionId, + amt, + cap, + complianceStatus, + txUuid + }) + + -- Update PAC balance and total collected + MySQL.prepare.await([[ + UPDATE `nyx_parliament_pacs` + SET `balance` = `balance` + ?, `total_donations_collected` = `total_donations_collected` + ? + WHERE `pac_id` = ? + ]], { amt, amt, pacId }) + + print(string.format("^2[Nyx:Parliament] Donation $%.2f received for PAC '%s' from %s (Compliance: %s, Tx: %s)^7", + amt, pac.title, citId, complianceStatus, txUuid)) + + return { + success = true, + donationUuid = donationUuid, + amount = amt, + complianceStatus = complianceStatus, + allowedAmount = evalData and evalData.allowedAmount or amt, + excessAmount = evalData and evalData.excessAmount or 0.0, + txUuid = txUuid + } +end + +-- 3. Perform PAC Statutory Audit & Assess Violations / Fines +function Nyx.Parliament.AuditPAC(src, pacId) + if not pacId or type(pacId) ~= "string" then + return { success = false, error = "INVALID_PAC_ID" } + end + + local pac = MySQL.single.await([[ + SELECT * FROM `nyx_parliament_pacs` WHERE `pac_id` = ? + ]], { pacId }) + + if not pac then + return { success = false, error = "PAC_NOT_FOUND" } + end + + local donations = MySQL.query.await([[ + SELECT * FROM `nyx_parliament_donations` WHERE `pac_id` = ? + ]], { pacId }) or {} + + local auditorCitId = "SYSTEM_PAC_AUDITOR" + if src and src > 0 then + local p = exports['nyx_core']:GetPlayer(src) + if p then auditorCitId = p.citizenId end + end + + local cap = Config.StatutoryDonationCap or 5000.00 + local mult = Config.PACAuditFineMultiplier or 1.5 + + local summary = Nyx.CampaignFinance.CalculatePACAuditSummary(donations, cap, mult) + local auditUuid = string.format("audit_%s_%s", pacId:gsub("[^%w]", ""):sub(1, 16), Nyx.RPC.GenerateNonce():sub(1, 6)) + + if summary.fineAssessed > 0.0 then + -- Execute statutory fine debit from PAC escrow to city treasury master account + pcall(function() + exports['nyx_treasury']:ExecuteTransfer( + "gov:escrow:pac", + "gov:city:master", + summary.fineAssessed, + "pac_statutory_fine", + "parliament:audit:" .. auditUuid, + string.format("Statutory PAC Campaign Finance Fine: %s", pac.title), + 0.0 + ) + end) + end + + MySQL.insert.await([[ + INSERT INTO `nyx_parliament_pac_audits` + (`audit_uuid`, `pac_id`, `auditor_citizen_id`, `total_audited_contributions`, + `flagged_excess_amount`, `fine_assessed`, `audit_status`, `notes`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { + auditUuid, + pacId, + auditorCitId, + summary.totalContributions, + summary.flaggedExcessAmount, + summary.fineAssessed, + summary.auditStatus, + string.format("Audited %d contributions. Flagged: %d (Excess: $%.2f, Fine: $%.2f)", + summary.totalCount, summary.flaggedCount, summary.flaggedExcessAmount, summary.fineAssessed) + }) + + MySQL.prepare.await([[ + UPDATE `nyx_parliament_pacs` SET `status` = 'audited' WHERE `pac_id` = ? + ]], { pacId }) + + print(string.format("^3[Nyx:Parliament] PAC Audit complete for '%s' (%s): Status = %s, Excess = $%.2f, Fine = $%.2f^7", + pac.title, pacId, summary.auditStatus, summary.flaggedExcessAmount, summary.fineAssessed)) + + return { + success = true, + auditUuid = auditUuid, + auditSummary = summary + } +end + +-- ============================================================================ +-- RPC Registrations for Campaign Finance & PACs +-- ============================================================================ + +Nyx.RPC.Register("parliament:registerPAC", function(src, payload) + if type(payload) ~= "table" then return { success = false, error = "INVALID_PAYLOAD" } end + return Nyx.Parliament.RegisterPAC(src, payload.candidateId, payload.officeId, payload.title) +end) + +Nyx.RPC.Register("parliament:donateToPAC", function(src, payload) + if type(payload) ~= "table" then return { success = false, error = "INVALID_PAYLOAD" } end + return Nyx.Parliament.DonateToPAC(src, payload.pacId, payload.amount, payload.donorFactionId) +end) + +Nyx.RPC.Register("parliament:auditPAC", function(src, pacId) + return Nyx.Parliament.AuditPAC(src, pacId) +end) + +Nyx.RPC.Register("parliament:getPACs", function(src) + local rows = MySQL.query.await([[ + SELECT `pac_id`, `candidate_id`, `office_id`, `title`, `treasurer_citizen_id`, + `balance`, `total_donations_collected`, `status`, `created_at` + FROM `nyx_parliament_pacs` + ORDER BY `total_donations_collected` DESC + ]]) + return rows or {} +end) + +Nyx.RPC.Register("parliament:getPACDonations", function(src, pacId) + if not pacId then return {} end + local rows = MySQL.query.await([[ + SELECT `donation_id`, `donation_uuid`, `pac_id`, `donor_citizen_id`, `donor_faction_id`, + `amount`, `cap_limit_applied`, `compliance_status`, `treasury_tx_uuid`, `donated_at` + FROM `nyx_parliament_donations` + WHERE `pac_id` = ? + ORDER BY `donated_at` DESC + ]], { pacId }) + return rows or {} +end) + +Nyx.RPC.Register("parliament:getPACAudits", function(src, pacId) + local query = [[ + SELECT `audit_id`, `audit_uuid`, `pac_id`, `auditor_citizen_id`, `total_audited_contributions`, + `flagged_excess_amount`, `fine_assessed`, `audit_status`, `notes`, `audited_at` + FROM `nyx_parliament_pac_audits` + ]] + local params = {} + if pacId and #pacId > 0 then + query = query .. " WHERE `pac_id` = ?" + params[#params + 1] = pacId + end + query = query .. " ORDER BY `audited_at` DESC LIMIT 50" + + local rows = MySQL.query.await(query, params) + return rows or {} +end) diff --git a/FiveM/nyx_parliament/server/polling.lua b/FiveM/nyx_parliament/server/polling.lua new file mode 100644 index 0000000..6d66d39 --- /dev/null +++ b/FiveM/nyx_parliament/server/polling.lua @@ -0,0 +1,216 @@ +--[[ + Nyxframework: nyx_parliament + File: server/polling.lua + Description: Citizen OS mobile opinion surveys, voter sentiment tracking, and response deduplication. +]] + +Nyx = Nyx or {} +Nyx.Parliament = Nyx.Parliament or {} + +-- 1. Create a Citizen OS Mobile Survey / Opinion Poll +function Nyx.Parliament.CreateMobilePoll(src, title, description, pollType, officeId, optionsList, durationHours) + local creatorCitId = "SYSTEM_GOV" + if src and src > 0 then + local player = exports['nyx_core']:GetPlayer(src) + if player then creatorCitId = player.citizenId end + end + + if not title or type(title) ~= "string" or #title < 3 then + return { success = false, error = "INVALID_POLL_TITLE" } + end + + if not optionsList or type(optionsList) ~= "table" or #optionsList < 2 then + return { success = false, error = "AT_LEAST_TWO_OPTIONS_REQUIRED" } + end + + local sanitizeTitle = title:gsub("[^%w%s]", ""):gsub("%s+", "_"):lower() + local pollId = string.format("poll:%s_%s", sanitizeTitle:sub(1, 24), Nyx.RPC.GenerateNonce():sub(1, 6)) + local pType = pollType or "policy_survey" + local hours = tonumber(durationHours) or Config.MobilePollDurationHours or 72 + + local optionsJson = json.encode(optionsList) + + local inserted = MySQL.prepare.await([[ + INSERT INTO `nyx_parliament_polls` + (`poll_id`, `title`, `description`, `creator_citizen_id`, `poll_type`, `office_id`, + `options_json`, `total_responses`, `starts_at`, `ends_at`, `status`) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? HOUR), 'active') + ]], { + pollId, + title, + description or "Citizen OS Opinion Survey", + creatorCitId, + pType, + officeId, + optionsJson, + hours + }) + + if not inserted then + return { success = false, error = "DATABASE_INSERTION_FAILED" } + end + + print(string.format("^2[Nyx:Parliament] Created Citizen OS Mobile Poll '%s' (%s, Type: %s, %d hours)^7", + title, pollId, pType, hours)) + + TriggerClientEvent("nyx:parliament:client:newMobilePoll", -1, { + pollId = pollId, + title = title, + pollType = pType, + endsInHours = hours + }) + + return { + success = true, + pollId = pollId, + title = title, + pollType = pType, + options = optionsList + } +end + +-- 2. Submit a Mobile Survey Response with Deduplication +function Nyx.Parliament.SubmitPollResponse(src, pollId, selectedOption, clientHash) + local player = exports['nyx_core']:GetPlayer(src) + if not player then return { success = false, error = "UNAUTHORIZED" } end + + if not pollId or type(pollId) ~= "string" or not selectedOption or type(selectedOption) ~= "string" then + return { success = false, error = "INVALID_POLL_PARAMETERS" } + end + + local poll = MySQL.single.await([[ + SELECT * FROM `nyx_parliament_polls` + WHERE `poll_id` = ? AND `status` = 'active' AND `ends_at` >= CURRENT_TIMESTAMP + ]], { pollId }) + + if not poll then + return { success = false, error = "POLL_NOT_ACTIVE_OR_EXPIRED" } + end + + local citId = player.citizenId + local voterHash = clientHash + if not voterHash or #voterHash < 8 then + voterHash = Nyx.RPC.GenerateNonce():sub(1, 16) + end + + -- Deduplication check by citizen ID or voter hash + local alreadyResponded = MySQL.scalar.await([[ + SELECT COUNT(*) FROM `nyx_parliament_poll_responses` + WHERE `poll_id` = ? AND (`citizen_id` = ? OR `voter_hash` = ?) + ]], { pollId, citId, voterHash }) or 0 + + if alreadyResponded > 0 then + return { success = false, error = "ALREADY_SUBMITTED_POLL_RESPONSE" } + end + + local inserted = MySQL.insert.await([[ + INSERT INTO `nyx_parliament_poll_responses` + (`poll_id`, `citizen_id`, `voter_hash`, `selected_option`, `device_os`) + VALUES (?, ?, ?, ?, 'CitizenOS_Mobile') + ]], { + pollId, + citId, + voterHash, + selectedOption + }) + + if not inserted then + return { success = false, error = "DATABASE_INSERTION_FAILED" } + end + + MySQL.prepare.await([[ + UPDATE `nyx_parliament_polls` SET `total_responses` = `total_responses` + 1 WHERE `poll_id` = ? + ]], { pollId }) + + print(string.format("^2[Nyx:Parliament] Poll response recorded for '%s' from citizen %s (Option: %s)^7", + pollId, citId, selectedOption)) + + return { + success = true, + pollId = pollId, + selectedOption = selectedOption + } +end + +-- 3. Calculate and Retrieve Poll Results & Voter Sentiment +function Nyx.Parliament.GetPollResults(pollId) + if not pollId or type(pollId) ~= "string" then + return { success = false, error = "INVALID_POLL_ID" } + end + + local poll = MySQL.single.await([[ + SELECT * FROM `nyx_parliament_polls` WHERE `poll_id` = ? + ]], { pollId }) + + if not poll then + return { success = false, error = "POLL_NOT_FOUND" } + end + + local responseRows = MySQL.query.await([[ + SELECT `selected_option`, COUNT(*) AS `cnt` + FROM `nyx_parliament_poll_responses` + WHERE `poll_id` = ? + GROUP BY `selected_option` + ]], { pollId }) or {} + + local counts = {} + for _, r in ipairs(responseRows) do + counts[r.selected_option] = tonumber(r.cnt) or 0 + end + + local sentiment = Nyx.CampaignFinance.CalculatePollSentiment(poll.options_json, counts, poll.total_responses) + + return { + success = true, + pollId = pollId, + title = poll.title, + description = poll.description, + pollType = poll.poll_type, + status = poll.status, + totalResponses = poll.total_responses, + sentiment = sentiment + } +end + +-- ============================================================================ +-- RPC Registrations for Citizen OS Polling +-- ============================================================================ + +Nyx.RPC.Register("parliament:createMobilePoll", function(src, payload) + if type(payload) ~= "table" then return { success = false, error = "INVALID_PAYLOAD" } end + return Nyx.Parliament.CreateMobilePoll( + src, + payload.title, + payload.description, + payload.pollType, + payload.officeId, + payload.options, + payload.durationHours + ) +end) + +Nyx.RPC.Register("parliament:submitPollResponse", function(src, payload) + if type(payload) ~= "table" then return { success = false, error = "INVALID_PAYLOAD" } end + return Nyx.Parliament.SubmitPollResponse(src, payload.pollId, payload.selectedOption, payload.voterHash) +end) + +Nyx.RPC.Register("parliament:getPollResults", function(src, pollId) + return Nyx.Parliament.GetPollResults(pollId) +end) + +Nyx.RPC.Register("parliament:getMobilePolls", function(src, status) + local query = [[ + SELECT `poll_id`, `title`, `description`, `creator_citizen_id`, `poll_type`, + `office_id`, `options_json`, `total_responses`, `starts_at`, `ends_at`, `status`, `created_at` + FROM `nyx_parliament_polls` + ]] + local params = {} + if status and #status > 0 then + query = query .. " WHERE `status` = ?" + params[#params + 1] = status + end + query = query .. " ORDER BY `created_at` DESC LIMIT 20" + + local rows = MySQL.query.await(query, params) or {} + return rows +end) diff --git a/FiveM/nyx_parliament/shared/campaign_finance.lua b/FiveM/nyx_parliament/shared/campaign_finance.lua new file mode 100644 index 0000000..11c559a --- /dev/null +++ b/FiveM/nyx_parliament/shared/campaign_finance.lua @@ -0,0 +1,157 @@ +--[[ + Nyxframework: nyx_parliament + File: shared/campaign_finance.lua + Description: Pure deterministic Lua formulas for campaign finance donation caps, + PAC statutory compliance auditing, and Citizen OS mobile opinion poll sentiment calculation. +]] + +Nyx = Nyx or {} +Nyx.CampaignFinance = Nyx.CampaignFinance or {} + +-- 1. Evaluate Campaign Finance Donation Cap & Compliance Status +function Nyx.CampaignFinance.EvaluateDonationEligibility(donorCitizenId, donorPriorTotal, donationAmount, statutoryCap, isFactionDonation) + local cap = tonumber(statutoryCap) or 5000.00 + local prior = tonumber(donorPriorTotal) or 0.0 + local amt = tonumber(donationAmount) or 0.0 + + if amt <= 0.0 then + return false, "INVALID_DONATION_AMOUNT", { + allowedAmount = 0.0, + excessAmount = 0.0, + remainingCap = math.max(0.0, cap - prior), + complianceStatus = "invalid" + } + end + + local remainingCap = math.max(0.0, cap - prior) + + if prior >= cap then + return false, "STATUTORY_CAP_EXCEEDED", { + allowedAmount = 0.0, + excessAmount = amt, + remainingCap = 0.0, + complianceStatus = "flagged_over_cap" + } + end + + if (prior + amt) > cap then + local allowed = remainingCap + local excess = amt - remainingCap + return true, "CAP_EXCEEDED_PARTIAL", { + allowedAmount = allowed, + excessAmount = excess, + remainingCap = 0.0, + complianceStatus = "flagged_over_cap" + } + end + + return true, "COMPLIANT", { + allowedAmount = amt, + excessAmount = 0.0, + remainingCap = math.max(0.0, cap - (prior + amt)), + complianceStatus = "compliant" + } +end + +-- 2. Calculate PAC Audit Summary & Statutory Fine Assessment +function Nyx.CampaignFinance.CalculatePACAuditSummary(donationsList, statutoryCap, fineMultiplier) + local mult = tonumber(fineMultiplier) or 1.5 + local cap = tonumber(statutoryCap) or 5000.00 + + local totalContributions = 0.0 + local compliantContributions = 0.0 + local flaggedExcessAmount = 0.0 + local flaggedCount = 0 + local totalCount = 0 + + if type(donationsList) == "table" then + for _, don in ipairs(donationsList) do + totalCount = totalCount + 1 + local amt = tonumber(don.amount) or 0.0 + totalContributions = totalContributions + amt + + if don.compliance_status == "flagged_over_cap" or (don.amount and tonumber(don.amount) > cap) then + flaggedCount = flaggedCount + 1 + local excess = math.max(0.0, amt - cap) + flaggedExcessAmount = flaggedExcessAmount + excess + else + compliantContributions = compliantContributions + amt + end + end + end + + local fineAssessed = flaggedExcessAmount * mult + local auditStatus = "passed" + if flaggedExcessAmount > 0.0 then + auditStatus = "fines_assessed" + end + + local riskScore = 0.0 + if totalContributions > 0.0 then + riskScore = math.min(100.0, (flaggedExcessAmount / totalContributions) * 100.0) + end + + return { + totalContributions = totalContributions, + compliantContributions = compliantContributions, + flaggedExcessAmount = flaggedExcessAmount, + fineAssessed = fineAssessed, + flaggedCount = flaggedCount, + totalCount = totalCount, + auditStatus = auditStatus, + riskScore = riskScore + } +end + +-- 3. Calculate Citizen OS Mobile Poll Voter Sentiment & Margin of Error +function Nyx.CampaignFinance.CalculatePollSentiment(pollOptions, responseCounts, totalTurnout) + local total = tonumber(totalTurnout) or 0 + local optionsMap = {} + local breakdown = {} + local leadingOption = nil + local maxVotes = -1 + + if type(pollOptions) == "string" then + local ok, parsed = pcall(json.decode, pollOptions) + if ok and type(parsed) == "table" then + pollOptions = parsed + else + pollOptions = {} + end + end + + responseCounts = responseCounts or {} + + for _, opt in ipairs(pollOptions or {}) do + local optId = type(opt) == "table" and (opt.id or opt.value) or tostring(opt) + local optLabel = type(opt) == "table" and (opt.label or opt.name or optId) or tostring(opt) + local count = tonumber(responseCounts[optId]) or 0 + + local pct = total > 0 and ((count / total) * 100.0) or 0.0 + + if count > maxVotes then + maxVotes = count + leadingOption = optId + end + + breakdown[optId] = { + id = optId, + label = optLabel, + count = count, + percentage = math.floor(pct * 100 + 0.5) / 100 + } + end + + -- Margin of Error formula: 1.96 * sqrt(0.25 / N) * 100 + local marginOfError = 0.0 + if total > 0 then + marginOfError = math.floor((1.96 * math.sqrt(0.25 / total) * 100.0) * 100 + 0.5) / 100 + end + + return { + totalTurnout = total, + leadingOption = leadingOption, + breakdown = breakdown, + marginOfErrorPercent = marginOfError + } +end diff --git a/FiveM/nyx_parliament/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql b/FiveM/nyx_parliament/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql new file mode 100644 index 0000000..6041cf8 --- /dev/null +++ b/FiveM/nyx_parliament/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql @@ -0,0 +1,105 @@ +-- ============================================================================ +-- Nyxframework: nyx_parliament, nyx_treasury, nyx_ui +-- Migration: 067_campaign_finance_and_citizen_os_polling.sql +-- Description: Political Action Committees (PACs), statutory campaign finance donation +-- caps, independent PAC compliance auditing & statutory fines, and +-- Citizen OS mobile opinion surveys & voter sentiment polling. +-- Engine: InnoDB | Charset: utf8mb4 | Collation: utf8mb4_unicode_ci +-- ============================================================================ + +SET FOREIGN_KEY_CHECKS = 0; + +-- 1. Political Action Committees (PACs) Registry +CREATE TABLE IF NOT EXISTS `nyx_parliament_pacs` ( + `pac_id` VARCHAR(64) PRIMARY KEY, + `candidate_id` VARCHAR(64) NULL, + `office_id` VARCHAR(32) NULL, + `title` VARCHAR(128) NOT NULL, + `treasurer_citizen_id` VARCHAR(16) NOT NULL, + `balance` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `total_donations_collected` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `status` ENUM('active', 'audited', 'suspended', 'dissolved') NOT NULL DEFAULT 'active', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_pac_candidate` (`candidate_id`), + INDEX `idx_pac_office` (`office_id`), + INDEX `idx_pac_treasurer` (`treasurer_citizen_id`), + INDEX `idx_pac_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. Campaign Finance Donations Ledger +CREATE TABLE IF NOT EXISTS `nyx_parliament_donations` ( + `donation_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `donation_uuid` VARCHAR(36) NOT NULL UNIQUE, + `pac_id` VARCHAR(64) NOT NULL, + `donor_citizen_id` VARCHAR(16) NOT NULL, + `donor_faction_id` VARCHAR(64) NULL, + `amount` DECIMAL(15, 2) NOT NULL, + `cap_limit_applied` DECIMAL(15, 2) NOT NULL DEFAULT 5000.00, + `compliance_status` ENUM('compliant', 'flagged_over_cap', 'refunded', 'audited') NOT NULL DEFAULT 'compliant', + `treasury_tx_uuid` VARCHAR(36) NULL, + `donated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_donation_pac` (`pac_id`), + INDEX `idx_donation_donor` (`donor_citizen_id`), + INDEX `idx_donation_compliance` (`compliance_status`), + INDEX `idx_donation_created` (`donated_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. PAC Statutory Compliance Audits Ledger +CREATE TABLE IF NOT EXISTS `nyx_parliament_pac_audits` ( + `audit_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `audit_uuid` VARCHAR(36) NOT NULL UNIQUE, + `pac_id` VARCHAR(64) NOT NULL, + `auditor_citizen_id` VARCHAR(16) NOT NULL DEFAULT 'SYSTEM_PAC_AUDITOR', + `total_audited_contributions` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `flagged_excess_amount` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `fine_assessed` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `audit_status` ENUM('passed', 'violations_found', 'fines_assessed', 'cleared') NOT NULL DEFAULT 'passed', + `notes` VARCHAR(255) NULL, + `audited_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_pac_audit_pac` (`pac_id`), + INDEX `idx_pac_audit_status` (`audit_status`), + INDEX `idx_pac_audit_created` (`audited_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 4. Citizen OS Mobile Opinion Surveys & Sentiment Polls +CREATE TABLE IF NOT EXISTS `nyx_parliament_polls` ( + `poll_id` VARCHAR(64) PRIMARY KEY, + `title` VARCHAR(128) NOT NULL, + `description` TEXT NOT NULL, + `creator_citizen_id` VARCHAR(16) NOT NULL DEFAULT 'SYSTEM_GOV', + `poll_type` ENUM('approval_rating', 'policy_survey', 'civic_referendum') NOT NULL DEFAULT 'policy_survey', + `office_id` VARCHAR(32) NULL, + `options_json` JSON NOT NULL, + `total_responses` INT UNSIGNED NOT NULL DEFAULT 0, + `starts_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `ends_at` TIMESTAMP NOT NULL, + `status` ENUM('active', 'concluded', 'archived') NOT NULL DEFAULT 'active', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_poll_status` (`status`), + INDEX `idx_poll_office` (`office_id`), + INDEX `idx_poll_ends` (`ends_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 5. Citizen OS Mobile Poll Responses Registry +CREATE TABLE IF NOT EXISTS `nyx_parliament_poll_responses` ( + `response_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `poll_id` VARCHAR(64) NOT NULL, + `citizen_id` VARCHAR(16) NOT NULL, + `voter_hash` VARCHAR(64) NOT NULL, + `selected_option` VARCHAR(64) NOT NULL, + `device_os` VARCHAR(32) NOT NULL DEFAULT 'CitizenOS_Mobile', + `submitted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_poll_citizen` (`poll_id`, `citizen_id`), + UNIQUE KEY `uk_poll_hash` (`poll_id`, `voter_hash`), + INDEX `idx_poll_resp_poll` (`poll_id`), + INDEX `idx_poll_resp_citizen` (`citizen_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 6. Seed Campaign Finance PAC Escrow Account in nyx_treasury_accounts +INSERT INTO `nyx_treasury_accounts` + (`account_id`, `label`, `balance`, `type`, `signatories_required`) +VALUES + ('gov:escrow:pac', 'Municipal Campaign Finance PAC Reserve Account', 0.00, 'reserve', 1) +ON DUPLICATE KEY UPDATE `label` = VALUES(`label`); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/FiveM/nyx_treasury/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql b/FiveM/nyx_treasury/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql new file mode 100644 index 0000000..6041cf8 --- /dev/null +++ b/FiveM/nyx_treasury/sql/migrations/067_campaign_finance_and_citizen_os_polling.sql @@ -0,0 +1,105 @@ +-- ============================================================================ +-- Nyxframework: nyx_parliament, nyx_treasury, nyx_ui +-- Migration: 067_campaign_finance_and_citizen_os_polling.sql +-- Description: Political Action Committees (PACs), statutory campaign finance donation +-- caps, independent PAC compliance auditing & statutory fines, and +-- Citizen OS mobile opinion surveys & voter sentiment polling. +-- Engine: InnoDB | Charset: utf8mb4 | Collation: utf8mb4_unicode_ci +-- ============================================================================ + +SET FOREIGN_KEY_CHECKS = 0; + +-- 1. Political Action Committees (PACs) Registry +CREATE TABLE IF NOT EXISTS `nyx_parliament_pacs` ( + `pac_id` VARCHAR(64) PRIMARY KEY, + `candidate_id` VARCHAR(64) NULL, + `office_id` VARCHAR(32) NULL, + `title` VARCHAR(128) NOT NULL, + `treasurer_citizen_id` VARCHAR(16) NOT NULL, + `balance` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `total_donations_collected` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `status` ENUM('active', 'audited', 'suspended', 'dissolved') NOT NULL DEFAULT 'active', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_pac_candidate` (`candidate_id`), + INDEX `idx_pac_office` (`office_id`), + INDEX `idx_pac_treasurer` (`treasurer_citizen_id`), + INDEX `idx_pac_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 2. Campaign Finance Donations Ledger +CREATE TABLE IF NOT EXISTS `nyx_parliament_donations` ( + `donation_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `donation_uuid` VARCHAR(36) NOT NULL UNIQUE, + `pac_id` VARCHAR(64) NOT NULL, + `donor_citizen_id` VARCHAR(16) NOT NULL, + `donor_faction_id` VARCHAR(64) NULL, + `amount` DECIMAL(15, 2) NOT NULL, + `cap_limit_applied` DECIMAL(15, 2) NOT NULL DEFAULT 5000.00, + `compliance_status` ENUM('compliant', 'flagged_over_cap', 'refunded', 'audited') NOT NULL DEFAULT 'compliant', + `treasury_tx_uuid` VARCHAR(36) NULL, + `donated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_donation_pac` (`pac_id`), + INDEX `idx_donation_donor` (`donor_citizen_id`), + INDEX `idx_donation_compliance` (`compliance_status`), + INDEX `idx_donation_created` (`donated_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 3. PAC Statutory Compliance Audits Ledger +CREATE TABLE IF NOT EXISTS `nyx_parliament_pac_audits` ( + `audit_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `audit_uuid` VARCHAR(36) NOT NULL UNIQUE, + `pac_id` VARCHAR(64) NOT NULL, + `auditor_citizen_id` VARCHAR(16) NOT NULL DEFAULT 'SYSTEM_PAC_AUDITOR', + `total_audited_contributions` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `flagged_excess_amount` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `fine_assessed` DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + `audit_status` ENUM('passed', 'violations_found', 'fines_assessed', 'cleared') NOT NULL DEFAULT 'passed', + `notes` VARCHAR(255) NULL, + `audited_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_pac_audit_pac` (`pac_id`), + INDEX `idx_pac_audit_status` (`audit_status`), + INDEX `idx_pac_audit_created` (`audited_at` DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 4. Citizen OS Mobile Opinion Surveys & Sentiment Polls +CREATE TABLE IF NOT EXISTS `nyx_parliament_polls` ( + `poll_id` VARCHAR(64) PRIMARY KEY, + `title` VARCHAR(128) NOT NULL, + `description` TEXT NOT NULL, + `creator_citizen_id` VARCHAR(16) NOT NULL DEFAULT 'SYSTEM_GOV', + `poll_type` ENUM('approval_rating', 'policy_survey', 'civic_referendum') NOT NULL DEFAULT 'policy_survey', + `office_id` VARCHAR(32) NULL, + `options_json` JSON NOT NULL, + `total_responses` INT UNSIGNED NOT NULL DEFAULT 0, + `starts_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `ends_at` TIMESTAMP NOT NULL, + `status` ENUM('active', 'concluded', 'archived') NOT NULL DEFAULT 'active', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_poll_status` (`status`), + INDEX `idx_poll_office` (`office_id`), + INDEX `idx_poll_ends` (`ends_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 5. Citizen OS Mobile Poll Responses Registry +CREATE TABLE IF NOT EXISTS `nyx_parliament_poll_responses` ( + `response_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `poll_id` VARCHAR(64) NOT NULL, + `citizen_id` VARCHAR(16) NOT NULL, + `voter_hash` VARCHAR(64) NOT NULL, + `selected_option` VARCHAR(64) NOT NULL, + `device_os` VARCHAR(32) NOT NULL DEFAULT 'CitizenOS_Mobile', + `submitted_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_poll_citizen` (`poll_id`, `citizen_id`), + UNIQUE KEY `uk_poll_hash` (`poll_id`, `voter_hash`), + INDEX `idx_poll_resp_poll` (`poll_id`), + INDEX `idx_poll_resp_citizen` (`citizen_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 6. Seed Campaign Finance PAC Escrow Account in nyx_treasury_accounts +INSERT INTO `nyx_treasury_accounts` + (`account_id`, `label`, `balance`, `type`, `signatories_required`) +VALUES + ('gov:escrow:pac', 'Municipal Campaign Finance PAC Reserve Account', 0.00, 'reserve', 1) +ON DUPLICATE KEY UPDATE `label` = VALUES(`label`); + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/tests/test_campaign_finance_and_citizen_os_polling.py b/tests/test_campaign_finance_and_citizen_os_polling.py new file mode 100644 index 0000000..4ee2aa3 --- /dev/null +++ b/tests/test_campaign_finance_and_citizen_os_polling.py @@ -0,0 +1,270 @@ +""" +Unit and Integration Tests for Campaign Finance & Citizen OS Polling (nyx_parliament & nyx_treasury). +Tests statutory donation caps, PAC registration & treasury escrow transfers, +PAC compliance auditing & statutory fine assessment, Citizen OS mobile survey response deduplication, +voter sentiment calculation, margin of error estimations, Migration 067, and manifest declarations. +""" + +import os +import json +import math +import pytest + +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + +class CampaignFinanceEngine: + """Python reference implementation mirroring Nyx.CampaignFinance in shared/campaign_finance.lua.""" + + DEFAULT_CAP = 5000.00 + DEFAULT_FINE_MULT = 1.5 + + @classmethod + def evaluate_donation_eligibility(cls, donor_citizen_id, donor_prior_total, donation_amount, statutory_cap=None, is_faction=False): + cap = float(statutory_cap if statutory_cap is not None else cls.DEFAULT_CAP) + prior = float(donor_prior_total or 0.0) + amt = float(donation_amount or 0.0) + + if amt <= 0.0: + return False, "INVALID_DONATION_AMOUNT", { + "allowedAmount": 0.0, + "excessAmount": 0.0, + "remainingCap": max(0.0, cap - prior), + "complianceStatus": "invalid" + } + + remaining = max(0.0, cap - prior) + + if prior >= cap: + return False, "STATUTORY_CAP_EXCEEDED", { + "allowedAmount": 0.0, + "excessAmount": amt, + "remainingCap": 0.0, + "complianceStatus": "flagged_over_cap" + } + + if (prior + amt) > cap: + allowed = remaining + excess = amt - remaining + return True, "CAP_EXCEEDED_PARTIAL", { + "allowedAmount": allowed, + "excessAmount": excess, + "remainingCap": 0.0, + "complianceStatus": "flagged_over_cap" + } + + return True, "COMPLIANT", { + "allowedAmount": amt, + "excessAmount": 0.0, + "remainingCap": max(0.0, cap - (prior + amt)), + "complianceStatus": "compliant" + } + + @classmethod + def calculate_pac_audit_summary(cls, donations_list, statutory_cap=None, fine_multiplier=None): + mult = float(fine_multiplier if fine_multiplier is not None else cls.DEFAULT_FINE_MULT) + cap = float(statutory_cap if statutory_cap is not None else cls.DEFAULT_CAP) + + total_contrib = 0.0 + compliant_contrib = 0.0 + flagged_excess = 0.0 + flagged_count = 0 + total_count = 0 + + for don in (donations_list or []): + total_count += 1 + amt = float(don.get("amount", 0.0)) + total_contrib += amt + + status = don.get("compliance_status") or don.get("complianceStatus") + if status == "flagged_over_cap" or amt > cap: + flagged_count += 1 + excess = max(0.0, amt - cap) + flagged_excess += excess + else: + compliant_contrib += amt + + fine = flagged_excess * mult + audit_status = "fines_assessed" if flagged_excess > 0.0 else "passed" + risk_score = min(100.0, (flagged_excess / total_contrib * 100.0)) if total_contrib > 0.0 else 0.0 + + return { + "totalContributions": total_contrib, + "compliantContributions": compliant_contrib, + "flaggedExcessAmount": flagged_excess, + "fineAssessed": fine, + "flaggedCount": flagged_count, + "totalCount": total_count, + "auditStatus": audit_status, + "riskScore": round(risk_score, 2) + } + + @classmethod + def calculate_poll_sentiment(cls, poll_options, response_counts, total_turnout): + total = int(total_turnout or 0) + breakdown = {} + leading_option = None + max_votes = -1 + + options = poll_options + if isinstance(options, str): + try: + options = json.loads(options) + except Exception: + options = [] + + counts = response_counts or {} + + for opt in (options or []): + opt_id = opt.get("id") or opt.get("value") if isinstance(opt, dict) else str(opt) + opt_label = opt.get("label") or opt.get("name") if isinstance(opt, dict) else str(opt) + count = int(counts.get(opt_id, 0)) + + pct = (count / total * 100.0) if total > 0 else 0.0 + + if count > max_votes: + max_votes = count + leading_option = opt_id + + breakdown[opt_id] = { + "id": opt_id, + "label": opt_label, + "count": count, + "percentage": round(pct, 2) + } + + margin_of_error = round((1.96 * math.sqrt(0.25 / total) * 100.0), 2) if total > 0 else 0.0 + + return { + "totalTurnout": total, + "leadingOption": leading_option, + "breakdown": breakdown, + "marginOfErrorPercent": margin_of_error + } + + +# ============================================================================== +# PYTEST TEST CASES +# ============================================================================== + +def test_statutory_donation_cap_evaluation(): + # 1. Compliant $2,000 donation under $5,000 cap + ok, reason, data = CampaignFinanceEngine.evaluate_donation_eligibility("CIT_1001", 0.0, 2000.00, 5000.00) + assert ok is True + assert reason == "COMPLIANT" + assert data["allowedAmount"] == 2000.00 + assert data["excessAmount"] == 0.0 + assert data["remainingCap"] == 3000.00 + assert data["complianceStatus"] == "compliant" + + # 2. Second donation of $3,000 reaching exactly the cap + ok, reason, data = CampaignFinanceEngine.evaluate_donation_eligibility("CIT_1001", 2000.00, 3000.00, 5000.00) + assert ok is True + assert reason == "COMPLIANT" + assert data["allowedAmount"] == 3000.00 + assert data["remainingCap"] == 0.0 + + # 3. Third donation exceeding cap partially (prior: $4,000, new: $2,000) + ok, reason, data = CampaignFinanceEngine.evaluate_donation_eligibility("CIT_1001", 4000.00, 2000.00, 5000.00) + assert ok is True + assert reason == "CAP_EXCEEDED_PARTIAL" + assert data["allowedAmount"] == 1000.00 + assert data["excessAmount"] == 1000.00 + assert data["complianceStatus"] == "flagged_over_cap" + + # 4. Donor already at or above cap attempting another donation + ok, reason, data = CampaignFinanceEngine.evaluate_donation_eligibility("CIT_1001", 5000.00, 500.00, 5000.00) + assert ok is False + assert reason == "STATUTORY_CAP_EXCEEDED" + assert data["allowedAmount"] == 0.0 + assert data["excessAmount"] == 500.00 + assert data["complianceStatus"] == "flagged_over_cap" + + # 5. Invalid negative donation + ok, reason, data = CampaignFinanceEngine.evaluate_donation_eligibility("CIT_1001", 0.0, -100.00, 5000.00) + assert ok is False + assert reason == "INVALID_DONATION_AMOUNT" + + +def test_pac_audit_and_fine_assessment(): + donations = [ + {"amount": 2500.00, "compliance_status": "compliant"}, + {"amount": 5000.00, "compliance_status": "compliant"}, + {"amount": 7000.00, "compliance_status": "flagged_over_cap"}, # $2,000 excess over $5,000 + {"amount": 10000.00, "compliance_status": "flagged_over_cap"} # $5,000 excess over $5,000 + ] + + summary = CampaignFinanceEngine.calculate_pac_audit_summary(donations, statutory_cap=5000.00, fine_multiplier=1.5) + + assert summary["totalCount"] == 4 + assert summary["flaggedCount"] == 2 + assert summary["totalContributions"] == 24500.00 + assert summary["compliantContributions"] == 7500.00 + assert summary["flaggedExcessAmount"] == 7000.00 # $2000 + $5000 + assert summary["fineAssessed"] == 10500.00 # $7000 * 1.5 + assert summary["auditStatus"] == "fines_assessed" + assert summary["riskScore"] == 28.57 # (7000 / 24500) * 100 + + +def test_mobile_poll_sentiment_and_margin_of_error(): + options = [ + {"id": "OPT_APPROVE", "label": "Approve Civic Reform"}, + {"id": "OPT_REJECT", "label": "Reject Civic Reform"}, + {"id": "OPT_UNDECIDED", "label": "Undecided / Neutral"} + ] + + response_counts = { + "OPT_APPROVE": 60, + "OPT_REJECT": 30, + "OPT_UNDECIDED": 10 + } + + sentiment = CampaignFinanceEngine.calculate_poll_sentiment(options, response_counts, total_turnout=100) + + assert sentiment["totalTurnout"] == 100 + assert sentiment["leadingOption"] == "OPT_APPROVE" + assert sentiment["breakdown"]["OPT_APPROVE"]["percentage"] == 60.0 + assert sentiment["breakdown"]["OPT_REJECT"]["percentage"] == 30.0 + assert sentiment["breakdown"]["OPT_UNDECIDED"]["percentage"] == 10.0 + # Margin of error for N=100: 1.96 * sqrt(0.25 / 100) * 100 = 1.96 * 0.05 * 100 = 9.8% + assert sentiment["marginOfErrorPercent"] == 9.8 + + +def test_migration_067_and_index_json_schema(): + """Verify Migration 067 exists in nyx_core, nyx_parliament, nyx_treasury, and is indexed.""" + core_mig = os.path.join(ROOT_DIR, "FiveM", "nyx_core", "sql", "migrations", "067_campaign_finance_and_citizen_os_polling.sql") + parl_mig = os.path.join(ROOT_DIR, "FiveM", "nyx_parliament", "sql", "migrations", "067_campaign_finance_and_citizen_os_polling.sql") + treas_mig = os.path.join(ROOT_DIR, "FiveM", "nyx_treasury", "sql", "migrations", "067_campaign_finance_and_citizen_os_polling.sql") + index_file = os.path.join(ROOT_DIR, "FiveM", "nyx_core", "sql", "migrations", "index.json") + + assert os.path.isfile(core_mig), "067 migration missing from nyx_core" + assert os.path.isfile(parl_mig), "067 migration missing from nyx_parliament" + assert os.path.isfile(treas_mig), "067 migration missing from nyx_treasury" + + with open(index_file, "r", encoding="utf-8") as f: + index_data = json.load(f) + + assert "067_campaign_finance_and_citizen_os_polling.sql" in index_data + + with open(core_mig, "r", encoding="utf-8") as f: + sql = f.read() + + assert "nyx_parliament_pacs" in sql + assert "nyx_parliament_donations" in sql + assert "nyx_parliament_pac_audits" in sql + assert "nyx_parliament_polls" in sql + assert "nyx_parliament_poll_responses" in sql + assert "gov:escrow:pac" in sql + + +def test_fxmanifest_and_script_declarations(): + """Verify campaign finance & polling scripts declared in fxmanifest.lua.""" + manifest_file = os.path.join(ROOT_DIR, "FiveM", "nyx_parliament", "fxmanifest.lua") + with open(manifest_file, "r", encoding="utf-8") as f: + manifest = f.read() + + assert "shared/campaign_finance.lua" in manifest + assert "server/campaign_finance.lua" in manifest + assert "server/polling.lua" in manifest + assert "client/polling.lua" in manifest + assert "client/pacs.lua" in manifest