Compare commits
7 Commits
50fdbb1b1d
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e7907d8fa | |||
| 614a0edfa1 | |||
| 1219117adf | |||
| 5f4af3886c | |||
| 156adeab59 | |||
| c6425887f1 | |||
| 04ef642775 |
12
.env.example
12
.env.example
@@ -7,4 +7,14 @@ AI_API_URL=https://integrate.api.nvidia.com/v1
|
|||||||
AI_API_KEY=your_nvidia_api_key_here
|
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);
|
||||||
|
|
||||||
|
|||||||
278
articles/hoa-fraud-prevention-internal-controls.html
Normal file
278
articles/hoa-fraud-prevention-internal-controls.html
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HOA Financial Fraud Prevention: Internal Controls Every Board Needs | HOA LedgerIQ Insights</title>
|
||||||
|
<meta name="description" content="Embezzlement rarely starts with a criminal mastermind — it starts with one signature and nobody double-checking. Here's how HOA boards build real internal controls." />
|
||||||
|
<meta name="keywords" content="HOA fraud prevention, HOA embezzlement, HOA internal controls, HOA financial oversight, segregation of duties HOA, HOA audit trail, community association fraud" />
|
||||||
|
<link rel="canonical" href="https://www.hoaledgeriq.com/articles/hoa-fraud-prevention-internal-controls" />
|
||||||
|
<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" />
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="HOA Financial Fraud Prevention: The Internal Controls Every Board Needs" />
|
||||||
|
<meta property="og:description" content="Embezzlement rarely starts with a criminal mastermind — it starts with one signature and nobody double-checking. Here's how to close that gap." />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="https://www.hoaledgeriq.com/articles/hoa-fraud-prevention-internal-controls" />
|
||||||
|
<meta property="article:published_time" content="2026-09-15" />
|
||||||
|
<!-- 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">
|
||||||
|
<div class="article-breadcrumb">
|
||||||
|
<a href="index.html">← Back to Insights</a>
|
||||||
|
</div>
|
||||||
|
<div class="article-tag">Board Management</div>
|
||||||
|
<h1 class="article-title">HOA Financial Fraud Prevention<br /><span class="gradient-text">The Internal Controls Every Board Needs</span></h1>
|
||||||
|
<p class="article-subtitle">Embezzlement rarely starts with a criminal mastermind. It starts with one signature, one account, and nobody else looking. Here's how to close that gap.</p>
|
||||||
|
<div class="article-meta">
|
||||||
|
<span class="article-meta-author">HOA LedgerIQ Team</span>
|
||||||
|
<span class="article-meta-separator"></span>
|
||||||
|
<span>September 15, 2026</span>
|
||||||
|
<span class="article-meta-separator"></span>
|
||||||
|
<span>9 min read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ARTICLE BODY: INTRO + SECTIONS 1-2 -->
|
||||||
|
<section class="article-body-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="article-prose">
|
||||||
|
|
||||||
|
<p>Every HOA board eventually has this conversation, usually in a hushed tone after a meeting has officially ended: "Have you ever actually looked at the bank statements, or do we just trust that the numbers in the report match?" More often than anyone would like to admit, the honest answer is that nobody has checked. The treasurer says the numbers are right, the board believes the treasurer, and the assumption gets passed down year after year, board term after board term, until it simply becomes how things are done.</p>
|
||||||
|
|
||||||
|
<p>That assumption is exactly the gap that HOA fraud slips through. It's a pattern that shows up in local news stories with depressing regularity: a well-liked, long-serving treasurer, trusted implicitly by neighbors who considered them a friend, quietly moves association funds into a personal account over months or years. Nobody suspected anything because nobody was structurally required to look. The fraud isn't usually discovered by an alert board member catching a discrepancy — it's discovered when that treasurer finally steps down and someone new opens the books for the first time.</p>
|
||||||
|
|
||||||
|
<p>This isn't a story about bad people. Most HOA treasurers are honest, and the overwhelming majority of community associations never experience fraud. But HOAs share a structural vulnerability that makes them unusually easy targets when something does go wrong: a single volunteer, often unpaid and rarely trained in accounting, frequently holds sole control over both the checkbook and the books that describe what the checkbook did. There's no separate finance department. No compliance officer. No second set of eyes required by policy. Trust becomes the entire control system — and trust, on its own, isn't a control at all.</p>
|
||||||
|
|
||||||
|
<p>The good news is that the fix isn't complicated, expensive, or a reflection on anyone's integrity. It's a small number of structural habits that, once in place, make fraud dramatically harder to commit and dramatically easier to catch early if it happens anyway. Here's what those habits look like.</p>
|
||||||
|
|
||||||
|
<h2>Why HOAs Are an Easy Target</h2>
|
||||||
|
|
||||||
|
<p>Picture the typical financial workflow in a self-managed HOA. The treasurer receives assessment payments, deposits them, pays the vendors, reconciles the bank account, and prepares the report the board sees at the meeting. One person touches every step. There is no moment in that chain where a second, independent set of eyes is structurally required to see what happened before the money has already moved.</p>
|
||||||
|
|
||||||
|
<p>Compare that to how a business — even a small one — typically handles money. The person who approves an expense usually isn't the same person who cuts the check, and neither of them is usually the person who reconciles the bank statement at month's end. That separation exists specifically because it makes fraud require collusion between multiple people rather than the judgment lapse of one. HOAs, run by volunteers with limited time and no formal training, tend to collapse all of those roles into whoever raised their hand to be treasurer.</p>
|
||||||
|
|
||||||
|
<p>Add to that the informality of most treasurer handoffs. When a volunteer treasurer's term ends, the transition is often a folder of documents and a login password, not a reconciled audit trail reviewed by an outgoing and incoming officer together. Years of financial history can pass from person to person without anyone outside that role ever independently verifying that the numbers on the report matched the numbers in the bank.</p>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<p><strong>The uncomfortable truth:</strong> in most HOA fraud cases that eventually make the news, the controls that would have caught it early weren't complicated. They just didn't exist. A single reconciliation review by someone other than the treasurer would very likely have surfaced the problem within months instead of years.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Segregation of Duties: The One Control That Matters Most</h2>
|
||||||
|
|
||||||
|
<p>If a board can only adopt one control this year, this is the one. Segregation of duties means no single person handles a financial transaction from start to finish without at least one other set of eyes involved somewhere in the process. It doesn't require hiring anyone or spending money — it requires distributing existing responsibilities across more than one board member.</p>
|
||||||
|
|
||||||
|
<p>In practice, this looks like: the treasurer prepares payments, but a second board member (often the president or vice president) reviews and co-signs checks or approves electronic payments above a set threshold. The person who deposits incoming assessment payments isn't the only person who ever sees the bank statement — someone else on the board reviews it independently each month, not just the treasurer's summary of it. Vendor additions and changes to banking information require sign-off from more than one person, since redirecting a legitimate vendor payment to a fraudulent account is one of the more common fraud patterns in any small organization.</p>
|
||||||
|
|
||||||
|
<p>None of this implies distrust of the treasurer. It's the same logic behind why pilots use pre-flight checklists even after thousands of hours of experience, and why publicly traded companies require independent auditors even when their own accounting staff is excellent. Good controls protect honest people as much as they deter dishonest ones — an honest treasurer who is the sole financial gatekeeper is also the person who gets unfairly blamed for any discrepancy nobody else was positioned to catch earlier.</p>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<p><strong>A simple starting rule:</strong> no payment above a set dollar threshold — many communities use $500 to $1,000 — should go out without a second board member's review and approval, and no one person should be able to both initiate a payment and reconcile the account it came from.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SECTIONS 3-5 -->
|
||||||
|
<section class="article-body-section" style="padding-top: 0;">
|
||||||
|
<div class="container">
|
||||||
|
<div class="article-prose">
|
||||||
|
|
||||||
|
<h2>The Documentation Habit Most Boards Skip</h2>
|
||||||
|
|
||||||
|
<p>Segregation of duties prevents fraud. Documentation catches it early if prevention fails. Most HOAs already generate more useful documentation than they realize — the problem is that nobody is required to actually look at it on a regular schedule.</p>
|
||||||
|
|
||||||
|
<p>A monthly bank reconciliation, reviewed by someone other than the person who prepared it, is the single most powerful early-detection tool available to a volunteer board. This doesn't need to be a forensic audit. It's a straightforward comparison: does the ending balance on the bank statement match the ending balance in the association's books? Do all the transactions in one appear in the other? Is anything unfamiliar — a vendor nobody recognizes, a check number out of sequence, a transfer nobody remembers approving? This kind of review takes a board member 20 to 30 minutes a month and is the reason most fraud, when it does occur, gets caught within a matter of months rather than years in communities that actually do it.</p>
|
||||||
|
|
||||||
|
<p>Beyond monthly reconciliation, a complete audit trail matters more than most boards appreciate until they need one. Every transaction should have a timestamp, a record of who initiated it, and — for payments — a record of who approved it. This isn't about creating paperwork for its own sake. It's about making sure that if a question ever comes up, whether from a suspicious homeowner, an incoming board, or an actual investigation, the answer already exists in a form nobody has to reconstruct from memory.</p>
|
||||||
|
|
||||||
|
<blockquote>"We didn't think we needed an audit trail because we trusted our treasurer completely. We were right to trust her — but when a homeowner asked a pointed question at an annual meeting, having the timestamped record meant we could answer in the room instead of promising to 'look into it' and hoping the question didn't come up again."</blockquote>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<p><strong>What good documentation buys a board:</strong> not just fraud detection, but the ability to answer any homeowner's question about where the money went — instantly, with a record, rather than a reassurance that requires trust nobody outside the treasurer's role can independently verify.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What Oversight Actually Looks Like at the Board Table</h2>
|
||||||
|
|
||||||
|
<p>Controls only work if someone is actually exercising them, which means the board as a whole — not just the treasurer — has to build financial oversight into its regular rhythm. This doesn't require every board member to become an accountant. It requires a handful of habits that turn oversight from an abstract responsibility into something that actually happens every month.</p>
|
||||||
|
|
||||||
|
<p>Every board meeting should include a genuine review of the financials, not a rubber-stamp acceptance of a report nobody reads closely. At least one board member other than the treasurer should be reviewing the bank statement independently each month, as described above. Many well-run associations also bring in an independent CPA to perform a review or compilation annually — not a full audit, which can be costly for smaller communities, but a level of outside scrutiny appropriate to the association's size. And treasurer transitions should include a formal handoff meeting where outgoing and incoming officers, ideally alongside a third board member, walk through the current reconciliation together before anyone signs off that everything is in order.</p>
|
||||||
|
|
||||||
|
<p>It's also worth having an honest conversation, once a year, about what would happen if something did go wrong. Does the association carry fidelity bond or crime insurance covering employee or volunteer theft? Many HOA insurance policies include this coverage by default, but plenty of boards have never confirmed it, and some governing documents or state statutes actually require a minimum coverage amount that nobody has checked in years.</p>
|
||||||
|
|
||||||
|
<div class="highlight-box">
|
||||||
|
<p><strong>What technology changes:</strong> a platform that logs every transaction automatically, flags unusual activity, and gives every board member — not just the treasurer — real-time visibility into the books removes the dependency on any one person's memory or goodwill. HOA LedgerIQ's audit trail keeps a timestamped record of every entry and every approval, so oversight doesn't depend on someone remembering to ask the right question.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SCREENSHOT CAROUSEL -->
|
||||||
|
<section class="article-showcase">
|
||||||
|
<div class="container">
|
||||||
|
<div class="article-showcase-header">
|
||||||
|
<div class="section-label">See It in Action</div>
|
||||||
|
<h2>Financial Oversight That Doesn't Depend on Memory</h2>
|
||||||
|
<p>A complete, timestamped audit trail and real-time dashboards give every board member visibility — not just the person holding the checkbook.</p>
|
||||||
|
</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">Dashboard — Real-Time Visibility for Every Board Member</div>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-slide">
|
||||||
|
<img src="../img/screenshot-cashflow.png" alt="HOA LedgerIQ Cash Flow — Actuals and forward projections" />
|
||||||
|
<div class="slide-caption">Cash Flow — Actuals & Forward Projections</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">Capital Planning — Multi-Year Project Timeline & Budget</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>
|
||||||
|
|
||||||
|
<!-- ARTICLE BODY: PRACTICE + CONCLUSION -->
|
||||||
|
<section class="article-body-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="article-prose">
|
||||||
|
|
||||||
|
<h2>What This Looks Like in Practice</h2>
|
||||||
|
|
||||||
|
<p>Consider a 90-unit community that decides, after a scare at a neighboring HOA made the local news, to formalize its financial controls. The board doesn't hire anyone new or spend a dollar on the change. It simply agrees on a handful of new habits.</p>
|
||||||
|
|
||||||
|
<p>The treasurer still handles the day-to-day bookkeeping and initiates payments, but the vice president now reviews and co-approves anything over $750 before it goes out, and reviews the bank statement independently each month rather than only reading the treasurer's summary at the meeting. Vendor banking changes now require a two-person sign-off after the board realizes, during the conversation, that they'd never actually verified how a wire redirect request would be caught if one ever came in by email — a scam that has hit other HOAs and small nonprofits with alarming frequency.</p>
|
||||||
|
|
||||||
|
<p>Six months in, the independent bank review catches a bookkeeping error — not fraud, just a misapplied vendor payment — within three weeks of it happening, instead of at year-end reconciliation the way it would have gone unnoticed before. The board fixes it in an afternoon. At the annual meeting, when a homeowner asks pointedly where a specific special assessment payment went, the treasurer pulls up the timestamped record in seconds instead of promising to follow up later. Nobody at that meeting realizes how much smoother that exchange was than it would have been a year earlier — which is exactly the point. Good controls are supposed to be invisible when nothing has gone wrong, and instantly available the moment something needs to be checked.</p>
|
||||||
|
|
||||||
|
<h2>Trust, But Build the System Anyway</h2>
|
||||||
|
|
||||||
|
<p>No board wants to treat its treasurer like a suspect, and building internal controls doesn't require that mindset. The entire point of segregation of duties, monthly reconciliation review, and a real audit trail is that they protect everyone involved — the community's money, certainly, but also the volunteer who would otherwise carry the full, unverifiable weight of "just trust me" for as long as they hold the role.</p>
|
||||||
|
|
||||||
|
<p>The communities that get burned by HOA fraud are rarely the ones with suspicious treasurers. They're the ones that never got around to building a system that didn't depend entirely on trust in the first place. That's a fixable gap, and closing it costs a board nothing but the discipline to make oversight a habit rather than an assumption.</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ARTICLE CTA -->
|
||||||
|
<section class="article-cta">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Give Every Board Member Real Visibility.</h2>
|
||||||
|
<p>HOA LedgerIQ's automatic audit trail and real-time dashboards mean financial oversight never has to rely on one person's memory.</p>
|
||||||
|
<a href="https://app.hoaledgeriq.com/pricing" class="btn btn-primary btn-lg" target="_blank" rel="noopener">Start Your Free 14-Day Trial</a>
|
||||||
|
<p class="article-cta-note">No credit card required · 14-day free trial · No contracts</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ARTICLE BOTTOM NAV -->
|
||||||
|
<div class="insights-grid-section" style="padding: 2.5rem 0 3rem;">
|
||||||
|
<div class="container">
|
||||||
|
<div style="border-top: 1px solid rgba(255,255,255,0.07); padding-top: 2rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||||
|
<a href="hoa-reserve-fund-health-score.html" style="color: var(--blue); font-size: 0.875rem; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; gap: 0.4rem; transition: opacity 0.2s;">← Previous: How to Read Your HOA's Reserve Fund Health Score</a>
|
||||||
|
<a href="index.html" style="color: var(--gray-400); font-size: 0.875rem; text-decoration: none; display: inline-flex; align-items: center; gap: 0.4rem; transition: color 0.2s;">All Insights →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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-fraud-prevention-internal-controls.html" class="article-card" style="text-decoration:none;">
|
||||||
|
<span class="article-card-tag">Board Management</span>
|
||||||
|
<h2 class="article-card-title">HOA Financial Fraud Prevention: The Internal Controls Every Board Needs</h2>
|
||||||
|
<p class="article-card-excerpt">Embezzlement rarely starts with a criminal mastermind — it starts with one signature, one account, and nobody else looking. Here's how boards build the segregation of duties and audit trail that close the gap.</p>
|
||||||
|
<div class="article-card-meta">
|
||||||
|
<span>HOA LedgerIQ Team</span>
|
||||||
|
<span class="article-card-meta-dot"></span>
|
||||||
|
<span>September 15, 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-09-15</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-fraud-prevention-internal-controls</loc>
|
||||||
|
<lastmod>2026-09-15</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