Compare commits
7 Commits
50fdbb1b1d
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
| 078e7bc455 | |||
| 614a0edfa1 | |||
| 1219117adf | |||
| 5f4af3886c | |||
| 156adeab59 | |||
| c6425887f1 | |||
| 04ef642775 |
10
.env.example
10
.env.example
@@ -8,3 +8,13 @@ AI_API_KEY=your_nvidia_api_key_here
|
|||||||
AI_MODEL=qwen/qwen3.5-397b-a17b
|
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
# Set to 'true' to enable detailed AI prompt/response logging
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
AI_DEBUG=false
|
AI_DEBUG=false
|
||||||
|
|
||||||
|
# Form abuse protection
|
||||||
|
# Cloudflare Turnstile (free): https://dash.cloudflare.com/?to=/:account/turnstile
|
||||||
|
# Leave blank to run without a visible CAPTCHA — the honeypot, signed form token
|
||||||
|
# and per-IP rate limits stay active either way.
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
# Optional: stable secret for signing form tokens. If unset, a random one is
|
||||||
|
# generated per process (tokens simply stop validating across restarts).
|
||||||
|
FORM_TOKEN_SECRET=
|
||||||
141
app.js
141
app.js
@@ -34,10 +34,76 @@
|
|||||||
|
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
|
|
||||||
function open() { overlay.classList.add('open'); document.body.style.overflow = 'hidden'; }
|
// ── Abuse protection ───────────────────────────────────
|
||||||
|
// A signed, single-use token is fetched when the form is opened; the server
|
||||||
|
// uses it to prove the form was really loaded and that a human took at least a
|
||||||
|
// few seconds to fill it in. When Turnstile keys are configured server-side, a
|
||||||
|
// CAPTCHA widget is rendered too.
|
||||||
|
const captchaSlot = document.getElementById('calcCaptcha');
|
||||||
|
let formToken = null;
|
||||||
|
let captchaWidget = null;
|
||||||
|
let siteKeyPromise = null;
|
||||||
|
|
||||||
|
async function fetchFormToken() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/form-token', { cache: 'no-store' });
|
||||||
|
formToken = (await res.json()).token || null;
|
||||||
|
} catch (_) { formToken = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTurnstileScript() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (window.turnstile) return resolve();
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||||
|
s.async = true;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = reject;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initCaptcha() {
|
||||||
|
if (!captchaSlot || captchaWidget !== null) return;
|
||||||
|
siteKeyPromise = siteKeyPromise || fetch('/api/form-config', { cache: 'no-store' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(c => c.turnstileSiteKey)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
const siteKey = await siteKeyPromise;
|
||||||
|
if (!siteKey) return; // CAPTCHA not configured — other layers still apply
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadTurnstileScript();
|
||||||
|
captchaWidget = window.turnstile.render(captchaSlot, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
theme: 'light',
|
||||||
|
action: 'roi_calculator',
|
||||||
|
});
|
||||||
|
} catch (_) { /* widget unavailable — server decides whether to allow */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function captchaResponse() {
|
||||||
|
if (captchaWidget === null || !window.turnstile) return '';
|
||||||
|
return window.turnstile.getResponse(captchaWidget) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCaptcha() {
|
||||||
|
if (captchaWidget !== null && window.turnstile) window.turnstile.reset(captchaWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
overlay.classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
fetchFormToken(); // starts the minimum-fill-time clock
|
||||||
|
initCaptcha();
|
||||||
|
}
|
||||||
function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
|
function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
|
||||||
|
|
||||||
openBtn?.addEventListener('click', open);
|
openBtn?.addEventListener('click', open);
|
||||||
|
// Footer CTA also opens the modal (v2.js handles its analytics) — it needs the
|
||||||
|
// same form token and CAPTCHA set-up.
|
||||||
|
document.getElementById('openCalc2')?.addEventListener('click', open);
|
||||||
closeBtn?.addEventListener('click', close);
|
closeBtn?.addEventListener('click', close);
|
||||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
||||||
@@ -55,6 +121,34 @@
|
|||||||
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
||||||
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
|
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
|
||||||
|
|
||||||
|
// Mirrors security.js validateEmail — the server is the authority, this just
|
||||||
|
// gives immediate feedback instead of a round-trip.
|
||||||
|
const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,24}$/;
|
||||||
|
|
||||||
|
// Reserved documentation domains / TLDs — mirrors security.js. Real leads
|
||||||
|
// never come from these; spam bots use them.
|
||||||
|
const BLOCKED_EMAIL_DOMAINS = ['example.com', 'example.org', 'example.net', 'example.edu'];
|
||||||
|
const BLOCKED_EMAIL_TLDS = ['test', 'example', 'invalid', 'localhost'];
|
||||||
|
|
||||||
|
function isValidEmail(v) {
|
||||||
|
if (v.length > 254 || v.indexOf('@') > 64) return false;
|
||||||
|
if (v.includes('..')) return false;
|
||||||
|
if (!EMAIL_RX.test(v)) return false;
|
||||||
|
const domain = v.slice(v.indexOf('@') + 1).toLowerCase();
|
||||||
|
const tld = domain.slice(domain.lastIndexOf('.') + 1);
|
||||||
|
if (BLOCKED_EMAIL_TLDS.includes(tld)) return false;
|
||||||
|
if (BLOCKED_EMAIL_DOMAINS.some(d => domain === d || domain.endsWith('.' + d))) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_HOMESITES = 10;
|
||||||
|
|
||||||
|
function showCalcError(msg) {
|
||||||
|
if (!calcErr) return;
|
||||||
|
calcErr.textContent = msg;
|
||||||
|
calcErr.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function setCalcLoading(on) {
|
function setCalcLoading(on) {
|
||||||
if (!submitBtn) return;
|
if (!submitBtn) return;
|
||||||
submitBtn.disabled = on;
|
submitBtn.disabled = on;
|
||||||
@@ -73,12 +167,33 @@
|
|||||||
const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true;
|
const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true;
|
||||||
|
|
||||||
if (!homesites || !annualIncome) {
|
if (!homesites || !annualIncome) {
|
||||||
calcErr.classList.remove('hidden');
|
showCalcError('Please fill in homesites and annual dues income to continue.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (homesites < MIN_HOMESITES) {
|
||||||
|
showCalcError(`Please enter the number of homesites in your community (minimum ${MIN_HOMESITES}).`);
|
||||||
|
document.getElementById('calcHomesites')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email is optional, but anything entered must be a real address.
|
||||||
|
if (calcEmail && !isValidEmail(calcEmail)) {
|
||||||
|
showCalcError('Please enter a valid email address.');
|
||||||
|
document.getElementById('calcEmail')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
calcErr.classList.add('hidden');
|
calcErr.classList.add('hidden');
|
||||||
setCalcLoading(true);
|
setCalcLoading(true);
|
||||||
|
|
||||||
|
// ── Abuse checks: server verifies the token, honeypot and CAPTCHA ──
|
||||||
|
const guardBody = {
|
||||||
|
formToken,
|
||||||
|
captchaToken: captchaResponse(),
|
||||||
|
hp_company_url: document.getElementById('hpCompanyUrl')?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
// ── Conservative investment assumptions ──
|
// ── Conservative investment assumptions ──
|
||||||
// Operating cash: depending on payment frequency, portion investable in high-yield savings
|
// Operating cash: depending on payment frequency, portion investable in high-yield savings
|
||||||
const opMultiplier = { monthly: 0.10, quarterly: 0.20, annually: 0.35 }[paymentFreq] || 0.10;
|
const opMultiplier = { monthly: 0.10, quarterly: 0.20, annually: 0.35 }[paymentFreq] || 0.10;
|
||||||
@@ -131,17 +246,37 @@
|
|||||||
|
|
||||||
// ── AI recommendation — call server to generate & save to DB (not displayed) ──
|
// ── AI recommendation — call server to generate & save to DB (not displayed) ──
|
||||||
try {
|
try {
|
||||||
await fetch('/api/calculate', {
|
const res = await fetch('/api/calculate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
...guardBody,
|
||||||
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
||||||
email: calcEmail, optIn: calcOptIn,
|
email: calcEmail, optIn: calcOptIn,
|
||||||
totalPotential, opInterest, resInterest,
|
totalPotential, opInterest, resInterest,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A blocked submission stops here — the estimate is not shown and nothing
|
||||||
|
// is stored. AI/service errors (502/503) fall through to the local result.
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data.blocked || data.field) {
|
||||||
|
setCalcLoading(false);
|
||||||
|
showCalcError(data.error || 'We could not verify this submission. Please try again.');
|
||||||
|
if (data.field === 'email') document.getElementById('calcEmail')?.focus();
|
||||||
|
if (data.field === 'homesites') document.getElementById('calcHomesites')?.focus();
|
||||||
|
resetCaptcha();
|
||||||
|
await fetchFormToken(); // tokens are single-use; issue a fresh one
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (_) { /* best-effort — DB save failed silently */ }
|
} catch (_) { /* best-effort — DB save failed silently */ }
|
||||||
|
|
||||||
|
// Token is spent on a successful submission; get another for a recalculation.
|
||||||
|
fetchFormToken();
|
||||||
|
resetCaptcha();
|
||||||
|
|
||||||
// ── Animate the main number ──
|
// ── Animate the main number ──
|
||||||
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
|
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
|
||||||
|
|
||||||
|
|||||||
266
articles/hoa-delinquency-management-guide.html
Normal file
266
articles/hoa-delinquency-management-guide.html
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>The 90-Day Blind Spot: How HOA Boards Can Catch Delinquent Assessments Before They Snowball | HOA LedgerIQ Insights</title>
|
||||||
|
<meta name="description" content="Most HOA boards discover payment problems 60-90 days too late. Here's how to spot delinquency trends early, protect cash flow, and collect with less friction." />
|
||||||
|
<meta name="keywords" content="HOA delinquent assessments, HOA dues collection, HOA delinquency management, community association collections, HOA cash flow delinquency, past due HOA dues, HOA accounts receivable" />
|
||||||
|
<link rel="canonical" href="https://www.hoaledgeriq.com/articles/hoa-delinquency-management-guide" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="../styles.css" />
|
||||||
|
<meta property="og:title" content="The 90-Day Blind Spot: How HOA Boards Can Catch Delinquent Assessments Before They Snowball" />
|
||||||
|
<meta property="og:description" content="A late payment that goes unnoticed for a quarter is a very different problem than one caught in week two. Here's how boards close that gap." />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="https://www.hoaledgeriq.com/articles/hoa-delinquency-management-guide" />
|
||||||
|
<meta property="article:published_time" content="2026-08-17" />
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-RTWNVXPMRF"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-RTWNVXPMRF');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- NAV -->
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="../index.html" class="nav-logo">
|
||||||
|
<img src="../logo_house_transparent.svg" alt="HOA LedgerIQ" class="logo-img" />
|
||||||
|
</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="../index.html">Home</a></li>
|
||||||
|
<li><a href="../index.html#features">Features</a></li>
|
||||||
|
<li><a href="../index.html#pricing">Pricing</a></li>
|
||||||
|
<li><a href="index.html" class="nav-active">Insights</a></li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://app.hoaledgeriq.com/pricing" class="btn btn-primary nav-btn" target="_blank" rel="noopener">Start Free Trial</a>
|
||||||
|
<a href="https://app.hoaledgeriq.com" class="btn btn-outline nav-btn nav-login" target="_blank" rel="noopener">Login</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Article Header -->
|
||||||
|
<header class="article-header">
|
||||||
|
<div class="container">
|
||||||
|
<nav class="article-breadcrumb">
|
||||||
|
<a href="../index.html">Home</a>
|
||||||
|
<span class="breadcrumb-separator">/</span>
|
||||||
|
<a href="index.html">Insights</a>
|
||||||
|
<span class="breadcrumb-separator">/</span>
|
||||||
|
<span>The 90-Day Blind Spot</span>
|
||||||
|
</nav>
|
||||||
|
<span class="article-tag">Financial Planning</span>
|
||||||
|
<h1 class="article-title">The 90-Day Blind Spot: How HOA Boards Can Catch Delinquent Assessments Before They Snowball</h1>
|
||||||
|
<p class="article-subtitle">A late payment caught in week two is a phone call. The same payment discovered ninety days later, tangled up with two more from the same household, is a collections case. Here's why the gap between those two outcomes is almost always about timing, not the homeowner.</p>
|
||||||
|
<div class="article-meta">
|
||||||
|
<span>HOA LedgerIQ Team</span>
|
||||||
|
<span class="meta-separator">•</span>
|
||||||
|
<span>August 17, 2026</span>
|
||||||
|
<span class="meta-separator">•</span>
|
||||||
|
<span>9 min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Article Body -->
|
||||||
|
<section class="article-body-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="article-prose">
|
||||||
|
|
||||||
|
<p class="lead">The treasurer of a 90-unit condo association pulled up the operating account one Tuesday morning and felt fine about what she saw: balance healthy, bills paid, nothing flagged. It wasn't until the property manager mentioned, almost in passing, that Unit 214 hadn't paid dues since May that the picture changed. A quick look through the ledger turned up two more units in the same position, one of them four months behind. None of it had shown up in the number she checked every week, because that number was the bank balance — and the bank balance doesn't know who owes what, only what's already arrived.</p>
|
||||||
|
|
||||||
|
<p>This is the quiet failure mode behind almost every serious HOA delinquency problem: not that boards ignore past-due accounts, but that the tools most communities rely on don't surface them until they're already large. A spreadsheet updated once a month, a management company report that lands two weeks after the period it covers, a mental model built entirely around "is the account funded" rather than "who's behind and by how much" — all of it adds up to the same result. Problems that started as a single missed payment get discovered only after they've compounded into three.</p>
|
||||||
|
|
||||||
|
<p>Delinquency management isn't a collections problem. It's a visibility problem that becomes a collections problem if it goes unaddressed long enough. Boards that catch it early spend a few minutes on a friendly reminder. Boards that catch it late spend months on liens, legal fees, and homeowners who feel ambushed by a bill that quietly tripled while nobody was watching.</p>
|
||||||
|
|
||||||
|
<h2>Why the Balance Sheet Hides the Problem</h2>
|
||||||
|
|
||||||
|
<p>Most HOA boards track exactly one number closely: how much is in the bank. It's the number on the agenda, the number the treasurer reports, the number that determines whether anyone feels anxious at a given meeting. The trouble is that a healthy bank balance and a healthy collections position are two completely different things, and a community can have the first without the second for a surprisingly long time.</p>
|
||||||
|
|
||||||
|
<p>A 150-unit community collecting $180,000 a year in assessments can have five households, four months behind, and still show a comfortable operating balance — because the other 145 households are paying on time and carrying the shortfall without anyone noticing. The cash is there. The compliance isn't. And because the top-line number looks fine, nothing prompts anyone to go looking for the households that aren't paying until the shortfall grows large enough to actually dent the balance — often not until it's a five-figure problem spread across a dozen accounts instead of a four-figure one spread across three.</p>
|
||||||
|
|
||||||
|
<blockquote>
|
||||||
|
"Our bank balance never once told us we had a problem. By the time it did, we were already chasing eleven months of one homeowner's dues and the attorney's letter cost more than the first three months would have." — HOA Board Treasurer, Mesa, AZ
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<strong>The core issue:</strong> Bank balance measures whether the community as a whole is solvent this month. Delinquency measures whether individual households are current. A board can be blind to the second while feeling reassured by the first — often for quarters at a time.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>The Real Cost of Discovering It Late</h2>
|
||||||
|
|
||||||
|
<p>The financial cost of a delinquent account grows in a predictable, almost mechanical way, and understanding that curve is what makes early detection worth the effort. A payment that's two weeks late is usually just late — a bounced autopay, a forgotten check, a homeowner who meant to get to it. A friendly reminder resolves the overwhelming majority of these without any further action needed.</p>
|
||||||
|
|
||||||
|
<p>Once an account crosses 60 to 90 days, the dynamics change. Many governing documents require formal notice at that point, which means legal or management fees start accruing on top of the original balance. The homeowner, who might have paid promptly given an early nudge, is now facing a bill inflated by fees they view as punitive, which makes them more likely to dispute it, delay further, or dig in defensively rather than simply pay. What started as a $400 quarterly assessment can become an $1,100 collections matter — not because the homeowner suddenly became less willing to pay, but because nobody caught the original miss in time to keep it small.</p>
|
||||||
|
|
||||||
|
<p>Multiply that pattern across a handful of accounts in any given year and the aggregate cost is significant: legal fees the association fronts and may never fully recover, board time spent on collections instead of capital planning, and — often overlooked — the reserve and operating shortfalls created while those balances sit uncollected. A community that carries $30,000 in aged receivables for eight months isn't just missing that money; it's potentially delaying a project, drawing down a cushion, or quietly leaning on other homeowners' timely payments to cover the gap.</p>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<strong>What to ask:</strong> "What's our current total in receivables aged past 30 days, 60 days, and 90 days — and how has that total moved over the last six months?"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Delinquency Is a Trend, Not a List</h2>
|
||||||
|
|
||||||
|
<p>A snapshot of who's currently behind is useful, but it answers the wrong question. The more important question is whether the total owed is growing, shrinking, or holding steady — and whether the accounts on this month's list are the same names as last month's, or new ones. A static list treated as a to-do item gets worked through account by account; a trend, tracked consistently, tells a board something structural is worth investigating.</p>
|
||||||
|
|
||||||
|
<p>If the same three households appear on the delinquency list every month, that's a pattern worth a direct, individual conversation — sometimes it's a payment plan, sometimes a hardship, sometimes simply a homeowner who needs an easier way to pay. If the total number of delinquent accounts is climbing steadily across the community rather than concentrated in a few households, that's a different signal entirely — possibly an assessment increase that landed harder than expected, or a payment process that's become inconvenient enough that otherwise reliable homeowners are falling behind on logistics, not intent.</p>
|
||||||
|
|
||||||
|
<p>Boards that only look at delinquency once a quarter, when the management report happens to include it, miss the moment when a one-off miss turns into a pattern. By the time the quarterly report shows the trend clearly, three more payment cycles have passed — three more chances for a $400 miss to become an $1,100 one.</p>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<strong>The bottom line:</strong> One month's delinquency list tells you who to call. Six months of delinquency totals, tracked together, tell you whether your collections process is working or quietly falling behind.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Building the Early-Warning Habit</h2>
|
||||||
|
|
||||||
|
<p>The boards that manage delinquency well share a common trait: they've turned it into a habit measured in days, not a review measured in quarters. That doesn't require a full-time staff member or expensive software — it requires treating "who hasn't paid yet" as a number worth checking as often as the bank balance, and building a light, consistent process around what happens when someone shows up on that list.</p>
|
||||||
|
|
||||||
|
<p>In practice, that usually looks like a short, predictable escalation: a friendly automated reminder at 15 days past due, a personal note or call at 30 days, and a clear, calmly worded formal notice at 60 days if nothing has changed — well before the point where governing documents require legal involvement. Each step is small and low-cost on its own, but strung together they catch the overwhelming majority of late payments long before they become collections cases, and they do it in a way most homeowners experience as a helpful nudge rather than an accusation.</p>
|
||||||
|
|
||||||
|
<p>The other half of the habit is simply reviewing the aging trend regularly — weekly or biweekly rather than quarterly — so a shift in the pattern gets noticed within days instead of months. That single change, moving from a quarterly glance to a routine check, is often what separates communities that resolve delinquency with a phone call from communities that resolve it with an attorney.</p>
|
||||||
|
|
||||||
|
<h2>What This Looks Like in Practice</h2>
|
||||||
|
|
||||||
|
<p>Copperfield Commons is a 210-unit community outside Charlotte that used to review delinquency the same way most associations do: a line item on the quarterly management report, glanced at, filed away. In early 2025, the board discovered — three months after the fact — that a homeowner who'd always paid on time had missed four consecutive months following a job loss, and the balance had grown large enough that a lien filing was already in motion by the time anyone on the board had a conversation with them.</p>
|
||||||
|
|
||||||
|
<p>The board changed one thing: instead of waiting for the quarterly report, they started reviewing a simple aging summary every two weeks — current, 30 days, 60 days, 90-plus — and treating any account that appeared for a second consecutive check as a call, not just a line item. Within the first quarter of the new habit, they caught two accounts at the 20-day mark that would previously have gone unnoticed until the next report, resolved both with a short conversation and a modest payment plan, and avoided any legal fees on either.</p>
|
||||||
|
|
||||||
|
<p>A year later, Copperfield's aged receivables — the total sitting past 60 days — had dropped by more than 70%, not because homeowners had become more reliable, but because the board was catching the same rate of missed payments dramatically earlier, when a phone call was still enough to fix it. The delinquency total is now a number the treasurer reports with the same routine confidence as the bank balance, because it's checked just as often.</p>
|
||||||
|
|
||||||
|
<blockquote>
|
||||||
|
"We didn't get better at collections. We got faster at noticing. That turned out to be almost the entire fix." — Copperfield Commons HOA Treasurer
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<strong>The bottom line:</strong> Delinquency doesn't become a crisis because homeowners stop paying — it becomes a crisis because boards don't notice quickly enough to intervene while intervention is still simple. Checking the aging trend as often as the bank balance is the single highest-leverage habit a board can build.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Every HOA will have a late payment eventually — a bounced autopay, a forgotten check, a homeowner going through a hard stretch. That part is unavoidable. What's avoidable is the ninety-day gap between when it happens and when the board finds out, and closing that gap doesn't take a bigger budget or a harder line with homeowners. It takes checking the right number often enough to catch the problem while it's still small enough to be a conversation instead of a case.</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SCREENSHOT CAROUSEL -->
|
||||||
|
<section class="article-showcase">
|
||||||
|
<div class="container">
|
||||||
|
<div class="article-showcase-header">
|
||||||
|
<div class="section-label">See How Modern HOA Financial Management Works</div>
|
||||||
|
<h2>HOA LedgerIQ surfaces delinquency trends continuously — not just when the quarterly report lands</h2>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-carousel" id="screenshotCarousel">
|
||||||
|
<div class="carousel-frame">
|
||||||
|
<div class="carousel-slides">
|
||||||
|
<div class="carousel-slide active">
|
||||||
|
<img src="../img/screenshot-dashboard.png" alt="HOA LedgerIQ Dashboard — Fund health scores, operating and reserve balances" />
|
||||||
|
<div class="slide-caption">A live dashboard that tracks aging receivables alongside your bank balance, not separately from it</div>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-slide">
|
||||||
|
<img src="../img/screenshot-cashflow.png" alt="HOA LedgerIQ Cash Flow — Projected balances with forward forecasting chart" />
|
||||||
|
<div class="slide-caption">Forward cash flow forecasting that accounts for uncollected assessments, not just what's already in the bank</div>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-slide">
|
||||||
|
<img src="../img/screenshot-capital.png" alt="HOA LedgerIQ Capital Planning — Multi-year project timeline and budget view" />
|
||||||
|
<div class="slide-caption">Component-level capital planning that stays accurate because collections stay on track</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-controls">
|
||||||
|
<button class="carousel-btn carousel-prev" aria-label="Previous screenshot">←</button>
|
||||||
|
<div class="carousel-dots">
|
||||||
|
<span class="carousel-dot active" data-index="0"></span>
|
||||||
|
<span class="carousel-dot" data-index="1"></span>
|
||||||
|
<span class="carousel-dot" data-index="2"></span>
|
||||||
|
</div>
|
||||||
|
<button class="carousel-btn carousel-next" aria-label="Next screenshot">→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<section class="article-cta">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Ready to Catch Delinquency Before It Compounds?</h2>
|
||||||
|
<p>HOA LedgerIQ tracks aging receivables alongside your cash position in real time — so a missed payment shows up in days, not the next quarterly report.</p>
|
||||||
|
<a href="https://app.hoaledgeriq.com/pricing" class="btn btn-primary btn-large" target="_blank" rel="noopener">Start Your Free 14-Day Trial</a>
|
||||||
|
<p class="cta-note">No credit card required · 14-day free trial · No contracts</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Article Navigation -->
|
||||||
|
<nav class="article-nav">
|
||||||
|
<div class="container">
|
||||||
|
<div class="article-nav-grid">
|
||||||
|
<a href="hoa-reserve-fund-health-score.html" class="article-nav-prev">
|
||||||
|
<span class="nav-label">Previous Article</span>
|
||||||
|
<span class="nav-title">How to Read Your HOA's Reserve Fund Health Score</span>
|
||||||
|
</a>
|
||||||
|
<a href="hoa-cash-flow-management-mistakes.html" class="article-nav-next">
|
||||||
|
<span class="nav-label">More Reading</span>
|
||||||
|
<span class="nav-title">What HOA Boards Get Wrong About Cash Flow Management</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container footer-inner">
|
||||||
|
<div class="footer-logo">
|
||||||
|
<img src="../logo_house.svg" alt="HOA LedgerIQ" class="logo-img logo-img--footer" />
|
||||||
|
<p>AI-powered HOA finance management.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<div class="footer-col">
|
||||||
|
<div class="footer-col-title">Product</div>
|
||||||
|
<a href="../index.html#features">Features</a>
|
||||||
|
<a href="../index.html#pricing">Pricing</a>
|
||||||
|
<a href="https://app.hoaledgeriq.com/pricing" target="_blank" rel="noopener">Start Free Trial</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<div class="footer-col-title">Pages</div>
|
||||||
|
<a href="../investment-management.html">Investment Management</a>
|
||||||
|
<a href="../reserve-study-software.html">Reserve Studies</a>
|
||||||
|
<a href="index.html">Insights</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-col">
|
||||||
|
<div class="footer-col-title">Legal</div>
|
||||||
|
<a href="../privacy.html">Privacy Policy</a>
|
||||||
|
<a href="../terms.html">Terms of Service</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<span>© 2026 HOA LedgerIQ. All rights reserved.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="../app.js"></script>
|
||||||
|
|
||||||
|
<!-- Support Chat Widget -->
|
||||||
|
<script>
|
||||||
|
(function(d,t) {
|
||||||
|
var BASE_URL="https://chat.hoaledgeriq.com";
|
||||||
|
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||||
|
g.src=BASE_URL+"/packs/js/sdk.js";
|
||||||
|
g.async = true;
|
||||||
|
s.parentNode.insertBefore(g,s);
|
||||||
|
g.onload=function(){
|
||||||
|
window.chatwootSDK.run({
|
||||||
|
websiteToken: '1QMW1fycL5xHvd6XMfg4Dbb4',
|
||||||
|
baseUrl: BASE_URL
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})(document,"script");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -54,6 +54,21 @@
|
|||||||
|
|
||||||
<div class="article-grid">
|
<div class="article-grid">
|
||||||
|
|
||||||
|
<!-- Article 11 — Newest first -->
|
||||||
|
<a href="hoa-delinquency-management-guide.html" class="article-card" style="text-decoration:none;">
|
||||||
|
<span class="article-card-tag">Financial Planning</span>
|
||||||
|
<h2 class="article-card-title">The 90-Day Blind Spot: How HOA Boards Can Catch Delinquent Assessments Before They Snowball</h2>
|
||||||
|
<p class="article-card-excerpt">A late payment caught in week two is a phone call. The same payment discovered ninety days later is a collections case. Here's how boards close that gap and stop small misses from compounding into liens and legal fees.</p>
|
||||||
|
<div class="article-card-meta">
|
||||||
|
<span>HOA LedgerIQ Team</span>
|
||||||
|
<span class="article-card-meta-dot"></span>
|
||||||
|
<span>August 17, 2026</span>
|
||||||
|
<span class="article-card-meta-dot"></span>
|
||||||
|
<span>9 min read</span>
|
||||||
|
</div>
|
||||||
|
<span class="article-card-read-more">Read article →</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Article 10 — Newest first -->
|
<!-- Article 10 — Newest first -->
|
||||||
<a href="hoa-reserve-fund-health-score.html" class="article-card" style="text-decoration:none;">
|
<a href="hoa-reserve-fund-health-score.html" class="article-card" style="text-decoration:none;">
|
||||||
<span class="article-card-tag">Reserve Funds</span>
|
<span class="article-card-tag">Reserve Funds</span>
|
||||||
|
|||||||
11
index.html
11
index.html
@@ -469,7 +469,7 @@
|
|||||||
<div class="calc-grid">
|
<div class="calc-grid">
|
||||||
<div class="calc-field">
|
<div class="calc-field">
|
||||||
<label for="calcHomesites">Number of homesites</label>
|
<label for="calcHomesites">Number of homesites</label>
|
||||||
<input type="number" id="calcHomesites" placeholder="e.g. 150" min="1" />
|
<input type="number" id="calcHomesites" placeholder="e.g. 150" min="10" />
|
||||||
</div>
|
</div>
|
||||||
<div class="calc-field">
|
<div class="calc-field">
|
||||||
<label for="calcPropertyType">Property type</label>
|
<label for="calcPropertyType">Property type</label>
|
||||||
@@ -509,8 +509,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Honeypot: hidden from humans, irresistible to bots. Leave it empty. -->
|
||||||
|
<div class="calc-hp" aria-hidden="true">
|
||||||
|
<label for="hpCompanyUrl">Company website</label>
|
||||||
|
<input type="text" id="hpCompanyUrl" name="hp_company_url" tabindex="-1" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
|
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
|
||||||
|
|
||||||
|
<!-- CAPTCHA widget — rendered only when Turnstile keys are configured -->
|
||||||
|
<div class="calc-captcha" id="calcCaptcha"></div>
|
||||||
|
|
||||||
<div class="calc-email-row">
|
<div class="calc-email-row">
|
||||||
<div class="calc-field calc-field--full">
|
<div class="calc-field calc-field--full">
|
||||||
<label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label>
|
<label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label>
|
||||||
|
|||||||
324
security.js
Normal file
324
security.js
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* HOA LedgerIQ — Form abuse protection
|
||||||
|
*
|
||||||
|
* Layered defence for public form endpoints (ROI calculator, lead capture):
|
||||||
|
*
|
||||||
|
* 1. Cloudflare Turnstile — real CAPTCHA, active when TURNSTILE_* keys are set.
|
||||||
|
* 2. Honeypot field — a hidden input humans never fill in.
|
||||||
|
* 3. Signed form token — proves the form was actually loaded, and enforces a
|
||||||
|
* minimum fill time (bots submit instantly).
|
||||||
|
* 4. Per-IP rate limiting — caps bursts and daily volume from one source.
|
||||||
|
*
|
||||||
|
* Layers 2–4 need no configuration and work on their own; adding Turnstile keys
|
||||||
|
* upgrades the protection to a full CAPTCHA challenge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// ── Config ───────────────────────────────────────────────
|
||||||
|
const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '';
|
||||||
|
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY || '';
|
||||||
|
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
|
||||||
|
// If no explicit secret is configured, generate one per process. Tokens then stop
|
||||||
|
// validating across restarts — harmless, the client just fetches a fresh one.
|
||||||
|
const FORM_SECRET = process.env.FORM_TOKEN_SECRET || crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const MIN_FILL_MS = 4 * 1000; // faster than this is not a human
|
||||||
|
const MAX_TOKEN_AGE_MS = 2 * 60 * 60 * 1000; // tokens expire after 2 hours
|
||||||
|
|
||||||
|
// Sliding-window caps per IP: short burst window + daily ceiling.
|
||||||
|
const RATE_WINDOWS = [
|
||||||
|
{ windowMs: 10 * 60 * 1000, max: 5, label: '10 minutes' },
|
||||||
|
{ windowMs: 24 * 60 * 60 * 1000, max: 25, label: '24 hours' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const turnstileEnabled = Boolean(TURNSTILE_SITE_KEY && TURNSTILE_SECRET);
|
||||||
|
|
||||||
|
// ── Signed, single-use form tokens ───────────────────────
|
||||||
|
const usedTokens = new Map(); // token -> expiry ms
|
||||||
|
const hits = new Map(); // ip -> [timestamps]
|
||||||
|
|
||||||
|
function sign(payload) {
|
||||||
|
return crypto.createHmac('sha256', FORM_SECRET).update(payload).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueFormToken() {
|
||||||
|
const payload = `${Date.now()}.${crypto.randomBytes(9).toString('base64url')}`;
|
||||||
|
return `${payload}.${sign(payload)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyFormToken(token) {
|
||||||
|
if (typeof token !== 'string' || token.length > 200) {
|
||||||
|
return { ok: false, reason: 'missing_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return { ok: false, reason: 'bad_token' };
|
||||||
|
|
||||||
|
const [tsRaw, nonce, sig] = parts;
|
||||||
|
const expected = sign(`${tsRaw}.${nonce}`);
|
||||||
|
if (sig.length !== expected.length ||
|
||||||
|
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||||
|
return { ok: false, reason: 'bad_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuedAt = Number(tsRaw);
|
||||||
|
if (!Number.isFinite(issuedAt)) return { ok: false, reason: 'bad_token' };
|
||||||
|
|
||||||
|
const age = Date.now() - issuedAt;
|
||||||
|
if (age > MAX_TOKEN_AGE_MS || age < -60_000) return { ok: false, reason: 'expired_token' };
|
||||||
|
if (age < MIN_FILL_MS) return { ok: false, reason: 'too_fast' };
|
||||||
|
if (usedTokens.has(token)) return { ok: false, reason: 'replayed_token' };
|
||||||
|
|
||||||
|
usedTokens.set(token, Date.now() + MAX_TOKEN_AGE_MS);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rate limiting ────────────────────────────────────────
|
||||||
|
function clientIp(req) {
|
||||||
|
// Requires `app.set('trust proxy', ...)` when running behind nginx.
|
||||||
|
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check the caps without consuming a slot. */
|
||||||
|
function checkRateLimit(ip) {
|
||||||
|
const now = Date.now();
|
||||||
|
const list = hits.get(ip) || [];
|
||||||
|
|
||||||
|
for (const { windowMs, max, label } of RATE_WINDOWS) {
|
||||||
|
const recent = list.filter(t => now - t < windowMs).length;
|
||||||
|
if (recent >= max) return { ok: false, reason: 'rate_limited', label };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a successful submission against the caller's IP. */
|
||||||
|
function recordSubmission(ip) {
|
||||||
|
const now = Date.now();
|
||||||
|
const widest = Math.max(...RATE_WINDOWS.map(w => w.windowMs));
|
||||||
|
const list = (hits.get(ip) || []).filter(t => now - t < widest);
|
||||||
|
list.push(now);
|
||||||
|
hits.set(ip, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Turnstile ────────────────────────────────────────────
|
||||||
|
async function verifyTurnstile(token, ip) {
|
||||||
|
if (!turnstileEnabled) return { ok: true, skipped: true };
|
||||||
|
if (typeof token !== 'string' || !token) return { ok: false, reason: 'captcha_missing' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = new URLSearchParams({ secret: TURNSTILE_SECRET, response: token });
|
||||||
|
if (ip && ip !== 'unknown') body.set('remoteip', ip);
|
||||||
|
|
||||||
|
const resp = await fetch(TURNSTILE_VERIFY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
return { ok: false, reason: 'captcha_failed', codes: data['error-codes'] };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Turnstile verify error:', err.message);
|
||||||
|
// Fail closed: an unverifiable challenge is not a passed challenge.
|
||||||
|
return { ok: false, reason: 'captcha_unavailable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Combined guard ───────────────────────────────────────
|
||||||
|
const MESSAGES = {
|
||||||
|
honeypot: 'Submission rejected.',
|
||||||
|
missing_token: 'Your session expired. Please reload the page and try again.',
|
||||||
|
bad_token: 'Your session expired. Please reload the page and try again.',
|
||||||
|
expired_token: 'Your session expired. Please reload the page and try again.',
|
||||||
|
replayed_token: 'This form was already submitted. Please reload the page to run another estimate.',
|
||||||
|
too_fast: 'That was a little too quick — please take a moment and try again.',
|
||||||
|
rate_limited: 'Too many submissions from this network. Please try again later.',
|
||||||
|
captcha_missing: 'Please complete the verification challenge.',
|
||||||
|
captcha_failed: 'Verification failed. Please try the challenge again.',
|
||||||
|
captcha_unavailable: 'Verification is temporarily unavailable. Please try again in a moment.',
|
||||||
|
email_required: 'Please enter your email address.',
|
||||||
|
email_invalid: 'Please enter a valid email address.',
|
||||||
|
email_too_long: 'That email address is too long.',
|
||||||
|
email_blocked: 'Please use a valid work or personal email address.',
|
||||||
|
homesites_too_low: 'Please enter the number of homesites in your community (minimum 10).',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS = { rate_limited: 429, honeypot: 400 };
|
||||||
|
|
||||||
|
/** User-facing message for a validation/abuse reason code. */
|
||||||
|
function messageFor(reason) {
|
||||||
|
return MESSAGES[reason] || 'Submission rejected.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email validation ─────────────────────────────────────
|
||||||
|
// Deliberately stricter than RFC 5322. The local part is limited to the
|
||||||
|
// characters real-world addresses actually use, which excludes every shell
|
||||||
|
// metacharacter (; | & ` $ ( ) < > \ " ' space) and every CSV-injection lead-in
|
||||||
|
// (= + @ at position 0). RFC-legal oddities like `foo!bar$baz@x.com` are
|
||||||
|
// rejected — an acceptable trade for a marketing form.
|
||||||
|
const EMAIL_RX = /^[A-Za-z0-9](?:[A-Za-z0-9._%+-]{0,62}[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.[A-Za-z]{2,24}$/;
|
||||||
|
|
||||||
|
// C0/C1 control characters and DEL — includes the CR/LF used for header injection.
|
||||||
|
const CONTROL_CHARS_RX = /[\x00-\x1F\x7F-\x9F]/;
|
||||||
|
|
||||||
|
// Domains that are never a real lead. These are the IANA reserved documentation
|
||||||
|
// domains (RFC 2606) plus reserved TLDs, which is what spam bots reach for.
|
||||||
|
// A submitted domain is blocked when it equals one of these or is a subdomain
|
||||||
|
// of one (e.g. `mail.example.com`).
|
||||||
|
const BLOCKED_EMAIL_DOMAINS = new Set([
|
||||||
|
'example.com', 'example.org', 'example.net', 'example.edu',
|
||||||
|
]);
|
||||||
|
const BLOCKED_EMAIL_TLDS = new Set(['test', 'example', 'invalid', 'localhost']);
|
||||||
|
|
||||||
|
function isBlockedDomain(domain) {
|
||||||
|
const d = domain.toLowerCase();
|
||||||
|
const tld = d.slice(d.lastIndexOf('.') + 1);
|
||||||
|
if (BLOCKED_EMAIL_TLDS.has(tld)) return true;
|
||||||
|
|
||||||
|
for (const blocked of BLOCKED_EMAIL_DOMAINS) {
|
||||||
|
if (d === blocked || d.endsWith('.' + blocked)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an email address.
|
||||||
|
* Returns { ok: true, email } with the normalised (trimmed, lower-cased) value,
|
||||||
|
* or { ok: false, reason }.
|
||||||
|
*
|
||||||
|
* Pass { required: false } to accept an empty value (the calculator's email
|
||||||
|
* field is optional) — an empty result comes back as { ok: true, email: null }.
|
||||||
|
*/
|
||||||
|
function validateEmail(raw, { required = true } = {}) {
|
||||||
|
if (raw === undefined || raw === null || raw === '') {
|
||||||
|
return required ? { ok: false, reason: 'email_required' } : { ok: true, email: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything that isn't a plain string is a structured-injection attempt
|
||||||
|
// (arrays and objects can survive into places a string wouldn't).
|
||||||
|
if (typeof raw !== 'string') return { ok: false, reason: 'email_invalid' };
|
||||||
|
|
||||||
|
const email = raw.trim();
|
||||||
|
if (email === '') {
|
||||||
|
return required ? { ok: false, reason: 'email_required' } : { ok: true, email: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length caps first — bounds every check that follows.
|
||||||
|
if (email.length > 254) return { ok: false, reason: 'email_too_long' };
|
||||||
|
|
||||||
|
// Control characters, including the newlines used for header injection.
|
||||||
|
if (CONTROL_CHARS_RX.test(email)) return { ok: false, reason: 'email_invalid' };
|
||||||
|
|
||||||
|
const at = email.indexOf('@');
|
||||||
|
if (at < 1 || at !== email.lastIndexOf('@')) return { ok: false, reason: 'email_invalid' };
|
||||||
|
|
||||||
|
const local = email.slice(0, at);
|
||||||
|
const domain = email.slice(at + 1);
|
||||||
|
|
||||||
|
if (local.length > 64 || domain.length > 253) return { ok: false, reason: 'email_too_long' };
|
||||||
|
if (email.includes('..')) return { ok: false, reason: 'email_invalid' };
|
||||||
|
if (!EMAIL_RX.test(email)) return { ok: false, reason: 'email_invalid' };
|
||||||
|
if (isBlockedDomain(domain)) return { ok: false, reason: 'email_blocked' };
|
||||||
|
|
||||||
|
return { ok: true, email: email.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Homesites validation ─────────────────────────────────
|
||||||
|
// Real associations have at least this many units; smaller values are the
|
||||||
|
// placeholder junk (0, 1, 2…) the spam submissions use.
|
||||||
|
const MIN_HOMESITES = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a homesites count.
|
||||||
|
* Returns { ok: true, homesites } (a finite number) or { ok: false, reason }.
|
||||||
|
*/
|
||||||
|
function validateHomesites(raw) {
|
||||||
|
const n = typeof raw === 'number' ? raw : parseFloat(raw);
|
||||||
|
if (!Number.isFinite(n) || n < MIN_HOMESITES) {
|
||||||
|
return { ok: false, reason: 'homesites_too_low' };
|
||||||
|
}
|
||||||
|
return { ok: true, homesites: n };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run every protection layer for a public form POST.
|
||||||
|
* Returns { ok: true, ip } or { ok: false, status, error, reason }.
|
||||||
|
*/
|
||||||
|
async function guardSubmission(req, { honeypotField = 'hp_company_url' } = {}) {
|
||||||
|
const ip = clientIp(req);
|
||||||
|
const body = req.body ?? {};
|
||||||
|
|
||||||
|
const fail = ({ reason, label }) => ({
|
||||||
|
ok: false,
|
||||||
|
reason,
|
||||||
|
status: STATUS[reason] ?? 403,
|
||||||
|
error: label ? `${MESSAGES[reason]} (limit: ${label})` : MESSAGES[reason],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Honeypot — any value at all means a bot filled every field it found.
|
||||||
|
if (typeof body[honeypotField] === 'string' && body[honeypotField].trim() !== '') {
|
||||||
|
console.warn(`[abuse] honeypot tripped from ${ip}`);
|
||||||
|
return fail({ reason: 'honeypot' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Rate limit (checked before the outbound Turnstile call).
|
||||||
|
const rate = checkRateLimit(ip);
|
||||||
|
if (!rate.ok) {
|
||||||
|
console.warn(`[abuse] rate limit hit by ${ip}`);
|
||||||
|
return fail(rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Signed single-use token + minimum fill time.
|
||||||
|
const tok = verifyFormToken(body.formToken);
|
||||||
|
if (!tok.ok) {
|
||||||
|
console.warn(`[abuse] form token rejected (${tok.reason}) from ${ip}`);
|
||||||
|
return fail(tok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. CAPTCHA.
|
||||||
|
const captcha = await verifyTurnstile(body.captchaToken, ip);
|
||||||
|
if (!captcha.ok) {
|
||||||
|
console.warn(`[abuse] captcha rejected (${captcha.reason}) from ${ip}`);
|
||||||
|
return fail(captcha);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSubmission(ip);
|
||||||
|
return { ok: true, ip };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicConfig() {
|
||||||
|
return { turnstileSiteKey: turnstileEnabled ? TURNSTILE_SITE_KEY : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Housekeeping ─────────────────────────────────────────
|
||||||
|
const sweep = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [token, exp] of usedTokens) if (exp < now) usedTokens.delete(token);
|
||||||
|
|
||||||
|
const widest = Math.max(...RATE_WINDOWS.map(w => w.windowMs));
|
||||||
|
for (const [ip, list] of hits) {
|
||||||
|
const kept = list.filter(t => now - t < widest);
|
||||||
|
if (kept.length) hits.set(ip, kept); else hits.delete(ip);
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
sweep.unref?.();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
turnstileEnabled,
|
||||||
|
issueFormToken,
|
||||||
|
guardSubmission,
|
||||||
|
validateEmail,
|
||||||
|
validateHomesites,
|
||||||
|
MIN_HOMESITES,
|
||||||
|
messageFor,
|
||||||
|
publicConfig,
|
||||||
|
clientIp,
|
||||||
|
};
|
||||||
81
server.js
81
server.js
@@ -16,6 +16,8 @@ const express = require('express');
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
|
|
||||||
|
const security = require('./security');
|
||||||
|
|
||||||
// ── Config ──────────────────────────────────────────────
|
// ── Config ──────────────────────────────────────────────
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
@@ -107,43 +109,61 @@ const getAllLeads = db.prepare(`
|
|||||||
|
|
||||||
// ── App ───────────────────────────────────────────────────
|
// ── App ───────────────────────────────────────────────────
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.set('trust proxy', 1); // behind nginx — needed for correct client IPs
|
||||||
|
app.use(express.json({ limit: '32kb' }));
|
||||||
app.use(express.static(__dirname)); // serve the marketing site
|
app.use(express.static(__dirname)); // serve the marketing site
|
||||||
|
|
||||||
|
// GET /api/form-config — public config the forms need (CAPTCHA site key)
|
||||||
|
app.get('/api/form-config', (_req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.json(security.publicConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/form-token — single-use, signed token proving the form was loaded
|
||||||
|
app.get('/api/form-token', (_req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.json({ token: security.issueFormToken() });
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/leads — capture a new preview sign-up
|
// POST /api/leads — capture a new preview sign-up
|
||||||
app.post('/api/leads', (req, res) => {
|
app.post('/api/leads', (req, res) => {
|
||||||
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
|
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
|
||||||
|
|
||||||
|
// Coerce defensively: a non-string (array, object, number) would otherwise
|
||||||
|
// blow up on .trim() and surface as a 500 instead of a 400.
|
||||||
|
const str = v => (typeof v === 'string' ? v.trim() : '');
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!firstName?.trim() || !lastName?.trim() || !email?.trim()) {
|
if (!str(firstName) || !str(lastName) || !str(email)) {
|
||||||
return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
|
return res.status(400).json({ error: 'firstName, lastName, and email are required.' });
|
||||||
}
|
}
|
||||||
if (!orgName?.trim()) {
|
if (!str(orgName)) {
|
||||||
return res.status(400).json({ error: 'Organization name is required.' });
|
return res.status(400).json({ error: 'Organization name is required.' });
|
||||||
}
|
}
|
||||||
if (!state?.trim()) {
|
if (!str(state)) {
|
||||||
return res.status(400).json({ error: 'State is required.' });
|
return res.status(400).json({ error: 'State is required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple email format check
|
// Strict email format check — see security.validateEmail
|
||||||
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailCheck = security.validateEmail(email);
|
||||||
if (!emailRx.test(email.trim())) {
|
if (!emailCheck.ok) {
|
||||||
return res.status(400).json({ error: 'Invalid email address.' });
|
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
||||||
}
|
}
|
||||||
|
const cleanEmail = emailCheck.email;
|
||||||
|
|
||||||
// Check for duplicate
|
// Check for duplicate
|
||||||
const existing = findByEmail.get(email.trim().toLowerCase());
|
const existing = findByEmail.get(cleanEmail);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
|
return res.status(409).json({ error: 'This email is already on the list.', id: existing.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = insertLead.run({
|
const info = insertLead.run({
|
||||||
firstName: firstName.trim(),
|
firstName: str(firstName),
|
||||||
lastName: lastName.trim(),
|
lastName: str(lastName),
|
||||||
email: email.trim().toLowerCase(),
|
email: cleanEmail,
|
||||||
orgName: orgName?.trim() ?? null,
|
orgName: str(orgName) || null,
|
||||||
state: state?.trim() ?? null,
|
state: str(state) || null,
|
||||||
role: role ?? null,
|
role: role ?? null,
|
||||||
unitCount: unitCount ?? null,
|
unitCount: unitCount ?? null,
|
||||||
betaInterest: betaInterest ? 1 : 0,
|
betaInterest: betaInterest ? 1 : 0,
|
||||||
@@ -175,7 +195,7 @@ app.post('/api/calculate', async (req, res) => {
|
|||||||
function saveCalcSubmission(aiRecommendation) {
|
function saveCalcSubmission(aiRecommendation) {
|
||||||
try {
|
try {
|
||||||
insertCalcSubmission.run({
|
insertCalcSubmission.run({
|
||||||
email: email?.trim() || null,
|
email: cleanEmail,
|
||||||
optIn: optIn ? 1 : 0,
|
optIn: optIn ? 1 : 0,
|
||||||
homesites: homesites || null,
|
homesites: homesites || null,
|
||||||
propertyType: propertyType || null,
|
propertyType: propertyType || null,
|
||||||
@@ -193,9 +213,11 @@ app.post('/api/calculate', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!aiClient) {
|
// ── Abuse protection: CAPTCHA, honeypot, form token, rate limit ──
|
||||||
saveCalcSubmission(null);
|
// Runs before anything is written to the DB or sent to the AI provider.
|
||||||
return res.status(503).json({ error: 'AI service not configured.' });
|
const guard = await security.guardSubmission(req);
|
||||||
|
if (!guard.ok) {
|
||||||
|
return res.status(guard.status).json({ error: guard.error, blocked: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -203,10 +225,31 @@ app.post('/api/calculate', async (req, res) => {
|
|||||||
email, optIn, totalPotential, opInterest, resInterest,
|
email, optIn, totalPotential, opInterest, resInterest,
|
||||||
} = req.body ?? {};
|
} = req.body ?? {};
|
||||||
|
|
||||||
if (!homesites || !annualIncome) {
|
// Email is optional here, but if one is supplied it must be a real address.
|
||||||
|
// Nothing is stored or sent onward until it passes.
|
||||||
|
const emailCheck = security.validateEmail(email, { required: false });
|
||||||
|
if (!emailCheck.ok) {
|
||||||
|
console.warn(`[abuse] rejected email from ${guard.ip}: ${JSON.stringify(String(email).slice(0, 120))}`);
|
||||||
|
return res.status(400).json({ error: security.messageFor(emailCheck.reason), field: 'email' });
|
||||||
|
}
|
||||||
|
const cleanEmail = emailCheck.email;
|
||||||
|
|
||||||
|
if (!annualIncome) {
|
||||||
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Homesites must be a real community size; tiny/placeholder values are spam.
|
||||||
|
const homesitesCheck = security.validateHomesites(homesites);
|
||||||
|
if (!homesitesCheck.ok) {
|
||||||
|
console.warn(`[abuse] rejected homesites from ${guard.ip}: ${JSON.stringify(homesites)}`);
|
||||||
|
return res.status(400).json({ error: security.messageFor(homesitesCheck.reason), field: 'homesites' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aiClient) {
|
||||||
|
saveCalcSubmission(null);
|
||||||
|
return res.status(503).json({ error: 'AI service not configured.' });
|
||||||
|
}
|
||||||
|
|
||||||
const fmt = n => '$' + Math.round(n).toLocaleString();
|
const fmt = n => '$' + Math.round(n).toLocaleString();
|
||||||
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
|
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
|
||||||
const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12;
|
const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12;
|
||||||
|
|||||||
@@ -37,11 +37,18 @@
|
|||||||
<!-- Insights / Blog -->
|
<!-- Insights / Blog -->
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.hoaledgeriq.com/articles/</loc>
|
<loc>https://www.hoaledgeriq.com/articles/</loc>
|
||||||
<lastmod>2026-07-15</lastmod>
|
<lastmod>2026-08-17</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.85</priority>
|
<priority>0.85</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://www.hoaledgeriq.com/articles/hoa-delinquency-management-guide</loc>
|
||||||
|
<lastmod>2026-08-17</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.80</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://www.hoaledgeriq.com/articles/hoa-reserve-fund-health-score</loc>
|
<loc>https://www.hoaledgeriq.com/articles/hoa-reserve-fund-health-score</loc>
|
||||||
<lastmod>2026-07-15</lastmod>
|
<lastmod>2026-07-15</lastmod>
|
||||||
|
|||||||
17
styles.css
17
styles.css
@@ -821,6 +821,23 @@ a.feature-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg)
|
|||||||
}
|
}
|
||||||
.input-prefix-wrap input { padding-left: 28px; }
|
.input-prefix-wrap input { padding-left: 28px; }
|
||||||
.calc-error { color: var(--red); font-size: 13px; margin: 8px 0; font-weight: 500; }
|
.calc-error { color: var(--red); font-size: 13px; margin: 8px 0; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Honeypot — visually and semantically hidden, but still fillable by bots.
|
||||||
|
Deliberately not `display:none`, which the better bots skip. */
|
||||||
|
.calc-hp {
|
||||||
|
position: absolute !important;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CAPTCHA widget slot — collapsed until a challenge is actually rendered */
|
||||||
|
.calc-captcha:empty { display: none; }
|
||||||
|
.calc-captcha { margin: 12px 0 0; display: flex; justify-content: center; }
|
||||||
.calc-submit-btn { width: 100%; justify-content: center; margin-top: 16px; }
|
.calc-submit-btn { width: 100%; justify-content: center; margin-top: 16px; }
|
||||||
.calc-fine {
|
.calc-fine {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
Reference in New Issue
Block a user