You can now add a service fee to online payments and share it across up to 4 bank accounts.
transfer.success/failed webhooks reconcile.The card-on-file autopay module shows in Settings → Modules as Coming soon — tapping it says it's coming in a future version, it can't be switched on, and all autopay screens stay hidden until it ships.
Several admin buttons — Delete resident, plus delete/reject/remove buttons on documents, vehicles, directory, security staff, meeting minutes, price list and the visitor log — silently did nothing. They passed a name into the button's click handler in a way that broke the handler whenever the name was present. All fixed. Deleting a resident now also wipes every record they own (gate codes, household passes, vehicles, charges, payments, complaints, poll votes, push subscriptions, group codes, etc.) so the database stays clean; gate-log and panic history are kept but anonymised.
service_fee_accounts, service_fee_payouts; service_charges gains service_fee_kobo + service_fee_enqueued.apps/estatemax/api/service-fee.php (get/banks/resolve/save/process-now) + Paystack helpers in lib/payments.php (em_paystack_call, list-banks, resolve, create-recipient, initiate-transfer, balance, fee config/amount/enqueue/process). portal.php adds the fee at init, splits it out on verify/webhook, and reconciles transfer.* webhooks. Cron step 6 drains the payout queue.features_paystack_recurring removed from the editable settings whitelist while it's coming-soon.features_service_fee, now whitelisted so it persists) read everywhere; the standalone Service-fee module card was removed and its config folded into the Online Payments section. The service-fee admin API is no longer feature-gated so it can be configured before the fee is switched on.The single "House / Unit number" box is now two fields — House number (required) and Unit number (optional) — on the resident sign-up form and on the admin Add-resident and Edit-resident sheets. The two are stored separately and still shown as one combined line ("House 7, Flat 2B") everywhere else, so nothing else changes visually. Existing residents keep their current value; when you next edit one, the House field is pre-filled with it so you can split it cleanly.
A deep audit of the payment flow (resident pays a service charge → Paystack → charge marked paid) confirmed the happy path works correctly: amounts are handled in kobo, the payment is verified server-side against Paystack with your secret key, duplicates are ignored, and the charge flips to paid with the right balance maths. Two hardenings added:
residents schema: added house_number + flat_number (additive; unit_number remains the combined display value). residents/create + residents/update accept house/flat and rebuild unit_number; the legacy single unit field still works for CSV import.portal/paystack-webhook action (HMAC-SHA512 signature check + server-side re-verify) in [api/portal.php](apps/estatemax/api/portal.php); added to csrf_exempt_apis.CREATE UNIQUE INDEX idx_payments_reference_unique (partial, non-blank references) on service_charge_payments.marketing/ — master kit, wins-vs-competitors, the "everything"/"money"/"security"/main proposals, and the WhatsApp/Email/Facebook channel copy — to add the new "no codes until they pay" debt-gating angle and the admin-can't-see-PINs security point. The HTML landing pages at /{appId}/{slug} render from these files, so they update automatically.New opt-in debt gating: residents who owe service charges past a grace period can't generate any codes until they pay.
0 blocks the day it becomes due.page now has a Collect payment button: it lists what they owe, and tapping a charge records a cash / transfer / POS payment that deducts from the balance (and marks the charge paid once settled). Their charge rows are now tappable too — tap any owing charge to record against it. An Other / one-off payment option handles ad-hoc amounts that aren't tied to a bill.
showed a blank icon because the category used an invalid icon name. They now show a building icon; light/connection fees show a clearer bolt icon.
service-charges.php: categoryIcon() mapped development to the Material name domain-add (not a Lucide icon) → now building-2; connection switched from bolt to zap.
resident-detail.php: added collectPayment(), payCharge(), rdRecordPay(), and a one-off rdCustomPayment() flow, reusing the existing service-charges?action=get, payments/record, and payments/record-custom endpoints. No API or schema changes.
PSP) was defined after each page's content, so any page that loaded data on open threw before it could fetch. The SDK is now defined before the page body, so the Notices inbox, the payment fee picker, and every other on-open list load correctly. (Pages that only act on a tap, like guest codes, were unaffected — which is why this looked inconsistent.)Some modules were switched on in Settings but had nowhere for the admin to actually use them. This release closes those gaps and makes every module reachable.
The Documents module (bylaws / policies / contracts) had a resident reading page but no admin uploader. New Documents page lets the manager add, edit and delete documents, filter by type (Bylaws / Policies / Contracts / Other), and choose whether each is visible to all residents or owners-only. Residents continue to read them at More → Documents.
The License-plate module let residents register their cars and security verify them at the gate, but the admin couldn't see the registry. New Vehicles page lists every registered plate with the resident, unit and car description, is searchable, and lets the admin remove a plate (e.g. for a resident who moved out).
The Resident-directory module is opt-in (residents list themselves from My account), but the admin had no oversight. New Directory page shows everyone active, who's currently Listed vs Hidden, is searchable and tab-filterable, shows the current display settings (phones / units / owners-only), and lets the admin add or remove any resident from the directory — e.g. force-hide someone who complained.
The Businesses manager (where churches / hotels / pharmacies inside the estate get their own login to release visitors) existed but wasn't surfaced on the dashboard. It's now a primary Quick-link on the admin home whenever the Special-business module is on.
The admin home screen used a fixed set of shortcuts. It now renders one tile per enabled module, split into Quick links (the everyday ones) and More tools (the rest), so estates that turn modules off see a cleaner screen and estates that turn them on can reach everything. New tiles: Businesses, Documents, Vehicles, Polls, Diesel, Price list, Reports, Activity log, SOS log.
Each enabled module card in Settings now shows a Manage → button next to its toggle, linking straight to that module's admin page (Documents, Vehicles, Businesses, Meetings, Polls, Complaints, Broadcasts, Service charges).
api/meeting-minutes.php index accepts a category filter (documents / minutes) so the Documents and Meeting-minutes pages share one endpoint; update now accepts an optional doc_type.api/vehicles.php (admin) with index + delete, gated by the license_plate feature.api/directory.php (admin) with index + set-optin, gated by the directory feature./documents, /vehicles-admin, /directory-admin; new view files views/documents.php, views/vehicles-admin.php, views/directory-admin.php.Reversed the earlier-today decision to let admin see a resident's current PIN. The plain-PIN copy was a security hole — anyone with admin access could read every resident's PIN, and a leaked admin session would expose hundreds of credentials at once. Admin still has full control of resident sign-in, just without seeing the secret.
force_pin_change = 0. Resident can sign in with that PIN and is not prompted to change it. Use when an admin is helping a resident pick a memorable PIN.1234) — sets force_pin_change = 1. Resident must pick a new PIN on next sign-in. Use when a PIN may be compromised or for first-time onboarding.Residents can already change their own PIN at My Account → Change PIN ([views/public/my-account.php](apps/estatemax/views/public/my-account.php) → [views/public/change-pin.php](apps/estatemax/views/public/change-pin.php)). No new UI needed.
residents.pin_plain column is marked DEPRECATED in [schema.sql](apps/estatemax/schema.sql) (kept in the table because the schema is additive-only, but ignored).pin_hash now also writes pin_plain = NULL, so any historical plaintext copy gets wiped on the next set / reset / change / register / forgot-PIN-reset:create, reset-pin, set-pin.change-pin.pin_plain = NULL. PIN for the demo is still 1234 (via pin_hash).Two related upgrades to the resident-management surface:
The API has supported residents/approve and residents/reject since the self-registration module shipped, but neither was reachable from the admin UI. Now:
Admin can now see a resident's current PIN at all times and set it to any value they choose — not just reset-to-default. Mirrors the DryCleanPro pin_plain pattern.
residents.pin_plain — admin-readable copy of the current PIN. Kept in lockstep with pin_hash on every set, reset, change, register, and forgot-PIN-reset path.residents/set-pin — admin posts {id, pin} (4-8 digits); the resident is set up with that PIN immediately (force_pin_change=0).•••• with a Show / Hide toggle, a Copy button, and two action buttons: Set PIN (open a sheet, type the value you want) and Reset to default. Three display states: PIN-known (the orange strip), legacy-data (PIN exists but plain copy missing — nudges admin to reset), and no-PIN.Every place that writes pin_hash to a residents row now also writes pin_plain:
create, reset-pin, set-pin (new).change-pin (resident picks their own PIN after first-login force-change, or via the change-PIN flow).security_staff and businesses tables don't get pin_plain (yet) — only residents, since admin most often needs to re-share a resident's PIN. Easy to extend later if needed.
[seeds/demo.sql](apps/estatemax/seeds/demo.sql) now sets pin_plain = '1234' on every demo resident, so the Portal Access card has something to show as soon as you open a profile.
New top-level [Overview.md](Overview.md) is now the live spec for what EstateMax is, where the code lives, and what's planned next. It covers: audiences and surfaces (admin, resident, security, business, self-registrants, marketers), the full module map with default-on/off settings, the route table (admin + public + marketing), the data model summary, external integrations, and the marketing surface.
The biggest new section is §7 Monetization roadmap — five resident-and-visitor-side revenue lines designed to bypass estate-management politics:
Each has a build sketch, schema stub, pricing model, and partner shortlist. No code changes today — the doc is the deliverable. Next session picks a feature off the roadmap and starts building.
Affiliates now have six angle-specific sales proposals to pick from in the Marketplace Promote tab, plus a shareable HTML link per proposal that opens as a beautifully styled mobile-first landing page (no more pasting long raw markdown into WhatsApp).
A new public route at /em-proposal/{slug} renders any marketing .md file as a beautifully styled mobile-first HTML page using marked.js. Affiliates share THIS link (not the raw markdown) on WhatsApp — recipients open it on their phone and see a proper landing page with hero strip, cinematic typography, a CTA card, and the affiliate's name attributed via ?ref=.
core/index.php route handler maps /em-proposal/{slug} to apps/estatemax/marketing-render.php.<script type="text/markdown"> tag, and uses marked.js client-side to render it into a styled <article>.navigator.share on mobile or copies the URL on desktop.Property managers don't all run an SMS provider account, so leaning on "no SMS bill" / "no per-SMS cost" makes the pitch feel like it's targeting only managers who've already faced that bill. Reframed every SMS-bill hook across the marketing pack to talk about what the feature does (siren, push notification, in-app fan-out, instant alert) instead of what it doesn't cost.
wins-vs-competitors.md — Win #5 (SOS), Win #7 (feature phones), Win #9 (self-registration), the Modules bullets for Self-registration and Web Push, the one-page-pitch SOS line, and two feature-matrix rows reworded.proposal-vs-gpera.md — the Free-500-residents row, SOS row, the "panic button is free forever" paragraph, the 6-numbers paragraph, the auto-approve closer, the manager-features list, "how to start" closer, and the cost closer.Email.txt, Facebook.txt, WhatsApp.txt — SOS bullet and self-registration bullet reframed to talk about the feature instead of the absent SMS bill.SMS.txt is the SMS-channel template; copy already focuses on the product, not on an "anti-SMS" angle.No code, schema, or view changes — copy-only sweep inside apps/estatemax/marketing/.
A small follow-up to the group guest codes flow. Tucked away under a collapsed "Repeat this group" panel — most hosts won't notice it, but the ones who run a weekly prayer meeting or a monthly family hangout get something powerful:
1. When creating a group, the resident expands "Repeat this group", picks Every week or Every month, and creates as normal. 2. After the meeting's end-time passes, the cron tick mints a fresh batch of codes for the next instance — same names, same duration, new 6-digit codes. 3. The new codes carry a starts_at timestamp so they refuse to open the gate until next week's meeting actually begins. Safe to sit in the host's phone all week without abuse risk. 4. The host sees the upcoming group in their Your recent groups list with a "Repeats weekly/monthly" badge and a different icon. They can share the codes ahead of time (each card still has Copy and Share-to-WhatsApp) — the recipient gets a "Save the date for [event name]" share message instead of the regular invitation, so they know it's a calendar reminder, not a today-now visit. 5. Cancelling the recurring group revokes the current week's unused codes and stops the spawn.
code_groups: recurring_rule, recurring_until, parent_group_id.access_codes: starts_at (NULL = valid immediately, existing behaviour preserved).idx_code_groups_recurring (partial: only rows with a rule).em_spawn_recurring_code_groups() invoked from run_estatemax_cron(), gated by em_module_enabled('group_guest_codes').AND (ac.starts_at IS NULL OR ac.starts_at <= datetime('now')) so future-windowed codes don't open the gate prematurely.em_share_text() for guest codes now mentions the start time when starts_at is set, and changes the greeting from "You are invited to" → "Save the date for" so recurring-spawned codes read like a heads-up.wins-vs-competitors.md gained a "Recurring small groups" paragraph.proposal-vs-gpera.md extended with a weekly-prayer-meeting paragraph.8/8 end-to-end functional tests pass: parent group created with weekly rule, cron spawns one child with 3 codes, all 3 child codes have starts_at in the future and preserved names from the parent, verify-code refuses a future-starts code at the gate, accepts the same code once starts_at passes, second cron pass is idempotent.
A resident hosting 12 friends for a birthday no longer has to tap "Generate code" twelve times. New flow at More → Group codes:
1. Resident picks the count (2 – 25), optionally names each guest, picks a shared end-time (2h / 4h / 6h / 12h / 1 day), and taps Generate. 2. Server mints that many separate single-use guest codes in one transaction. Each code is a normal access_codes.code_type='guest' row — single-use, individually revokable, with its own row in the audit log — and points back to a new code_groups row that carries the shared label + end-time. 3. The resident sees a per-code share screen: each code as a card with a Copy button and a Share-to-WhatsApp button. The share text mentions the event name ("You are invited to Birthday party at Happy Land Estate, Tunde."). 4. Cancel the party? One tap revokes every unused code in the group at once. Codes that were already used at the gate stay logged.
This is intentionally distinct from event-mass codes (one code, many uses, for 400-person Sunday services). Group codes target small gatherings where each guest deserves their own single-use code with their own name on it.
features_group_guest_codes — ON by default for new installs.code_groups table; new access_codes.code_group_id column (additive); new index idx_access_codes_group.group-guest-create, group-guest-list, group-guest-codes, group-guest-revoke./group-guest./guest-code that links to the group flow.group_guest_max_count (default 25), group_guest_default_hours (default 6).em_share_text('guest', …) now supports an event_label option so group codes carry the event name into the share message.marketing/wins-vs-competitors.md.marketing/proposal-vs-gpera.md and "Card 6 — Small party at home" in the marketing kit's per-feature cards.proposal.md, Email.txt, Facebook.txt, WhatsApp.txt updated with the new benefit.6/6 in-memory functional tests pass: group row created, 7 codes inserted under it, per-code names preserved, using one code leaves the others untouched, group revoke-all kills unused codes only (leaves used ones logged), list-groups stats accurate.
A pass through the admin Settings page so a brand-new estate gets a usable app out of the box and a busy manager can find the one thing they came for.
Fresh installs now ship with these modules already on:
And these off (admin opts in when ready):
EstateMax defaults to in-app notifications + Web Push (both free). Admin turns on the SMS channel module if they want gate-code SMS or code-used confirmations to also go out by SMS — and pays for the SMS budget at the platform level. When the module is off, send_estatemax_sms() no-ops cleanly and the SMS sub-settings (code-to-resident, code-used) are hidden from the Resident Access panel.
0000 → 1234. Matches the resident default; no more two-PIN onboarding confusion.monthly → yearly. Most estates bill annually; this is now pre-selected.address-book isn't a Lucide icon. Replaced with contact across every card and tile that referenced it (modules grid, More tab tile, My-account menu row, app.json registry).features_sms flag whitelisted in api/settings.php.send_estatemax_sms() opens with an em_module_enabled('sms') short-circuit.<details id="group-sms-channel"> section appears in Advanced settings when the SMS module is on, with a link to the platform's SMS provider page.em_share_text() brand-line fallback chain: share_brand setting → estate_name setting → empty.seeds/fresh.sql and seeds/demo.sql updated with the new defaults so fresh installs and demo seeds pick them up automatically.Broadcasts no longer send SMS. Every resident now has a notice inbox they see right inside the app — no per-message bill, no SMS budget, no waiting for cron to drain a queue.
em_drain_broadcast_queue() is now a no-op that just heals any leftover pending/sending rows from old SMS-era installs (they get flipped to delivered so residents see them).broadcasts.channel default flipped from 'sms' to 'in_app'. New broadcasts.push_delivered_count column. New broadcast_recipients.read_at column. New index idx_broadcast_recipients_resident for fast inbox lookups.portal/broadcasts-list, portal/broadcasts-unread-count, portal/broadcast-mark-read./broadcasts for the resident inbox.broadcast_recipients first) and clickable; falls back to the global latest broadcast on installs where recipients haven't been backfilled.Self-registration is now a one-page form. Resident enters name, phone, optional email, house number, type, and picks their own PIN — no email OTP step, no default PIN. Two new sub-settings on the Self-registration module:
selfreg_require_admin_approval = 0) — new sign-ups go straight to active and can sign in right after submitting. Use this on launch day when a wave of residents all sign up at once.selfreg_require_email) — defaults off; turn on if you want every resident to have an email on file for forgot-PIN.Default behaviour: admin still approves each new sign-up (safer default). Flip the toggle any time.
This makes launch day trivial: turn on self-registration with auto-approve, share one link in the estate WhatsApp group, every resident signs themselves up and starts using it the same day. After the launch wave, switch auto-approve OFF so new arrivals wait in your approval queue.
marketing/marketing-kit.md — one file with the elevator pitch, headline options, marquee feature list, side-by-side comparison table, full proposal, paste-ready social copy (WhatsApp/Email/SMS/Facebook/Twitter/Instagram), objection-handling answers, and a 90-second self-registration demo script.marketing/proposal-vs-gpera.md — added self-registration as a marquee benefit (section #7), dropped the custom-domain row, dropped the "no setup fee" line.marketing/wins-vs-competitors.md — same edits: self-registration is now win #9, dropped the custom-domain row and pitch line.marketing/proposal.md, marketing/Email.txt, marketing/Facebook.txt, marketing/Instagram.txt, marketing/Twitter.txt, marketing/WhatsApp.txt, marketing/SMS.txt — all updated to lead with self-registration as the key benefit.marketing/articles/sharing-with-customers.md — trimmed the custom-domain tutorial section.The biggest single drop since launch. Twelve new modules behind the new Modules grid in Settings — turn on the ones your estate needs, leave the rest off. Nothing in the old experience changes unless you flip a switch.
/register. They confirm their email with a 6-digit OTP, then wait for the estate manager to approve. Until approved, login is refused with an explanatory message. Admin reviews the queue under People → Pending filter; approving sends a welcome email with the default PIN.doc_type picker). Residents see them under More → Documents. Meeting minutes still surface separately under Meetings.The settings page now opens with a module grid. Each card shows the module's name, a one-line description, an on/off toggle, and (when enabled) a Configure shortcut to the right Advanced sub-section. Cards for not-yet-shipped modules are marked with their Phase tag so you can see what's coming.
The long collapsible list of preferences is still there — it's under Advanced settings below the modules grid. Modules without a dedicated Configure sub-section (everything in Tier 2) save right from the card toggle.
Foundation that shipped alongside the modules. Residents, security guards, and business logins can reset their own PIN: click Forgot PIN? on the sign-in page → enter the email on their account → type the 6-digit code we email them → pick a new PIN. The code expires in 15 minutes, is single-use, and is locked after 5 wrong tries. otp_tokens table backs every OTP flow with bcrypt-hashed codes and per-email throttling.
marketing/wins-vs-competitors.md lists 17 concrete advantages over Gpera, Gate Africa, Residence.ng, Venco, and the rest of the Nigerian field — plus an honest gap list, a side-by-side feature matrix, an objection-handling section, and a copy-paste one-page pitch.
otp_tokens, resident_vehicles, polls, poll_options, poll_votes, resident_paystack_subscriptions, web_push_subscriptions. New columns on residents (email, approval_status, registration_method, rejected_reason, directory_opt_in), businesses (email), security_staff (email), complaints (expected_complete_at, actual_complete_at, cost, before_photo_path, after_photo_path), business_codes (recurring_rule, recurring_until), meeting_minutes (doc_type).em_module_enabled, em_send_otp, em_verify_otp, em_plate_normalise, em_spawn_recurring_event_codes. Email pipeline uses the platform's existing send_mail() (PHP mail() underneath on shared hosting).cron_estatemax_tick now spawns next-instance recurring event_mass codes when their parent expires.web-push.php: VAPID-signed delivery (RFC 8291 aes128gcm payload encryption + RFC 8292 ES256 JWT). Pure openssl + hash extensions, no Composer dependency. Helpers: em_push_generate_vapid, em_push_encrypt, em_push_send, em_push_send_to_resident. 410 / 404 subscriptions are auto-pruned. New admin API settings/generate-vapid mints + saves the keypair.When residents and businesses share gate codes via WhatsApp / SMS, the recipient now sees a full Nigerian-friendly message instead of just six bare digits. Same shape across every share surface in the app:
``` You are invited to visit Happy Land Estate, Aunt Adaeze.
This code is valid between Thu May 14 at 6:07 PM and Fri May 15 at 12:07 AM.
Passcode: 805205
Show these 6 digits at the gate.
Powered by gate.happyland.app ```
em_share_text($type, $code, $opts) in helpers.php. Five variants — guest, household, business_exit, event, self — each phrased for the recipient's context but using the same headline / validity / passcode / footer skeleton.em_portal_url() resolves the estate's public portal URL (custom domain or platform fallback) for the "Powered by" footer line.em_dependent_schedule_summary() formats a household pass's days + time window as a friendly string ("Mon · Tue · Wed · Thu · Fri from 6 AM to 8 PM").The API now returns share_text on every endpoint that issues or lists a code, so the front-end never has to compose the message itself:
portal/get-guest-code (already returned share_text; reformatted to unified shape)portal/security-issue-visitor-entry (visitor invited to the business)portal/business-issue-exit (exit code for a leaving visitor)portal/business-event-code-create + portal/business-event-codes-list (event mass codes)portal/dependent-create + portal/dependent-regenerate + portal/dependents-list (household passes)/home live-guest-code rows: PHP loop computes share text per row, JS share button reads it from a data-share attribute./household pass rows + post-create celebration sheet: shareCode() now takes a third shareText argument; row buttons look up share_text via the stashed window.__depList./business exit-code celebration + event-code Share/Show buttons: now consume res.share_text (one-shot) or the stashed window.__eventCodes (list).share_text is missing — so a legacy install in mid-migration never breaks the share flow.apps/estatemax/helpers.php — three new helpers (em_share_text, em_portal_url, em_dependent_schedule_summary).apps/estatemax/api/portal.php — share_text attached to 7 endpoints.apps/estatemax/views/public/home.php — server-built share_text per active guest row + event-delegated share button.apps/estatemax/views/public/household.php — propagate share_text through row buttons + celebration sheet.apps/estatemax/views/public/business.php — event-codes list stashed for lookup; shareEvent / showEventCode / showCodeCelebration take optional share_text.End-to-end test verified all 5 variants produce sensible text including the example case from the brief.
{…} second line on every audit row is gone. Each row now reads as one plain-English sentence — admin can scan the activity log without translating business_visitor.entry_issued and {"business_id":1,"visitor_phone":"234…"} in their head. Sample lines:em_humanize_audit($row) helper maps every existing audit action (28 of them) to a friendly sentence. Unknown / future actions fall through to a generic phrasing rather than the cryptic code.charge.paystack_paid) for technical/forensic lookups.em_prune_audit_log() runs whenever the Activity Log page is opened, throttled to once-per-hour via audit_last_prune_at in settings. No server-side cron required.audit.pruned audit entry with the count + the retention window — so retention is itself auditable. Pruned counts surface as "Old log entries cleaned up: N entries older than X days removed."apps/estatemax/helpers.php — em_humanize_audit(), em_prune_audit_log().apps/estatemax/api/audit.php — em_prune_audit_log() called on every request; humanized field attached to index + CSV.apps/estatemax/api/settings.php — audit_log_retention_days whitelisted + validated.apps/estatemax/views/audit.php — humanised line as headline, action code as small tag.apps/estatemax/views/partials/settings-sections.php — new "Audit log retention" collapsible section with dropdown + last-prune timestamp.apps/estatemax/app.json + seeds/fresh.sql + seeds/demo.sql — audit_log_retention_days default 180.End-to-end tests against an in-memory DB: 10 humanizer cases + 6 auto-prune scenarios — 16/16 pass.
clamp() font sizes so phones in the 320–414px range never overflow.actor_role, actor_name, ip_address, user_agent columns on audit_log + two new indexes (idx_audit_actor, idx_audit_action). Lazy migrator auto-applies on existing installs.em_audit() rewritten to capture every dimension every time:actor_role (admin / resident / security / business / system) + user_id + actor_name, picked automatically from whichever session type is active.action + target_type + target_id + JSON details.created_at in UTC.ip_address (handles HTTP_X_FORWARDED_FOR and HTTP_CF_CONNECTING_IP in front of CDNs) + truncated user_agent.distinct-actions endpoint that surfaces prefixes like business_visitor.*), actor-role filter, date-range filter, day-grouped output, actor pill on every row, Lagos-local timestamps, CSV export of the filtered set./reports admin page with a 7/30/90/365-day window picker. Renders:New: views/reports.php, api/reports.php. Modified: schema.sql (audit_log + indexes), helpers.php (em_audit rewrite), api/audit.php (filters + CSV), views/audit.php (full rewrite), app.json (routes + nav), views/public/home.php (compactness), views/public/household.php (empty-state CTA), views/public/history.php, views/public/meetings.php, views/public/complaint.php.
Every module is opt-in per estate via Settings → Special Access Modules. Estates that only want resident gate codes leave everything off and see no UI change. End-to-end functional + integration tests for the new code paths: 61/61 pass.
barcode_scan)/code, /guest-code and /household (new household pass celebration sheet) now render a scannable QR code AND a 1D Code 128 barcode beneath the 6 digits. Same code, two more input channels.Scan code button (only when flag on). Tapping opens an in-page camera scanner via html5-qrcode (lazy-loaded). On a successful read, the digits drop into the keypad and the existing verify-code endpoint fires — no new server path.verify-code accepts a via: 'camera' hint and writes a separate barcode.scanned audit row so admins can see scan vs typed entries.qrcode.js, jsbarcode, html5-qrcode) are CDN-loaded ONLY when the flag is on — zero added bytes for estates that don't enable it.business_visitors)For estates with a church, hotel, pharmacy, event centre or shop inside the gates. Solves the "I'm going to the hotel" cover-story problem.
businesses table. Admin adds each business (name, type, contact phone) at /businesses. Each business gets one phone+PIN login, mirroring the resident/security identity pattern. New /business-detail admin page edits, resets PIN, disables, deletes, and shows the visitor history.resolve_phone() and current_portal_user() extended to also try the businesses table. Login form + JSON portal login both auto-route businesses to /business with the same lockout / force-pin-change logic the residents and guards already use.Visitor code button on the keypad opens a sheet: pick the business from a dropdown, type the visitor's phone (required) + name (optional). Server validates the business is active, generates a 6-digit business_entry code that's marked used-at-issue (it's an audit record, not a re-verifiable code — the visitor walks in immediately) and writes an entry_log row. Visitor gets the digits + QR + 1D on screen if the barcode module is also on./business, sees active visitors, taps Release on a row OR types the phone. Server uses phone_variants() to find the most-recent matching open business_entry for that business; if the phone doesn't match anything, the server rejects with a friendly error AND audits business_visitor.phone_mismatch so admins can spot abuse patterns. On match, generates a business_exit code linked to the entry.verify-code now picks up business_exit codes, marks them single-use, logs entry_log with direction='out', and returns a business_exit resident-shape so the green takeover reads "Leaving Golden Pearls Pharmacy".business_codes table (separate from access_codes so the resident-NOT-NULL constraint on access_codes stays clean). em_unique_code() now checks both tables so the 6-digit code namespace stays globally unique while live.event_mass_codes, requires business_visitors)For churches running Sunday service, event centres hosting one-day events — one code, hundreds of guests, configurable window.
/business → Events tab → "Generate code". Sheet captures label ("Sunday Service"), start, end. Server validates end > start and the window ≤ event_code_max_days (default 7 days, admin-configurable). Returns code + QR + 1D the business can screenshot to WhatsApp.verify-code recognises code_type='event_mass': multi-use (no used_at flip), each scan logged in new event_code_uses table + entry_log. Pre-event scans return "Event hasn't started yet". After expires_at or revoked_at → reject.event_visitor_phone_required setting. When on, verify-code returns valid: 'needs_visitor_info' so the gate keypad prompts for visitor phone before logging the entry. Default off — high-throughput Sunday services don't want to type 300 phone numbers.Every new action calls em_audit():
barcode.scanned (with code_type)business.create / update / reset_pin / set_status / deletebusiness_visitor.entry_issued / exit_issued / exit_used / phone_mismatchevent_code.created / used / revokedAll visible in /audit for admins to audit who came in, when, by which guard, via which business, and which phone — including failed phone-match attempts.
businesses (id, name, business_type, contact_name, phone UNIQUE, pin_hash, force_pin_change, …)business_codes (code, code_type, business_id, visitor_*, label, event_start_at, expires_at, linked_entry_code_id, issued_by_*, used_at, used_by_*, revoked_at, …) + partial unique index on live codesevent_code_uses (business_code_id, visitor_name, visitor_phone, vehicle_plate, security_staff_id, entry_log_id, used_at)entry_log.business_code_id (new column — additive; migrator adds it automatically)New "Special Access Modules" collapsible section in Settings:
features_barcode_scan togglefeatures_business_visitors toggle + business_visitor_ttl_seconds (30 min–24 h, default 6 h) + business_exit_ttl_seconds (5 min–1 h, default 15 min) + link to /businessesfeatures_event_mass_codes toggle (greyed out unless business_visitors is on) + event_code_max_days (1–30, default 7) + event_visitor_phone_required toggleNew: api/businesses.php, views/businesses.php, views/business-detail.php, views/public/business.php. Modified: schema.sql, app.json, seeds/fresh.sql, seeds/demo.sql, helpers.php, api/portal.php, api/settings.php, views/public/login.php, views/public/_layout.php, views/public/security.php, views/public/code.php, views/public/guest-code.php, views/public/household.php, views/partials/settings-sections.php.
Rewrote every piece of customer-facing marketing copy so it matches the real, gate-focused, Nigerian-context product instead of the generic placeholder that still said "run a residential estate end-to-end".
description, marketplace.why_use, benefits, features — refocused on the one-tap-code-at-the-gate experience, Pa/Ma/gateman wording, household passes, in-app SOS, Paystack inside, no SMS bill for onboarding.PSP.api was using the browser's default fetch cache, so a refresh sometimes returned a stale empty response from before any rows were added. Set cache: 'no-store' + credentials: 'same-origin' on every portal API call.inline-flex + nowrap so the lucide icon and text sit side-by-side on one line.The lazy schema migrator had three compounding bugs that, together, left installs permanently stuck after any partial migration:
1. Unconditional hash stamping in _lazy_migrate_if_stale(). The function stamped the new schema hash after updater_apply_scope() returned, regardless of whether the apply had errors or skipped statements. So a migration that failed halfway through still stamped the hash, and every subsequent request saw $haveHash === $wantHash and skipped the retry — even after the migrator code itself was fixed. 2. No staleness self-check. The lazy migrator's only "is this DB stale?" signal was the schema hash. If the hash was wrongly stamped (per #1), the DB was permanently marked "up to date" even with missing tables. 3. updater_apply_scope() ran the whole schema as one big $db->exec($sql) call. A single failing statement (typically a partial UNIQUE INDEX that conflicts with pre-existing duplicate rows) would roll back the entire transaction — taking every new table in the same schema down with it.
Fixes:
_lazy_migrate_if_stale() no longer stamps the hash at all; that's owned by updater_apply_scope(), which only stamps after a clean(-enough) commit._lazy_has_missing_tables() — a cheap defensive check that scans the schema's declared CREATE TABLE names and verifies each exists in sqlite_master. Returns true if any are missing, which forces the lazy migrator to re-run regardless of the cached hash. Strips SQL comments first so doc strings like "the CREATE TABLE above" don't false-positive.updater_apply_scope() now executes the schema statement by statement (via new updater_split_statements() that respects strings, parens, line + block comments) with per-statement try/catch. A failing CREATE UNIQUE INDEX no longer kills new CREATE TABLEs.skipped items so the migrator doesn't loop forever retrying when the only failure is an index that genuinely can't be created (data violates the constraint).updater_scan_app_users() and updater_apply_app_users() — the bulk path that update.php uses for "Update all" actions.End-to-end verified: an existing install with panic_events / dependents / diesel_log missing AND the new hash already stamped will self-heal on the next page request. Resident data, access codes (including any duplicates), and existing tables are preserved exactly as they were.
white-space: nowrap and font-size: clamp(11px, 3.4vw, 14px), so the subtitle stays on one line on actual mobile devices without overflowing.panic_events / dependents / diesel_log not getting created on installs with duplicate live access codes.onclick= handlers were unreliable across the bottom-sheet rendering path. Refactored to addEventListener + event delegation on the row actions list. Empty-name input gets validated client-side. New pass codes also now show in a celebration sheet with Copy / Share buttons, not just a toast.Driver,Househelp,Nanny,Family,Gardener,Cook,Other). Residents see this exact list in the dropdown when adding a household pass — admin can append cleaner, family-member roles, security guards, etc.core/lib/updater.php: new updater_split_statements() helper, top-level statement loop with per-statement try/catch.portal/relationships (resident-side, reads settings.dependent_relationships).dependent_relationships setting wired into [seeds/fresh.sql](apps/estatemax/seeds/fresh.sql), [seeds/demo.sql](apps/estatemax/seeds/demo.sql), and [app.json](apps/estatemax/app.json) defaults.PSP.openSheet() with proper event handlers and a confirmation sheet that surfaces the new pass code prominently.panic_events, dependents, diesel_log) down with it. Now columns get added on existing tables before the schema executes. Fixes "no such table: panic_events" when raising the SOS button, "could not load fees" with templates missing, and the empty /household page.America/New_York. New helper em_format_local_time() parses the stored UTC string and renders in Africa/Lagos — fixes the "Valid until 4:01 AM" issue on the live-code strip, plus history, visitor pre-approvals, and the broadcast timestamp./dues "Could not load fees" fixed. The fetch URL had a .. segment that some browsers didn't normalize. Now uses an absolute portal URL./household "Add" button now works. The bottom-sheet helper wasn't defined on the portal SDK — built PSP.openSheet() / PSP.closeSheet() / PSP.confirm() to match the admin SDK's shape. Household passes, dependent forms, and confirm dialogs all use it.08… form throughout (Nigeria-only app — never show the 234 country code on screen)./manifest.webmanifest) and a service worker (/sw.js), both scoped to the portal path. Chrome / Edge surfaces the native install prompt; the login page also shows an "Install this app on your home screen" pill (and an iOS-specific Share-sheet hint for Safari, since Safari doesn't fire beforeinstallprompt). Once installed, the app opens straight to the login page on the resident's home screen.service_charges.paystack_reference; verify looks up the charge by reference and refuses any reference that doesn't match. Closes a hole where one resident could credit another resident's successful Paystack reference to their own dues.javascript: and data: links are refused at save time — only http:///https:// links pass.run_estatemax_cron() into the platform cron runner — every active EstateMax install ticks each hour. Sends "3 days before due", "due today", and "3 days overdue" reminders, flips overdue status, and cleans up expired access codes.broadcast_recipients with status='pending' and drained by the cron tick. The first 20 recipients still go out immediately so the admin sees progress; the rest fan out in the background. List view now shows "sent · still queued · failed" breakdowns.080… everywhere, not +234… / 234…. Storage stays in 234 form for stable joins, but every screen — residents list, resident detail, security staff, complaints, visitor log — renders in the Nigerian local form residents actually read. Form placeholders updated to "08012345678".resolve_phone() resolver that tries all four NG variants. Residents whose phone was stored as 08… no longer fail to find their own row./diesel admin page tracks fuel refills (liters · price/liter · total · supplier · optional generator-hours-run) with 90-day spend totals. "Bill residents" wizard one-taps a per-house diesel levy into service_charges (deduped by period). New "Diesel" quick-action tile on the admin home./security) and the admin dashboard (/, /panic) poll for open events every 8 seconds and pop a sticky red banner with a Web Audio siren, an animated icon, vibration on capable devices, and an optional browser desktop notification (one-time permission ask). The banner offers "Call resident" (tel: link) and "Mark responded" buttons; acknowledging from either screen clears it everywhere. New /panic admin view shows the full event history. Rate-limited to one event per resident per minute. Zero ₦ per alert./household portal page. Each resident is capped at 10 active passes.paystack_reference + paystack_amount_kobo columns on service_charges; partial unique index idx_access_codes_live_uniq on (code) WHERE used_at IS NULL AND revoked_at IS NULL; new tables diesel_log, panic_events, dependents; entry_log.dependent_id column.broadcast_recipients.status gained pending / sending values; new idx_broadcast_recipients_pending index.apps/estatemax/helpers.php: em_display_phone(), em_drain_broadcast_queue(), em_unique_dependent_code(), em_dependent_allowed_now().cron_estatemax_tick() lives in core/cron/tasks.php; runs run_estatemax_cron() and em_drain_broadcast_queue() against every active install.app.json: stripped vestigial roles block (pre-pivot shared-DB leftover), flipped ready to true, registered /diesel + /household routes + diesel API.api/portal.php, helpers.php header, api/staff.php.beginTransaction + try/catch with a rollback on failure.service-charges.php had a stray no-op UPDATE access_codes SET id = id WHERE 1=0 removed.resident-detail.php already had the correct slashes in PS.post('residents/...') — left as-is.require_tenant_member() / require_tenant_admin() gates rewritten to require_auth() as a placeholder. apps/estatemax/api/members.php and the members api-route removed.tenant_members / tenant_invites CREATE TABLE statements stripped (comments remain). identity block removed from app.json. The existing residents / security_staff tables (already app-managed with pin_hash) are untouched./guest-code page: one tap generates a 6-digit code for a visitor, valid 6 hours by default (admin-configurable). Big "Share via WhatsApp / SMS" button (uses the Web Share API where available, WhatsApp deep-link as fallback) and a "Copy" button. Optional guest details (name / phone / vehicle plate) live in a collapsed <details> panel so the one-tap path stays clean./history page lists the resident's last 20 codes with status — Used / Revoked / Expired / Live — and the guard's name where applicable./dues page. Each unpaid charge gets a "Pay ₦X online" button; tapping opens the Paystack popup. On success, the payment is server-verified against Paystack's API (no client-side trust) and recorded with method paystack. Charge status flips to paid once the balance hits zero. Existing manual cash / transfer / POS flows are unchanged.guest_code_ttl_seconds (default 21600 = 6 hours, min 5 min, max 7 days).sms_on_code_used (default 0).paystack_enabled, paystack_public_key, paystack_secret_key (default disabled, empty keys).access_codes gains code_type, revoked_at, revoked_by_role, revoked_by_user_id, guest_name, guest_phone, guest_plate, used_vehicle_plate. New index idx_access_codes_type. Per CLAUDE.md the platform's lazy migrator picks these up from the CREATE TABLE diff.helpers.php: em_active_guest_codes, em_issue_guest_code, em_revoke_code, em_unique_code, em_notify_code_used, em_guest_code_ttl_seconds. em_issue_code is now scoped to code_type = 'self' (guest codes are untouched when a new self-code is issued).lib/payments.php with em_is_payment_enabled, em_payment_currency, em_verify_paystack_payment (pattern lifted from PropertyPro).get-guest-code, revoke-code, my-codes, recent-uses, paystack-init, paystack-verify. New admin actions: residents/revoke-code, residents/active-codes.EstateMax is now a full app. The marquee surface is the one-tap gate-code flow; the rest of estate management sits behind it.
1234; admin can change it in Settings.0000.gate.yourestate.com to the resident portal at /admin/domains.residents, security_staff, access_codes, entry_log, service_charges, service_charge_payments, complaints, complaint_messages, broadcasts, broadcast_recipients, meeting_minutes, notification_log, reminder_log, verify_attempts, tenant_invites.helpers.php: portal session auth (require_resident, require_security), em_issue_code, default-PIN hashing, CSV parsers, SMS wrapper, cron runner.PSP SDK (not the platform PS) because residents have no Pancho session.apps/accesspass/ folder. Pre-launch — no migration needed.tenant_members table to schema.sql (backed by the platform's _per_user_app_bootstrap_owner() hook).estatepro → estatemax).subscriptions.app_id value now uses the new slug. Platform had not gone live yet, so old URLs are not preserved.All notable changes to this app are recorded here. Newest entries on top.