Rentara · Test run · run 2026-11-07
Rentara Test run, as shipped in the download
663 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 with php tests/run.php. Run it yourself after you download.
== 1. occupancy: occupied / total, notice excluded ==
PASS total units (got 5, want 5)
PASS occupied count (got 3, want 3)
PASS vacant count (got 1, want 1)
PASS notice count (got 1, want 1)
PASS occupancy pct = occupied/total (3/5=60) (got 60, want 60)
PASS empty portfolio pct is 0 (no div-by-zero) (got 0, want 0)
PASS empty portfolio total 0 (got 0, want 0)
PASS fully occupied = 100 (got 100, want 100)
PASS rounding 1/3 -> 33 (got 33, want 33)
== 2. arrears: unpaid AND due_date strictly before today ==
PASS arrears count (only #1 and #5) (got 2, want 2)
PASS arrears total is sum of overdue unpaid (got 235000, want 235000)
PASS due-today charge is NOT arrears
PASS paid charge is NOT arrears
PASS future charge is NOT arrears
PASS yesterday charge IS arrears
PASS no charges -> zero arrears (got 0, want 0)
== 3. monthTotals: collected vs due for a calendar month ==
PASS due this month = sum of July charges (got 455000, want 455000)
PASS collected this month = paid July charges (got 335000, want 335000)
PASS outstanding = due - collected (got 120000, want 120000)
PASS due count (got 3, want 3)
PASS paid count (got 2, want 2)
PASS month with no charges -> 0 due (got 0, want 0)
== 4. planCharges: idempotent monthly generation for active leases ==
PASS generates one charge per active lease (2) (got 2, want 2)
PASS ended lease produces no charge
PASS upcoming lease produces no charge
PASS due date is month-01 (got '2026-07-01', want '2026-07-01')
PASS amount comes from lease rent (got 145000, want 145000)
PASS re-running with existing charges creates nothing (idempotent) (got 0, want 0)
PASS only the un-charged active lease is planned (got 1, want 1)
PASS and it is lease 11 (got 11, want 11)
PASS a different month generates fresh charges (got 2, want 2)
PASS duplicate lease id in input -> single charge (got 1, want 1)
== 5. expiringLeases: active leases ending within N days (inclusive) ==
PASS three leases in the 30-day window (got 3, want 3)
PASS lease ending exactly today is included
PASS lease ending on the last day (today+30) is included
PASS lease ending one day past the window is excluded
PASS already-expired lease excluded
PASS non-active lease excluded
PASS open-ended lease excluded
PASS sorted soonest-first (first is today) (got 1, want 1)
PASS next is July 20 (id 7) before Aug 4 (id 2) (got 7, want 7)
== 6. ticket state machine: open -> in_progress -> resolved ==
PASS open -> in_progress allowed
PASS open -> resolved allowed
PASS in_progress -> resolved allowed
PASS in_progress -> open allowed
PASS resolved -> open allowed (reopen)
PASS resolved -> in_progress NOT allowed
PASS open -> open NOT allowed (no-op)
PASS unknown state NOT allowed
PASS moving to resolved stamps resolved_at (got '2026-07-05 12:00:00', want '2026-07-05 12:00:00')
PASS resolving keeps an existing stamp (got '2026-06-01 09:00:00', want '2026-06-01 09:00:00')
PASS reopening clears resolved_at (got NULL, want NULL)
PASS moving to in_progress clears resolved_at (got NULL, want NULL)
== 7. unit status flips on lease assign / end ==
PASS assigning a lease occupies the unit (got 'occupied', want 'occupied')
PASS ending a lease frees the unit (got 'vacant', want 'vacant')
PASS occupyUnit does not mutate the input array
PASS lease status derives to upcoming for a future start (got 'upcoming', want 'upcoming')
PASS lease status derives to active for a past/today start (got 'active', want 'active')
== 8. toCsv: RFC-4180 escaping + CRLF ==
PASS header row (got 'Property,Tenant,Amount,Note', want 'Property,Tenant,Amount,Note')
PASS plain row unquoted (got 'Maple Court,Sarah Johnson,1450.00,ok', want 'Maple Court,Sarah Johnson,1450.00,ok')
PASS CRLF line endings used
PASS field with a comma is quoted
PASS embedded double-quotes are doubled
PASS newline-containing field survives a round-trip (got 'line1
line2', want 'line1
line2')
PASS quoted comma field parses back correctly (got 'O\'Brien, James', want 'O\'Brien, James')
PASS formula = neutralized
PASS formula + neutralized
PASS formula - neutralized
PASS formula @ neutralized
PASS leading TAB neutralized
PASS formula with comma is neutralized AND rfc-quoted
PASS dangerous cell in a later column neutralized
PASS neutralized value carries the guard quote (got '\'=1+1', want '\'=1+1')
PASS a plain non-formula value is NOT altered
PASS negative amount exports verbatim (numeric-aware guard) (got '-1234.56', want '-1234.56')
PASS positive amount exports verbatim (got '1450.00', want '1450.00')
PASS zero exports verbatim (got '0', want '0')
PASS negative amount round-trips to a parseable number (got '-1234.56', want '-1234.56')
== 9. money / cents helpers ==
PASS money formats cents with symbol (got '$1,450.00', want '$1,450.00')
PASS money handles negatives (got '-$45.00', want '-$45.00')
PASS money zero (got '$0.00', want '$0.00')
PASS to_cents parses plain decimal (got 145000, want 145000)
PASS to_cents strips grouping + symbol (got 145050, want 145050)
PASS to_cents empty -> 0 (got 0, want 0)
PASS to_cents clamps oversize positive to INT max (got 2147483647, want 2147483647)
PASS to_cents clamps oversize negative to INT min (got -2147483648, want -2147483648)
PASS to_cents leaves an in-range amount intact (got 2100000000, want 2100000000)
PASS str_cap truncates to the column length (got 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', want 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
PASS str_cap leaves a short string intact (got 'Maple Court', want 'Maple Court')
PASS centsToDecimal round-trips (got '1450.00', want '1450.00')
PASS centsToDecimal null -> empty (got '', want '')
PASS currency_symbol known code (got '€', want '€')
PASS currency_symbol unknown code falls back (got 'SEK ', want 'SEK ')
== 10. date helpers + validators ==
PASS date_add_days forward (got '2026-08-04', want '2026-08-04')
PASS date_add_days backward (got '2026-06-30', want '2026-06-30')
PASS date_add_months (got '2027-07-05', want '2027-07-05')
PASS Rent::addDays matches (got '2026-08-19', want '2026-08-19')
PASS normalizeUnitStatus keeps valid (got 'notice', want 'notice')
PASS normalizeUnitStatus defaults invalid (got 'vacant', want 'vacant')
PASS normalizePriority keeps valid (got 'high', want 'high')
PASS normalizePriority defaults invalid (got 'med', want 'med')
PASS normalizeLeaseStatus defaults invalid (got 'active', want 'active')
== 11. billingPeriods: anchor day, clamping, frequency, end date ==
PASS mid-month start: multi-month catch-up plans 5 periods (Mar–Jul) (got 5, want 5)
PASS first due date IS the lease start date (got '2026-03-10', want '2026-03-10')
PASS first period key (got '2026-03', want '2026-03')
PASS later periods keep the anchor day (got '2026-06-10', want '2026-06-10')
PASS Jan-31 anchor clamps to Feb 28 (got '2026-02-28', want '2026-02-28')
PASS …back to the 31st in March (got '2026-03-31', want '2026-03-31')
PASS …and the 30th in April (got '2026-04-30', want '2026-04-30')
PASS quarterly: 3 periods Jan/Apr/Jul (got 3, want 3)
PASS quarterly periods land every 3rd month (got '2026-04', want '2026-04')
PASS quarterly keeps the anchor day (got '2026-07-15', want '2026-07-15')
PASS periods due after the lease end date are excluded (got 3, want 3)
PASS last period is the one still due before end (got '2026-05-10', want '2026-05-10')
PASS future start yields no periods (got 0, want 0)
PASS garbage start date yields no periods (got 0, want 0)
== 12. planRecurring: idempotent catch-up keyed on lease+period ==
PASS catch-up plans 5 + 2 periods for the two active leases (got 7, want 7)
PASS ended lease posts nothing
PASS rows carry the clear ledger label (got 'Rent 2026-03', want 'Rent 2026-03')
PASS amount comes from the lease (got 100000, want 100000)
PASS 2nd catch-up run plans nothing (idempotent) (got 0, want 0)
PASS 3rd catch-up run still plans nothing (got 0, want 0)
PASS legacy month-01 charge suppresses that period (no double-billing) (got 4, want 4)
PASS and the suppressed period is March
PASS planForMonth returns only the asked month (got 2, want 2)
PASS …with the anchored due date (got '2026-07-10', want '2026-07-10')
PASS planForMonth on a pre-start month is empty (got 0, want 0)
PASS quarterly lease skips an off-cycle month (got 0, want 0)
== 13. planLateFees: grace boundary, flat vs bp vs both, one per period ==
PASS due+grace day exactly -> NO fee (grace boundary) (got 0, want 0)
PASS due+grace+1 -> fee posts (got 1, want 1)
PASS flat fee amount (got 2500, want 2500)
PASS fee due date is deterministic (due+grace+1) (got '2026-06-07', want '2026-06-07')
PASS fee label names the overdue period (got 'Late fee — Rent 2026-06', want 'Late fee — Rent 2026-06')
PASS fee keeps the rent period key (got '2026-06', want '2026-06')
PASS bp fee: 5% of 1450.00 = 72.50 (got 7250, want 7250)
PASS flat + bp combine: 25.00 + 72.50 = 97.50 (got 9750, want 9750)
PASS an existing fee for the period suppresses a second one, forever (got 0, want 0)
PASS a PAID rent charge never gets a fee (got 0, want 0)
PASS late fees never compound (late_fee input ignored) (got 0, want 0)
PASS rule disabled (0 flat, 0 bp) -> automation off (got 0, want 0)
PASS zero grace: overdue by 1 day fees immediately (got 1, want 1)
PASS lateFeeCents clamps to the INT range (got 2147483647, want 2147483647)
== 14. statementTotals: hand-computed cash-basis fixture ==
PASS rent collected = 1450.00 + 1650.00 (got 310000, want 310000)
PASS late fees collected = 97.50 (got 9750, want 9750)
PASS maintenance = only the in-range resolved cost (got 40000, want 40000)
PASS NET = 3100.00 + 97.50 − 400.00 = 2797.50 (got 279750, want 279750)
PASS billed in range (all types due Jun) (got 609750, want 609750)
PASS still-open billed (got 190000, want 190000)
PASS empty statement nets zero (got 0, want 0)
== 15. Billing on a real database: 3× catch-up -> identical ledger; token flow ==
[DB: SQLite]
PASS 3.0: migrate() created the settings singleton, so the fixture UPDATE lands
PASS run 1 posts 5 rent periods (Mar 15 … Jul 15) (got 5, want 5)
PASS run 1 posts 4 late fees (Jul rent not yet past grace) (got 4, want 4)
PASS late fee amount = 25.00 flat + 1% of 1000.00 (got 3500, want 3500)
PASS run 2 posts nothing (got 0, want 0)
PASS run 3 posts nothing (got 0, want 0)
PASS ledger after 3 runs is byte-identical to after 1 run
PASS ledger row count stays 9 (got 9, want 9)
PASS rent rows carry clear labels (got 'Rent 2026-03', want 'Rent 2026-03')
PASS fee rows carry clear labels (got 'Late fee — Rent 2026-03', want 'Late fee — Rent 2026-03')
PASS later run: only the missing Aug rent posts; paid-July gets no fee (got 1, want 1)
PASS …and no new fees for old periods (one per period, ever) (got 0, want 0)
PASS DB unique index rejects a duplicate (lease,type,period) row
-- portal token flow --
PASS unknown token -> null (404 path) (got NULL, want NULL)
PASS empty token -> null (got NULL, want NULL)
PASS oversize token -> null (got NULL, want NULL)
PASS issued token is 40 hex chars (got 1, want 1)
PASS valid token resolves to the lease with display context
PASS regenerating rotates the token
PASS the OLD link is dead immediately after regenerate (got NULL, want NULL)
PASS the new link works
PASS revoked token -> null (404 path) (got NULL, want NULL)
-- portal per-IP rate limit --
PASS 5 submissions/hour from one IP -> limited
PASS a different IP is not limited
PASS empty IP never limited (proxied installs)
== 16. Database::migrate(): old-shape install upgrades in place ==
PASS settings gains the version marker (got 2, want 2)
PASS settings data intact (got 'Buyer LLC', want 'Buyer LLC')
PASS leases gain portal_token / frequency / auto_bill
PASS migrated lease keeps automation OFF (no surprise backfill) (got 0, want 0)
PASS migrated lease defaults to monthly (got 'monthly', want 'monthly')
PASS charge rows intact (got 2, want 2)
PASS period keys backfilled from due dates (got '2026-05', want '2026-05')
PASS legacy charges typed as rent (got 'rent', want 'rent')
PASS paid data intact (got '2026-05-02', want '2026-05-02')
PASS lease_documents table exists and accepts rows
PASS properties gain owner fields (got '', want '')
PASS tickets gain source (default staff) (got 'staff', want 'staff')
PASS late fee can share a due date with a rent charge (old index dropped) (got 2, want 2)
PASS second migrate() is a no-op (version short-circuits) (got 3, want 3)
PASS migrated lease does not auto-post (automation off by default) (got 0, want 0)
== 17. upload-name safety + honeypot + clip ==
PASS pdf upload gets a random name under data/uploads (got 1, want 1)
PASS jpeg upload name pattern (got 1, want 1)
PASS names are random (two calls differ)
PASS text/html upload rejected (got NULL, want NULL)
PASS image/svg+xml rejected (script-capable) (got NULL, want NULL)
PASS application/x-php rejected (got NULL, want NULL)
PASS pdf is NOT allowed for tenant photos (got NULL, want NULL)
PASS png IS allowed for tenant photos (got 1, want 1)
PASS honeypot trips on a filled website field
PASS honeypot silent for real users (empty/absent)
PASS 9000-char tenant title clips to the column width (got 190, want 190)
PASS tenant details clip to 16000 chars (utf8mb4-safe under 64 KB) (got 16000, want 16000)
== 18. CSRF empty-token rejection + new POST handlers guarded ==
PASS empty stored + empty submitted CSRF token is REJECTED (no hash_equals("","") bypass)
PASS new POST handler portal_submit enforces CSRF
PASS new POST handler lease_portal_issue enforces CSRF
PASS new POST handler lease_portal_revoke enforces CSRF
PASS new POST handler lease_autobill enforces CSRF
PASS new POST handler document_upload enforces CSRF
PASS new POST handler document_delete enforces CSRF
PASS new POST handler ticket_cost enforces CSRF
PASS portal submit checks the honeypot
PASS portal submit enforces the per-IP rate limit
PASS document serve is auth-gated
PASS document serve sends nosniff
PASS ticket photo serve is auth-gated + nosniff
PASS statement endpoints are auth-gated
PASS document upload verifies MIME with finfo, not the client
== 19. statement CSV: formula injection neutralized, money stays numeric ==
PASS malicious property name neutralized in statement CSV
PASS malicious tenant name neutralized
PASS malicious label neutralized
PASS negative money survives verbatim (numeric-aware guard)
PASS positive money survives verbatim
== PWA ==
PASS manifest.json is valid JSON
PASS manifest has required fields
PASS manifest URLs are RELATIVE (subpath-install safe)
PASS manifest icons exist on disk (192 + 512 + apple-touch)
PASS sw.js never caches non-GET
PASS sw.js never caches page navigations (auth HTML can not enter cache storage)
PASS sw.js cache name carries the version (old caches purged on activate)
PASS offline fallback page ships
PASS every app layout links the manifest + registers the SW
-- Own It 2.0 --
PASS v2: tables
PASS v2: totp_cols
PASS v2: smtp_cols
PASS v2: v20_survives_version_guard
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: rate
PASS v2: csv_dry
PASS v2: csv_commit
PASS v2: csv_dup_skips
PASS v2: payment_rule
PASS v2: payment_fallbacks
PASS v2: ticket_rules
PASS v2: arrears_computed
PASS v2: backup_tables
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: notifications + webhooks fire on the BROWSER path (incl. the tenant portal), never fatally
PASS v2: API reuses the tested core (no re-derived guards)
PASS v2: the UI posts payments through the SAME rule as the API
PASS v2: dark palette + no hardcoded white backgrounds outside @media print
PASS v2: theme.js + qr.js ship and layout loads theme.js + a toggle
PASS v2: shared modules are copy-identical to the shared core (never forked per app)
PASS v2: API.md ships (the sw cache version is asserted in the 3.0 block)
== Own It 3.0: MCP - roles - GDPR - iCal - PDF - the migrate trap ==
PASS migrate() creates audit_log + invites + saved_views
PASS users gain role; settings gain branding, SSO and the calendar token; tenants gain anonymized_at
PASS install() ALONE produces the current schema
PASS migrate() creates the settings singleton row
PASS so "UPDATE settings ... WHERE id = 1" is no longer a silent no-op
PASS **THE TRAP: migrate() returns early on a current install — the v3 block still runs**
PASS a member records payments, works tickets, edits leases, issues portal links
PASS but cannot delete a tenant or property, or reach settings
PASS a viewer reads the rent roll and statements and cannot record or edit
PASS a viewer canNOT read the audit trail or the team roster
PASS an admin holds deletions and the late-fee rule
PASS AN UPGRADE NEVER LOOSENS: the existing user stays an admin
PASS the last admin cannot be demoted into a lockout
PASS tickets move open -> in_progress -> resolved
PASS **resolved -> open is ALLOWED (the intentional reopen the row names)**
PASS but resolved -> in_progress is not a legal move
PASS resolving stamps resolved_at
PASS and reopening clears it
PASS MCP exposes exactly the row's seven tools, in order
PASS every tool description declares READ-ONLY or WRITES
PASS GET /mcp is refused with 405, not 404
PASS a nested argument that is not allowlisted is refused
PASS an unknown argument is refused BY NAME
PASS lease_detail returns the ledger and the outstanding balance
PASS **arrears_report EQUALS Rent::arrears() — never re-derived**
PASS and it finds the overdue charges with the right total
PASS rent_roll EQUALS Rent::monthTotals()
PASS create_ticket raises a ticket with a normalized priority
PASS advance_ticket moves a ticket forward
PASS **an agent may REOPEN a resolved ticket, exactly as the UI can**
PASS and an illegal move is refused with the allowed ones named
PASS MCP role gate matches the UI: a viewer key cannot raise a ticket
PASS and a viewer key can still read arrears
PASS MCP writes land in the audit trail
PASS the fixture has a live tenancy AND money owed (the blocked case)
PASS subject access returns the person
PASS **anonymize really blanks name, email and phone (verified on the ROW)**
PASS **and the tenancy stays — it is the record of a tenancy, not of a person**
PASS and it reports what it touched
PASS the statement renders as a real PDF
PASS and its totals come from Rent::statementTotals()
PASS the calendar token verifies and a wrong one does not
PASS the feed is a real VCALENDAR with stable UIDs
PASS **the calendar feed carries no tenant name or email**
PASS the backup drops bcrypt password hashes
PASS and API-key hashes
PASS and the SMTP password
PASS **and the tenant portal token — a live magic link into a ledger**
PASS while still carrying the estate itself
PASS and a restore keeps the live token rather than blanking it
PASS inkOn() clears 4.5:1 on a very light and a very dark accent
PASS a PHP script named .png is refused
PASS another product's backup is refused by name stamp
PASS OIDC refuses alg=none
PASS mutations write audit entries
PASS the trail exports as CSV
PASS **no audit row contains a tenant portal token**
PASS exactly ONE place fires payment.recorded
PASS exactly ONE place fires maintenance.status_changed
PASS the ticket transition guard is called from one place
PASS all four payment doors go through _rn_payment_recorded()
PASS all four ticket doors go through _rn_ticket_status_apply()
PASS no door invents an event name nobody subscribes to
PASS bulk actions email and fire webhooks AFTER the commit
PASS the two tenant-facing toggles exist
PASS a fresh install writes to no tenant
PASS a dead mail server returns false, never an exception
PASS no address (or a malformed one) is skipped, not an error
PASS the receipt reaches the tenant: amount, date, what is left
PASS **the receipt never asks a tenant to pay online**
PASS a status change reaches the tenant who raised it
PASS both messages carry ONE well-formed portal link
PASS a refused transition writes nothing and says why
PASS saving the org card no longer erases the SMTP config
PASS absolute URLs are built once — no doubled scheme, views included
PASS Settings shows and saves the tenant-facing switches
== Own It 3.0: wiring (static) ==
PASS Database::migrate() runs at boot
PASS POST and GET /mcp are both routed
PASS src includes are idempotent
PASS migrateV3() is called before migrate()'s fast-path return
PASS install() also reaches the v3 schema
PASS the maintenance bulk literal is registered BEFORE /maintenance/{id}
PASS the MCP layer computes no arrears, rent totals or transitions of its own
PASS the statement PDF reuses the screen's own scope/range/rows helpers
PASS bulk pay loops Billing::recordPayment() inside one transaction
PASS the roll's pay endpoint delegates to Billing::recordPayment and never writes the ledger itself
PASS the roll's pay endpoint is role-gated (403) and CSRF-checked before any write
PASS the roll's pay endpoint maps refusals to honest codes and audits the payment
PASS the rent roll is unit-centric: every unit is a row, vacant ones included
PASS the roll keeps its bulk-pay and saved views (no feature lost to the redesign)
PASS no bulk action mass-updates the ledger behind the product's back
PASS bulk ticket moves are validated per ticket, not per batch
PASS the GDPR map uses the key the core actually reads
PASS anonymizing is refused while a tenancy is active or money is owed
PASS the audit trail never receives a raw portal token
PASS destructive + money actions are role-gated
PASS the REST surface is role-gated too
PASS money mutations write audit entries
PASS the backup redaction list covers the portal token
PASS BOTH backup paths use that one list
PASS filled controls take their ink from the branding contrast helper
PASS the uploaded logo is actually RENDERED in the layout
PASS the branding accent overrides BOTH themes
PASS P5 is reachable and does not nest forms on the rent roll
PASS P5 is reachable on the maintenance list too
PASS the tenant page exposes subject access and anonymization
PASS the PDFs are reachable from the screens that own them
PASS src/Mcp.php is byte-identical to the shared core copy of Mcp.php
PASS src/AuditLog.php is byte-identical to the shared core copy of AuditLog.php
PASS src/Pdf.php is byte-identical to the shared core copy of Pdf.php
PASS src/Ical.php is byte-identical to the shared core copy of Ical.php
PASS src/BackupRestore.php is byte-identical to the shared core copy of BackupRestore.php
PASS src/Branding.php is byte-identical to the shared core copy of Branding.php
PASS src/RolesKit.php is byte-identical to the shared core copy of RolesKit.php
PASS src/Oidc.php is byte-identical to the shared core copy of Oidc.php
PASS src/Gdpr.php is byte-identical to the shared core copy of Gdpr.php
PASS a switched-off person's API key stops working (u.active in requireKey)
PASS sw.js cache is static-v4.1.3
PASS healthz + api_ping + openapi report 4.1.3
PASS API.md documents the MCP endpoint and the reopen rule
PASS README documents "New in 3.0"
PASS the deploy kit is filled in
PASS nav: every sidebar link is highlighted by the page it opens
PASS nav: no page highlights a sidebar item that does not exist
PASS ip: a direct visitor's forged headers are ignored
PASS ip: behind a trusted proxy the forwarded visitor is used
PASS ip: CF-Connecting-IP wins over the X-Forwarded-For chain
PASS ip: the chain is read right-to-left, past trusted hops
PASS ip: a Cloudflare edge is not a trusted proxy by default
PASS ip: CIDR maths (v4 boundaries + v6, no cross-family match)
PASS scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
PASS scope: requireKey reads the key scope and refuses a write on a read-only key
PASS scope: mintKey persists the chosen scope, and anything but read is full access
PASS scope: /mcp hands the key scope to the core, which refuses write tools per tool
PASS scope: the key form lets an administrator mint a read-only key
PASS license: every font ships the SIL OFL text for its own family beside it
PASS license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
== fix wave: the landlord hears when a lease is ending ==
PASS lease end setting and ledger exist
PASS lease end is off on a fresh install
PASS lease end needs smtp and an address
PASS lease end dry run lists only active leases inside 60 days
PASS lease end a refused send releases its claims
PASS lease end one message to the landlord listing both
PASS lease end the same stage is never sent twice
PASS lease end the 14 day stage comes once more
PASS lease end a renewed lease starts over
PASS lease end is audited
PASS lease ends: the viewer is refused the check-now button (403)
PASS lease ends: the member is refused the check-now button (403)
PASS lease ends: an admin presses the button and is told what happened
PASS lease ends: the switch and the schedule line are on the Email settings
PASS lease ends: cron/lease-ends.php runs the one function and refuses anything but the command line
PASS lease ends: .htaccess refuses the cron/ script, src/ and data/
PASS lease ends: .htaccess lets the /cron/reminders route reach the app (the manual's daily cron line)
PASS lease ends: the dev router refuses cron/ files only, not the /cron/reminders route
PASS ui: the sidebar brand cannot be shrunk to nothing (flex-shrink:0)
== fix wave: the webhook buttons work ==
PASS webhooks: Add creates the endpoint (no 404)
PASS webhooks: Pause switches it off (no 404)
PASS webhooks: pressing it again switches it back on
PASS webhooks: the page offers Pause and Delete for the endpoint, at those same addresses
PASS webhooks: Delete removes it (no 404)
PASS readme: no feature is still labeled "new in 1.1"
== fix wave: every backup and restore route is administrator-only ==
PASS backup: a signed-in viewer gets 403 on GET /backup.json
PASS backup: a signed-in viewer gets 403 on GET /backup.sqlite
PASS backup: a signed-in viewer gets 403 on GET /restore
PASS backup: a signed-in viewer gets 403 on POST /restore/preview
PASS backup: a signed-in viewer gets 403 on POST /restore/commit
PASS backup: a signed-in viewer gets 403 on POST /backup/token
PASS backup: a signed-in viewer without the token gets nothing from GET /backup/scheduled
PASS backup: a signed-in member gets 403 on GET /backup.json
PASS backup: a signed-in member gets 403 on GET /backup.sqlite
PASS backup: a signed-in member gets 403 on GET /restore
PASS backup: a signed-in member gets 403 on POST /restore/preview
PASS backup: a signed-in member gets 403 on POST /restore/commit
PASS backup: a signed-in member gets 403 on POST /backup/token
PASS backup: a signed-in member without the token gets nothing from GET /backup/scheduled
PASS backup: an admin still opens the restore page
-- F2b: the demo ribbon's wording and size; the JSON backup's secrets --
PASS demo ribbon: on the owner link (/o/…) it says what the page is, not "signed in as the admin"
PASS demo ribbon: on the tenant link (/t/…) it says what the page is, not "signed in as the admin"
PASS demo ribbon: an administrator's page keeps the admin wording
PASS demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
PASS demo ribbon: registered only behind the .demo-mode marker, and non-HTML output passes through untouched
PASS backup: the JSON download carries none of the 16 credentials this schema holds (settings.oidc_client_secret, settings.backup_token_hash, settings.ical_token_hash, settings.smtp_pass, settings.stripe_secret_key, settings.stripe_webhook_secret, users.password_hash, users.totp_secret, users.totp_recovery, tenants.sms_stop_token, leases.portal_token, invites.token_hash, backup_state.s3_secret, api_keys.token_hash, webhooks.secret, owner_links.token_hash)
== 3.2 · part payments: a payment splits a charge; the rest stays owed ==
PASS 3.2 schema: the charge key is (lease, type, period, part), and the old three-column key is gone
PASS part payment: 1,500.00 of 2,100.00 — the charge keeps the paid 1,500.00, a balance row carries 600.00, same period and due date
PASS part payment: collected + outstanding still add up to what was charged (4,200.00)
PASS part payment: the message says what is still owed on that charge
PASS part payment: a part of the balance splits again (part 2), without breaking the unique key
PASS part payment: more than the charge is refused (spread a larger payment on the lease instead)
PASS lease payment: 1,000.00 pays the oldest first — the 500.00 January balance in full, then 500.00 of February
PASS lease payment: a receipt row is kept for it
PASS lease payment: more than is owed is refused and changes nothing
PASS lease payment: nothing, or a negative amount, is refused
PASS the catch-up guarantee holds: a second part-0 rent row for the same period is still refused by the database
PASS late fees: a period with a paid part and an unpaid balance gets one fee, not one per row
== 3.2 · the tenant's pay button (the landlord's own Stripe) ==
PASS card payment is OFF on a fresh install
PASS with a key, a signing secret and a card currency, it is ready (charged in the landlord's currency, usd)
PASS checkout: the lease's whole balance (2,400.00), the lease as reference, back to the tenant's own page
PASS checkout: Stripe is asked with the landlord's key, and the tenant goes to Stripe's page
PASS webhook: Stripe's signature verifies; a wrong secret or a stale event does not
PASS webhook: a paid checkout is recorded oldest charge first (March in full, 300.00 of April)
PASS webhook: Stripe retrying the same event records nothing more
PASS webhook: another currency is refused
PASS webhook: a lease that does not match the metadata is ignored
PASS webhook: paying more than is owed by then applies what is owed and writes the excess on the payment for the landlord
PASS backup + audit: both Stripe secrets are on the redaction lists
== 3.2 · rent reminders and the landlord's digest ==
PASS reminders: every kind is OFF on a fresh install, and a run writes nothing
PASS reminders: the preview lists exactly who gets one today — rent coming up (Cara) and 3 days late (Dev) — and writes nothing
PASS reminders: a tenancy first seen long in arrears gets nothing today (past every step's window), never a burst
PASS reminders: a paused lease and a tenant with no address are held, with the reason
PASS reminders: the run sends those two (demo: recorded, no socket), each claimed with its reference
PASS reminders: a second run the same day sends nothing
PASS reminders: a tenant with two charges coming up gets ONE note today; the other waits, with the reason
PASS reminders: the overdue note states what is owed and since when, links the ledger, and calls itself a reminder, not a notice
PASS digest: the landlord's list goes once a day (demo: recorded), and again today says done
PASS digest: it lists every tenancy in arrears (Dev, Eli, Fay, Gil among them), paused or not — it goes to the landlord, not a tenant
PASS reminders: through a real SMTP conversation, Hana's "rent coming up" leaves, to her, with the amount and the date
PASS reminders: a refused send is recorded as failed, gives its claim back (tomorrow retries) and ends the run
== 3.2 pass B · late fees: per day, and a cap ==
PASS late: the cap is the lower of a fixed amount and a percent of the rent, and absent when neither is set
PASS late: the per-day fee counts each day after the grace days, until the rent is paid in full
PASS late: the per-day fee counts only from the day it was switched on, and never reaches rent settled before then
PASS late: the one-off fee is held under the cap too
PASS late: the one-off fee, then the daily fee growing one row, and all of it held at the cap
PASS late: rent paid three days late earns three days, and the fee stops there
PASS late: once a payment has reached the daily fee, the next days are a new part and the paid one is untouched
PASS late: the per-day fee counts as a fee on the owner statement
== 3.2 pass B · the owner portal ==
PASS owner: a link only for an owner named on a property
PASS owner: the link opens its owner, a new one stops the old, and a withdrawn one opens nothing
PASS owner: months and their ranges
PASS owner: only their properties, with the rent owed today
PASS owner: the monthly email names the month and carries the link
PASS owner: the monthly note goes once a month to an owner with an email and a live link, and not at all while it is off
== 3.2 · the doors, through the real front controller ==
PASS door: a viewer cannot record a payment (403)
PASS door: a member records 1,250.00 against the lease — January in full, 250.00 of February, 750.00 still owed
PASS api: POST /api/leases/{id}/payments with amount_cents and no charge_id pays the lease oldest-first through Billing::applyPayment
PASS door: the lease page lists the payment and offers "Record a payment" while something is owed
PASS door: POST /hooks/stripe with no signature is refused 400
PASS door: the tenant's page shows no card button while card payment is off
PASS door: "Pay by card" while it is off asks Stripe for nothing and says so
PASS door: the reminders preview opens for an admin and sends nothing; a member is refused
PASS door: /cron/reminders without the token is refused
PASS door: Settings carries both 3.2 sections
== 3.2 pass B · the owner portal and the late-fee rule, through the front controller ==
PASS door: the owner link opens their statement with no login, out of search engines, and nothing of another owner
PASS door: the owner downloads the month as CSV
PASS door: a wrong owner link is a 404 that names nobody
PASS door: a viewer cannot make an owner link; a member can
PASS door: only an administrator switches the monthly owner email on
PASS door: Settings saves the per-day fee and the cap, dates the day it was switched on, keeps that day when the amount changes, and clears it when switched off
————————————————————————————————————————————————————
== 3.3 · a bank statement matched to rent, and the units import's preview ==
PASS bank statement: dates read day-first or month-first as you say, ISO dates always, an impossible date refused [sqlite]
PASS bank statement: money in is read (with thousands and a currency sign); money out and a number it cannot read are left out [sqlite]
PASS bank statement: money in only, one fixed reference per line (identical lines apart), an unreadable line named [sqlite]
PASS bank statement: proposed by the tenant's name; refused when two tenancies fit, when it is more than owed, or when nothing fits [sqlite]
PASS bank statement: with no name, an amount only one tenancy owes is proposed and flagged to check [sqlite]
PASS bank statement: a confirmed line is recorded like a payment typed in (oldest first), as a transfer from the bank, once [sqlite]
PASS bank statement: the same statement again shows what is already recorded [sqlite]
PASS bank statement: more than is owed is refused, as for a payment typed in [sqlite]
PASS installing again over a database that holds a part payment works, and keeps the split (the demo reset runs it) [sqlite]
PASS units import preview: it counts exactly what the import brings in, names the same skipped rows, and writes nothing [sqlite]
PASS units import preview: a unit that exists, one twice in the file and a row with no property are each named [sqlite]
PASS units import preview: the upload step uses the import's own handler in normal mode, never the dry-run flag [sqlite]
PASS bank statement door: a viewer cannot open it or record from it (403)
PASS bank statement door: an administrator gets the upload form, and recording with no statement uploaded records nothing
PASS bank statement door: the rent roll links to it for someone who records rent
== rent texts: the landlord's own SMS gateway, only to tenants who agreed ==
PASS texts: off on a new install; when switched on the defaults are 3 days before and the due day, the late-fee text, 09:00 to 20:00 [sqlite]
PASS texts: while they are off nothing is sent and nothing is claimed, even for a tenant who agreed [sqlite]
PASS texts: a new tenant has not agreed to texts [sqlite]
PASS texts: a gateway address that is not https never switches texts on [sqlite]
PASS consent: agreeing needs a phone number a text can go to [sqlite]
PASS consent: the agreement records when and who, and makes the tenant's stop link [sqlite]
PASS consent: of two tenants with rent coming up, only the one who agreed gets a text [sqlite]
PASS consent: the agreement is a condition inside both queries that pick who gets a text [sqlite]
PASS texts: each one names the business, says what is due and when, and ends with the stop link [sqlite]
PASS texts: the tenant's own link rides along, with no card offer while card payment is off [sqlite]
PASS texts: the log keeps the words, never the tenant's link or stop link [sqlite]
PASS exactly once: a second run the same day sends nothing [sqlite]
PASS exactly once: the claim is one row under a UNIQUE key (lease, rent period, step); the database refuses a second [sqlite]
PASS exactly once: a text another run already claimed is not sent [sqlite]
PASS exactly once: each step goes once per rent period, and again for next month's rent [sqlite]
PASS texts: rent first seen inside a later step gets that step's text only, never a run of them [sqlite]
PASS texts: paid rent gets no text [sqlite]
PASS texts: a paused lease and the daily cap are held, each with its reason [sqlite]
PASS send window: outside it nothing goes or is claimed, and the page says why [sqlite]
PASS send window: when it opens, the waiting text goes [sqlite]
PASS send window: within one day only; a window from 22:00 to 06:00 is never open [sqlite]
PASS send window: hours saved the wrong way round fall back to 09:00 to 20:00; nothing goes at 23:30 or 02:00 [sqlite]
PASS send window: the default 09:00 to 20:00 is a valid window, not a fallback [sqlite]
PASS late fee: a fee posted by Rentara's own late-fee rule gets one factual text with what is owed now [sqlite]
PASS late fee: the text goes once per rent period [sqlite]
PASS late fee: a fee posted before the last three days is never texted [sqlite]
PASS late fee: several old months posted at once get one text, the rest covered by it [sqlite]
PASS late fee: the per-day fee is texted as an amount a day [sqlite]
PASS late fee: the text has its own switch [sqlite]
PASS texts: at most one a day per tenancy; the due-day text first, the late-fee text the next day [sqlite]
PASS pay link: with card payment on, the text carries the tenant's pay-by-card link [sqlite]
PASS pay link: a lease with no tenant link gets no account line, only the stop link [sqlite]
PASS field mapping: JSON with to, from, text and a Bearer key by default [sqlite]
PASS field mapping: Twilio's To, From, Body, form-encoded, with Basic auth [sqlite]
PASS field mapping: a key in the address sends no header; a blank field name falls back [sqlite]
PASS phone numbers: cleaned to digits with a leading +, nonsense refused, masked to the last four [sqlite]
PASS settings: days before the due date and send times are read strictly [sqlite]
PASS failure: a refusal is logged with what the gateway answered, and audited [sqlite]
PASS failure: a refusal stops texts for the rest of the day, and the page says so [sqlite]
PASS failure: a failed text is not tried again the same day, even after a test text starts texts again [sqlite]
PASS failure: on a later day, while it is still due, the failed text is tried again by taking back its own row (UPDATE … WHERE failed and earlier) [sqlite]
PASS exactly once: once a text went, it is never sent again [sqlite]
PASS consent: the agreement and the charge are read again before each text, so a stop or a payment during a run stops it [sqlite]
PASS audit: a sent text is audited with the last four digits only [sqlite]
PASS failure: an unreachable gateway is never fatal [sqlite]
PASS test text: one that goes through is logged and starts texts again the same day [sqlite]
PASS test text: works before texts are switched on; tenants still get none [sqlite]
PASS demo: a text is recorded and no connection is opened, a test text included [sqlite]
PASS texts ride the reminders run (dashboard and cron), with email reminders off [sqlite]
PASS erasure: no more texts, no stop link, and the number leaves the text log [sqlite]
PASS stop: after a stop no further text goes, and the stop is recorded [sqlite]
PASS door: the tenant link offers no texts while texts are off
PASS door: a viewer cannot change the text settings or send a test (403)
PASS door: an http:// gateway address is refused and nothing is saved
PASS door: a send window with the same time twice, or running past midnight (22:00 to 06:00), is refused and nothing is saved
PASS door: an administrator switches texts on with the gateway, the steps and the window
PASS door: the Settings card shows texts on, keeps the key write-only, and offers a test text
PASS door: saving again with the key left blank keeps it
PASS door: with texts on, the tenant's own link offers rent texts at the masked number, off until they ask
PASS door: the tenant switches texts on from their link; it is recorded as theirs, with the time and a stop link
PASS door: opening the stop link shows a Stop button and changes nothing (a link preview cannot stop texts)
PASS door: an unknown stop link is a 404
PASS door: pressing Stop ends the texts and records when
PASS door: the tenant form shows the stop and asks for a new agreement before texts go again
PASS door: the office ticks "they agreed to texts" on the tenant form; it is recorded with who ticked it
PASS door: unticking it on the form stops the texts
PASS door: ticking it with no phone number switches nothing on, and says why
PASS door: the Reminders page lists today's texts beside the email reminders
PASS backup: the SMS gateway key is left out of backups and audit details
PASS front controller: src/Sms.php and controllers/texts.php are loaded, and the five routes are there
PASS subject access: the tenant's data download lists the rent reminder emails and the rent texts sent to them
PASS erasure: anonymizing a tenant blanks the address on the reminder emails sent to them (dates and subjects stay), stops their texts and blanks the number in the text log; the landlord's own digest row keeps its address
== 4.0: the Update button is wired ==
PASS updates: the release public key is pinned
PASS updates: Database::migrate() creates the updater table (in code, not in a comment)
PASS updates: the updater table holds exactly one state row
PASS updates: index.php clears stale compiled code before anything else loads
PASS updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
PASS updates: route get /updates is updates_page()
PASS updates: route post /updates/key is updates_key_save()
PASS updates: route post /updates/check is updates_check()
PASS updates: route post /updates/apply is updates_apply()
PASS updates: updates_page() opens with the restore page's admin gate
PASS updates: updates_key_save() opens with the restore page's admin gate
PASS updates: updates_check() opens with the restore page's admin gate
PASS updates: updates_apply() opens with the restore page's admin gate
PASS updates: every POST checks the CSRF token
PASS updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
PASS updates: the menu links to the page
PASS updates: bin/update.php refuses the web and finishes in a fresh process
PASS updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
PASS updates: with a key and a newer signed release the page offers "Back up and update"
== 4.0 A2: sample data, backups, health ==
PASS A2: the owner's-install checks ran to the end
PASS A2: an owner's own install (no Cloud config, no demo marker)
PASS A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
PASS A2: switched off: an install without "Start with sample data" gets none
PASS A2: first request: the marker is used once and the sample business is there
PASS A2: every one of the owner's settings is theirs, not the sample's
PASS A2: the sample logins cannot sign in: no password matches, and switched off where the product can
PASS A2: the owner's own login is untouched
PASS A2: the load is in the audit log
PASS A2: a second marker on a Rentara with records seeds nothing and changes nothing
PASS A2: real use: one of the owner's own rows among the sample
PASS A2: remove: the owner's own row stays, and a sample row it points at is kept
PASS A2: remove: the report says what went, and what was kept because the owner's records use it
PASS A2: remove: the rest of the sample is gone
PASS A2: remove: every kept sample row keeps all its own parts
PASS A2: remove: no row is left pointing at a row that was removed
PASS A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
PASS A2: remove: the sample's files are gone from disk (2 written; one a row that stays names stays with it)
PASS A2: remove: the owner's own uploaded file is untouched
PASS A2: "Load demo data" on a Rentara with records refuses and deletes nothing
PASS A2: backup: written, sent to the bucket (a signed PUT), and read back
PASS A2: backup: the bucket secret is not in the backup it protects
PASS A2: backup: under the standard keep count (14) every backup is kept
PASS A2: switched off: with automatic backups off, a backup is never due however old the last one
PASS A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
PASS A2: an offsite endpoint must be https for an owner (plain http only in tests)
PASS A2: all 25 owner's-install checks reported
PASS A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
PASS A2: index.php loads the three cores and the wiring
PASS A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
PASS A2: route post /sample-data/remove is sample_data_remove()
PASS A2: route post /restore/backups is backups_save()
PASS A2: route post /restore/backup-now is backup_now()
PASS A2: route get /health is health_page()
PASS A2: sample_data_remove() opens with the restore page's admin gate
PASS A2: backups_save() opens with the restore page's admin gate
PASS A2: backup_now() opens with the restore page's admin gate
PASS A2: health_page() opens with the restore page's admin gate
PASS A2: every POST checks the CSRF token
PASS A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
PASS A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
PASS A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
PASS A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
PASS A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Rentara, removable)
PASS A2: the menu links to Health
PASS A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
PASS A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
SKIP A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
PASS erase planting · sqlite: the subject is created and a marker is planted in all 8 personal columns
PASS erase planting · sqlite: the app's own erase runs through its route and reports no failure
PASS erase planting · sqlite: no planted marker survives anywhere (28 tables dumped), outside the 1 column(s) kept by ruling (possible legal records)
PASS erase planting · sqlite: the erase page (/tenants/1) says what an erase keeps: "Kept: the documents filed on their lease, with their titles …"
PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the documents filed on their lease, with their titles …"
ALL 663 TESTS PASSED (0 failed)
663 assertions
← Back to Rentara · Manual · Quickstart · API