Duplicate payments are not a fraud problem in most portfolios. They are a filing problem. A vendor emails an invoice, hears nothing for three weeks, and emails it again with a new number. Both get coded. Both get paid. Nobody notices, because the two payments are three weeks and forty invoices apart.
Most property accounting software will stop you entering the same vendor and the same invoice number twice. That catches the honest duplicate and nothing else. The expensive version is the same work billed under a different number: a re-issue, a statement paid alongside the individual invoices it summarizes, or a work order billed once by the vendor and once by the property manager who fronted it.
None of those trip an exact-match rule, because nothing about them is exactly the same except the amount and roughly the date. Which is the whole basis for catching them.
Recovering a duplicate payment ninety days later means asking a vendor you still need to send money back. Catching it before approval costs nothing and nobody has to be told.
function normVendor(name) {
return name
.toLowerCase()
.replace(/[.,'&-]/g, " ")
.replace(/\b(inc|llc|l l c|corp|co|company|ltd|the)\b/g, " ")
.replace(/\s+/g, " ")
.trim();
}
// "Southeast Roofing, LLC" and "SE Roofing LLC" still differ.
// Normalizing is step one; the amount match below is what carries the check.function score(a, b) {
let s = 0;
if (normVendor(a.vendor) === normVendor(b.vendor)) s += 40;
if (Math.abs(a.amount - b.amount) < 0.01) s += 40;
else if (Math.abs(a.amount - b.amount) / b.amount < 0.02) s += 20;
const gap = Math.abs(new Date(a.date) - new Date(b.date)) / 86400000;
if (gap <= 7) s += 15;
else if (gap <= 45) s += 10;
else if (gap <= 120) s += 5;
if (a.invoiceNo && b.invoiceNo) {
const x = String(a.invoiceNo).replace(/\D/g, "");
const y = String(b.invoiceNo).replace(/\D/g, "");
if (x === y) s += 20; // same number, different prefix
}
return s;
}const RECURRING = new Set(["greenscape lawn", "orkin", "waste management"]);
// for these, only flag when the gap is under 20 days
const tooSoon = RECURRING.has(normVendor(a.vendor)) && gapDays < 20;| Score | What it usually means | What happens |
|---|---|---|
| 95 and above | Same vendor, same amount, within a week. Almost always a genuine re-send. | Hold and notify. These are the ones that pay for the build. |
| 80 to 94 | Same vendor and amount, further apart, or amounts within 2%. Often a re-issue under a new number. | Hold for review with both invoices shown. |
| 60 to 79 | Two of the three signals. Frequently legitimate. | Log it, do not interrupt anybody. Review the log monthly and use it to tune. |
| Under 60 | Noise. | Ignore. |
Start at 80 and watch what it holds for a month. If more than one in four holds turns out to be legitimate, the problem is almost always a recurring vendor missing from the suppression list rather than the threshold being wrong.
Statements paid alongside invoices. A vendor sends individual invoices during the month and a statement at the end that totals them. Somebody pays the statement, somebody else pays the invoices. The statement amount matches nothing individually, so a pairwise check never sees it. Flag any invoice whose amount equals the sum of two or more open items from the same vendor in the same period, and treat a document containing the word statement as needing a human by default.
Split and partial billing. A $12,000 roof job billed as $6,000 deposit and $6,000 on completion will score 80 and it is entirely legitimate. Both halves are the same vendor and the same amount, usually within your window. Look for deposit, progress, draw or final in the description and lower the score when you find one, rather than letting the reviewer see the same false positive on every capital project.
Credit memos read as invoices. A negative amount that gets imported without its sign becomes a positive charge that matches the original perfectly. Check for a negative amount or a credit indicator before scoring, and route credits somewhere else entirely. This one is quiet and it moves the wrong direction, so it rarely surfaces on its own.
The kit is the finished version of everything above: the scenario blueprint, the vendor normalizer, the scoring function, the review queue, and the thresholds as a spreadsheet you can edit. Email [email protected] if you would rather have it built into what you already run.
One email when a new guide goes up. Nothing else.
No sequence, no upsell drip. Unsubscribe link on every send.