AP Duplicate Payment Recovery Copy link
Plugin: Blitz Report Excel Add-in Skills (
br-ap-duplicate-payments)Works with: Claude in Excel, Microsoft 365 Copilot in Excel
Find duplicate and overpaid Oracle EBS Accounts Payable invoices/payments in Excel: have the br-run-reports skill extract the AP invoice universe, fuzzy-match it for duplicate-entry patterns (same supplier + amount, re-keyed invoice numbers, transposed digits, duplicate vendor masters, cross-OU double entry), separate recoverable (both paid) from preventable (one still open), and build a ranked recovery worklist with $-at-risk.
When to use it
- Use when the user asks to find duplicate payments, duplicate invoices, overpayments, recover overpaid cash, or an AP recovery audit.
- Download the skill zip. br-ap-duplicate-payments.zip
- In Claude in Excel open Customize > Skills > Create skill and upload the zip.
- Connect the Blitz Report Excel add-in to your Oracle EBS instance, then ask Claude for the task in your own words.
- Download the skill folder from GitHub. br-ap-duplicate-payments
- Copy the folder into Documents/Copilot/Microsoft Excel/Skills in your OneDrive.
- Open Excel with the Blitz Report add-in connected and ask Copilot for the task.
- Add the Enginatics marketplace in Claude Code.
/plugin marketplace add enginatics/ai-skills - Install the Excel add-in skills plugin.
/plugin install blitz-report-excel-addin-ai-skills@enginatics - Run the setup skill; it installs or updates the skills in Claude in Excel or Microsoft 365 Copilot in Excel.
/blitz-report-excel-addin-ai-skills:excel-skills-setup
Requirements: Blitz Report and the Blitz Report Excel add-in, connected to your Oracle EBS instance, plus Claude in Excel or Microsoft 365 Copilot in Excel.
🚨 Blitz data = Excel worksheet UDFs, not MCP tools/connectors. Extract data only by writing
=br_...()into a cell (viaexecute_office_js) and reading the spill — nevertool_searchforbr_*, neverrefresh_mcp_connectors, and never conclude "no Oracle EBS / Blitz connector is set up" (the Blitz Report add-in is an Excel add-in, not an MCP connector, so it never appears in the connector list — its absence there is expected). Load the extraction mechanism first:read_skill("br-run-reports").Performance: Read
br-analytics-standardsat the start of every run (read_skill("br-analytics-standards")). All pipeline steps must follow those rules. Target runtime: Standard run under 6 minutes. There is no Step-0 depth prompt here: the only optional work is Step 5 (payment confirmation — an extra extract), which is already opt-in, so just don't volunteer it. The real cost driver is the lookback window, because pair generation is quadratic within a supplier group — doubling the window roughly quadruples the matching. Surface that lever after Step 1b, when you know the actual universe size (see Step 1b).
Produce an AP duplicate-payment recovery worklist in one Excel workbook. You own the orchestration, the match logic, the recoverable-vs-preventable split, and the written recommendations; the specialist skills do the extraction and the dashboard build. Run steps in order, on separate turns, keeping every intermediate sheet so the user can audit each candidate pair.
Requires: br-run-reports (extraction). Optional: br-anomaly-detection (duplicate-scan engine — if it doesn't fire, run the match rules inline per Step 3); br-data-dashboard (dashboard build — if it doesn't fire, build inline per Step 6).
Division of responsibility — READ FIRST
You do NOT run Blitz reports yourself. All extraction is performed by the br-run-reports skill, which owns add-in connection, authentication, asynchronous execution, completion polling, and deterministic cell population.
- Never call
br_*add-in functions directly. - Never rename the result sheet, read its values, sum a column, or start any analysis until extraction is confirmed complete — touching the workbook while the add-in's run is still in flight can stall it and the run will never finish.
- Your job is only: (1) ask br-run-reports to fetch the data, then (2) once it has fully landed, match, score, and present it.
This skill is read-and-recommend only. It does NOT execute any fix — it does not place a hold, raise a supplier credit, void a payment, or file a claim. It produces an evidenced candidate list and the recommended action per candidate; recovering the cash is a separate, human-approved step (closed-loop is on the roadmap).
Disposition discipline: a flagged pair is a candidate, not a confirmed duplicate. Oracle Payables blocks an identical (supplier, invoice number) re-entry, so true exact duplicates are rare — the leak is near-duplicates (re-keyed numbers, transposed digits, duplicate vendor masters, cross-OU entry). Always present the evidence (both invoice ids, the rule, the $) and let a human confirm before any recovery. Never assert "this was paid twice" — say "candidate, rule Rn".
When to use
"Find duplicate payments", "did we pay any invoice twice", "duplicate invoice detection", "AP overpayment / recovery audit", "are we about to pay a duplicate", "recover overpaid cash from suppliers". For a single AP report with no matching/analysis, use br-run-reports directly.
Inputs (collect up front)
- Operating Unit or Ledger (required) — ask if not supplied.
- Lookback window — an Invoice Date From/To range (default: the last 24 months). A wider window finds more but runs slower; let the user widen it.
- Optional: Supplier filter (scope to one vendor); materiality threshold (default $100 — pairs whose exposure is below this are suppressed from the headline worklist).
- Optional: "also confirm which were actually paid" → run the paid-confirmation report (Step 5).
Reports used (authoritative names — do NOT broad-search for them)
These exact report names are known. When you ask br-run-reports to run one, pass the documented
name directly — do NOT run a br_report_list_external discovery search first. Inspect parameters
only if you need an exact LOV spelling. Fallback: only if a run returns #BLITZ_REPORT_NOT_FOUND (the instance renamed it, or the module isn't installed) do a name search. If a run returns a bare ERROR: ERROR, the param names didn't match — have br-run-reports inspect params
(br_report_params_external) and adapt, then retry; do NOT abandon the run.
| Report (exact name) | Run with | Role |
|---|---|---|
AP Invoices and Lines | Operating Unit (or Ledger) + Invoice Date From/To (the lookback) + Display Level = Headers (the default → one row per invoice). Leave Payment Status blank so both open and paid invoices come back (we need both to tell recoverable from preventable). | The candidate universe — the match runs over this. Payables (always present) |
AP Invoice Payments (optional, Step 5) | Operating Unit (or Ledger) + Payment Date / Accounting Date range overlapping the lookback | Confirms a candidate was actually disbursed (→ recoverable $); also carries PAYMENTS_PER_INVOICE (a built-in same-invoice-paid-twice signal) and DOCUMENT_NUMBER (the check/payment number to cite). |
Run with
Display Level = Headers(the report's default) →AP Invoices and Linesreturns one row per invoice (header grain), so no manual line-collapse is needed. Verified output columns (r12, parsed):SUPPLIER,SUPPLIER_NUMBER,SUPPLIER_SITE,INVOICE_NUM,INVOICE_DATE,INVOICE_AMOUNT,AMOUNT_PAID,INVOICE_CURRENCY_CODE,INVOICE_PAYMENT_STATUS,INVOICE_STATUS,INVOICE_TYPE,INVOICE_CANCELLED_DATE/INVOICE_CANCELLED_AMOUNT,OPERATING_UNIT,INVOICE_ID. (If a customer instance defaults Display Level to a line level, collapse to one row perINVOICE_IDbefore matching.)
CONFIG — edit to match the customer; logic below reads from here
Normalisation (compute once, before matching)
Per invoice (header grain), derive — using the verified columns:
supplier_key=SUPPLIER_NUMBER(the unique vendor number) when present, elseUPPER(TRIM(SUPPLIER)).SUPPLIER_NUMBERis the reliable key; the name fallback lets R5 catch the same vendor split across two supplier records.norm_invnum=INVOICE_NUMuppercased, with spaces and punctuation (-,/,.,#) removed and leading zeros stripped. Strip a leading/trailing alpha token only when the numeric core left behind is still ≥ 4 digits (INV0012345→12345,0012345→12345,12345-A→12345). 🚨 Never strip the alpha when it would leave a tiny core —STA-01,STAP-01,STAADJJUL-01,STAADJAUG-01must stay distinct (their numeric core is just01), NOT all collapse to01; over-collapsing makes recurring monthly adjustments look like duplicates (a real false-positive seen in a run). Keep the genuine identifying core so a re-keyed number matches without over-collapsing distinct invoices.amount=INVOICE_AMOUNT(header) in a single currency. Only compare amounts within the sameINVOICE_CURRENCY_CODE— never match across currencies. Keep the sign so a credit memo (negative) is never matched as a duplicate of a positive invoice.
Match rules → confidence (each candidate PAIR carries the highest rule it matches)
Generate pairs within the same supplier_key (and, for R6, across OUs). A pair is two distinct
invoice ids. | Rule | Match condition (same currency throughout) | Confidence |
|---|---|---|---|
| R1 | same supplier_key + same amount + same invoice date + different invoice id | Very high | |
| R2 | same supplier_key + same amount + invoice dates within N days (default 7) + different invoice id | High | |
| R3 | same supplier_key + same norm_invnum + different invoice id (amount may differ — re-keyed/partial) | High | |
| R4 | same supplier_key + same amount + invoice numbers a typo/transposition apart (Levenshtein ≤ 2 on norm_invnum) | Medium | |
| R5 | different supplier record but same normalised supplier name/tax id + same amount + same norm_invnum (duplicate vendor master) | Medium | |
| R6 | same amount + same norm_invnum + same invoice date across different Operating Units (cross-OU double entry) | Medium |
Anything matching several rules takes the highest confidence; record every rule it hit.
Recoverable vs preventable
🚨 The "Paid" signal is AMOUNT_PAID, NOT the INVOICE_STATUS column. INVOICE_STATUS is the validation status (Validated / Never Validated / Cancelled) — it is never the literal value
"Paid", so testing INVOICE_STATUS === 'Paid' classifies every invoice as Open and forces all pairs
to Preventable (a real bug seen in a run: 0 recoverable on a universe that contained paid invoices). Many
instances' AP Invoices and Lines output carries no payment-status column at all — derive Paid from
the amounts: an invoice is Paid when AMOUNT_PAID ≥ INVOICE_AMOUNT (fully paid), Open otherwise.
Use INVOICE_PAYMENT_STATUS only if the report actually returned that column.
- Recoverable — both invoices in the pair are Paid (
AMOUNT_PAID ≥ INVOICE_AMOUNT, orINVOICE_PAYMENT_STATUS= Paid when that column is present). Real cash is out the door → recovery (supplier credit / refund claim).$ exposure= the lesser of the two paid amounts. - Preventable — at least one invoice is still Open/Unpaid (
AMOUNT_PAID < INVOICE_AMOUNT). No loss yet → place a hold before the second payment leaves.$ exposure= the open invoice's amount. - Already-resolved — one of the pair is cancelled (
INVOICE_CANCELLED_DATEset /INVOICE_CANCELLED_AMOUNT) or fully credited/voided (net ≈ 0) → exclude from the worklist (note the count). - 🚨 Headline $ = DISTINCT invoices, never Σ over pairs. When you total Recoverable $ / Preventable $
for the dashboard, dedupe by
INVOICE_IDand sum each flagged invoice once — NOT the per-pair$ exposureadded up. In a same-amount cluster one invoice appears in many pairs, so summing per-pair exposure multiplies it (a 50-invoice cluster → 1,225 pairs → ~24× overstatement); the live run's $120.9M was inflated exactly this way. The pair list still shows every relationship; the $ rolls up once per invoice (seedistinctInvoiceExposurebelow).
False-positive guards (apply BEFORE ranking — demote, don't delete)
- 🚨 Same-amount cluster (ERS / auto-invoicing) — the #1 false-positive source. When a
supplier_key+ currency +amountgroup has ≥3 members, it is a systematic pattern (Evaluated-Receipt-Settlement auto-invoicing, installments, or a recurring charge), NOT a pile of duplicates → tagSame-amount cluster, demote all its amount-rule pairs (R1/R2/R4) to an audit tab, keep them out of the headline. 🚨 Do NOT gate this on an even cadence / date-variance test — ERS invoices for repeated identical deliveries land on the same day (date gaps ≈ 0), so a cadence test silently lets them through. That exact hole produced a $120.9M / 1,321-pair false-positive headline in a live run (supplier "Advanced Network Devices", invoice numbersERS-13578-224817,ERS-13588-224994, …). Group size ≥ 3 is the trigger, period. A genuine duplicate is the 2-member same-amount pair, or an extra hit that breaks an otherwise-clean cluster — present those; demote the clusters. (For the amount rules this subsumes the recurring guard below.) - 🚨 ERS-vs-ERS amount pair (the 2-member ERS hole the ≥3 cluster guard misses). Evaluated-Receipt
-Settlement invoices carry auto-generated numbers like
ERS-23693-224957. Two distinct ERS numbers are two distinct receipts, so an amount-rule (R1/R2/R4) pair of two distinct ERS numbers is a false positive by construction — demote it to the audit tab. The cluster guard above only catches groups of 3+, so a 2-member ERS pair (ERS-…224957vsERS-…224958, consecutive auto-numbers, same amount → caught by R4) slips through and pollutes the Preventable headline (a live run surfaced 76 of these reaching the orchestrator cross-check). Demote R1/R2/R4 ERS-vs-ERS pairs; keep R3 (an exact same normalised number even on ERS is a real concern). UseersAmountPair(CONFIG JS). This is a targeted auto-numbering signature, not a blanket invoice-number-prefix heuristic — handle it here, upstream, not as an ad-hoc split at cross-check time. - Recurring / installment series: same
supplier_key+ sameamountwith ≥3 occurrences (rent, lease, subscription, payroll-style) → tagRecurring pattern, drop from the duplicate worklist (keep on an audit tab). An even cadence is a strong hint but is NOT required (and never add a variance/cadence gate — a same-day cluster would slip through it; see the same-amount cluster guard above). A genuine duplicate is an extra hit off-pattern. 🚨 Apply this guard to ALL rules INCLUDING R3 — do NOT exempt R3. Recurring monthly invoices commonly share a normalised number (each month's…-01), so R3 is exactly the rule that catches them; exempting R3 mislabels every monthly series as a duplicate (a real false-positive seen in a run with StaplesSTAADJJUL-01/STAADJAUG-01). - Partial payments / split invoices: same invoice paid in instalments is one liability — R3 with amounts that sum to a single sibling invoice is a split, not a duplicate.
- Credit memos / debit memos: negative-amount docs net against their positive twin — never a duplicate.
- Materiality: pairs with
$ exposure< the threshold drop off the headline worklist (kept on the full tab). - 🚨 Plausibility gate — present, don't assert. If the flagged candidate pairs exceed ~10% of the invoice universe, the matcher is surfacing a systematic pattern, not a duplicate epidemic — report "high candidate volume ({n} pairs over {universe} invoices) — likely ERS/recurring, review before treating as duplicates" and do NOT headline a confident "$X about to be paid twice". A real duplicate rate is well under 1%; the live run's 1,321 pairs were ~36% of the universe.
Canonical JS — COPY THESE VERBATIM (don't re-derive)
These are the JS ports of the offline-tested primitives in tools/skill_tests/skill_logic.py. The bugs that cost the most (disposition on the
wrong column, norm_invnum over-collapse, the ERS same-amount blowup, the per-pair
exposure double-count) all came from re-deriving this logic live — copy these instead.
// Normalised invoice number: strip a leading/trailing alpha token ONLY when the
// numeric core left is >= 4 digits, so STA-01 / STAADJJUL-01 stay DISTINCT.
function normInvNum(s) {
if (!s) return "";
let n = String(s).toUpperCase().replace(/[\s\-/.#]/g, "");
let m = n.match(/^([A-Z]+)(\d+)$/);
if (m && m[2].replace(/^0+/, "").length >= 4) n = m[2];
m = n.match(/^(\d+)([A-Z]+)$/);
if (m && m[1].replace(/^0+/, "").length >= 4) n = m[1];
return n.replace(/^0+/, "");
}
// Paid signal is AMOUNT_PAID, NOT the validation status column (which is never "Paid").
function disposition(inv) {
if (String(inv.invoiceStatus || "").toUpperCase() === "CANCELLED") return "Cancelled";
const amt = Number(inv.invoiceAmount) || 0, paid = Number(inv.amountPaid) || 0;
return (amt > 0 && paid >= amt) ? "Paid" : "Open"; // fully paid
}
// Same-amount cluster guard: a supplier+currency+amount group of >=3 is SYSTEMATIC
// (ERS auto-invoicing / installments / recurring), NOT duplicates — demote it.
// NO cadence/variance test: same-day ERS clusters have date gaps 0 and slip a cadence
// check (the exact hole behind the live $120.9M / 1,321-pair false positive).
function sameAmountClusterKeys(invoices) {
const g = {};
for (const inv of invoices) {
const k = inv.supplierKey + "|" + inv.currency + "|" + (Number(inv.invoiceAmount) || 0).toFixed(2);
(g[k] = g[k] || []).push(inv);
}
return new Set(Object.keys(g).filter(k => g[k].length >= 3));
}
// In the matcher, before emitting an amount-rule (R1/R2/R4) pair, skip it when its
// amount group is a cluster: if (clusters.has(supplierKey+"|"+currency+"|"+amt)) continue;
// (R3, the number rule, is NOT cluster-gated — it keeps the recurring guard instead.)
// Headline $ at risk = DISTINCT flagged invoices, never the per-pair exposure summed
// (an invoice in K pairs would be counted K times — the live $120.9M inflation).
function distinctInvoiceExposure(pairs) {
const seen = new Set(); let total = 0;
for (const p of pairs) for (const inv of [p.a, p.b])
if (!seen.has(inv.invoiceId)) { seen.add(inv.invoiceId); total += Math.abs(Number(inv.invoiceAmount) || 0); }
return total;
}
// Two DISTINCT ERS auto-numbers = two distinct receipts, so an amount-rule (R1/R2/R4)
// pair of them is a false positive — demote it (catches the 2-member ERS pairs the
// >=3 cluster guard misses). KEEP R3 (exact same normalised number even on ERS).
const isErsNumber = s => /^ERS[-\s]/.test(String(s || "").toUpperCase());
function ersAmountPair(a, b) { // call only for amount-rule (R1/R2/R4) pairs
const na = String(a.invoiceNum || ""), nb = String(b.invoiceNum || "");
return isErsNumber(na) && isErsNumber(nb) && na.toUpperCase() !== nb.toUpperCase();
}Output sheet names & tab order (left to right)
AP_Dup_Recovery (dashboard, first), Dup_Candidates (every pair, ranked), AP_Invoices (the
extracted universe — its report name), [AP_Payments] (paid-confirmation, if Step 5 ran).
Pipeline
Step 1 — Extract (DELEGATE to br-run-reports — do not run it yourself)
Ask br-run-reports to run "AP Invoices and Lines" with Operating Unit (or Ledger) = {input} and Invoice Date From/To = {lookback}, Payment Status left blank. Pass the exact name directly. While it runs, do nothing — no add-in calls, no sheet renames, no reads.
- Your FIRST add-in action is the run itself — do NOT
br_report_list_external-search. "AP Invoices and Lines" is the authoritative name (see Reports used). A discovery search ("AP duplicate invoices…", then "AP invoices") wastes 2+ round-trips; it is only the#BLITZ_REPORT_NOT_FOUNDfallback. - If the user gave the Operating Unit / Ledger value (e.g. "Vision Operations"), pass it as-is — do
NOT look up its LOV first. Inspect params (
br_report_params_external) only on a bareERROR: ERROR(param-name mismatch) or to resolve an exact LOV spelling you don't already have. #BLITZ_NOT_CONNECTED→ relay it, ask the user to connect via the Blitz Report ribbon, retry.- Fallback: if the hand-off doesn't fire or the data never lands, tell the user to run "AP Invoices and Lines" themselves for the OU + date range, then ask you to analyse it; resume at 1b.
Step 1b — Anchor (single execute_office_js call)
With Display Level = Headers the rows are already one-per-invoice. In one call: locate the columns
(SUPPLIER, SUPPLIER_NUMBER, INVOICE_NUM, INVOICE_DATE, INVOICE_AMOUNT, INVOICE_CURRENCY_CODE, INVOICE_PAYMENT_STATUS, OPERATING_UNIT, INVOICE_ID), record the invoice count and SUM of INVOICE_AMOUNT as the reconciliation anchor, and return both. (If the instance returned line grain,
collapse to one row per INVOICE_ID first.) If zero invoices: report "no AP invoices for {OU} in
{window}" and stop.
- Report the universe size back to the user and name the window as the lever — matching is quadratic within a supplier group, so on a large universe say so before Step 3 rather than after a slow run: e.g. "{n} invoices over {window} — matching will take a few minutes; narrow the window if you want a faster first pass." Do not silently narrow it yourself: a duplicate 18 months apart is exactly what a 24-month window exists to catch, so shortening it is the user's call.
- 🚨
INVOICE_IDis the FINAL column ofAP Invoices and Lines— at the far right (~column 170), NOT inside the first ~52 columns. Read the WHOLE header row (e.g.A2:GZ2) and findInvoice Idby name, then read that column for every invoice. Reading onlyA:AZmisses it. 🚨 NEVER substitute the worksheet row number forINVOICE_ID(invoiceId: String(rowNum)): the dup dashboard would still look right, but the orchestrator's cross-check joins the pay-run's REALINVOICE_IDs against these, so a row-index surrogate makes the join match nothing and report a false "CLEAN" — a real run did exactly this (0 RED when 89 open duplicates were sitting in the pay-run). The id values must be the real OracleInvoice Id(large integers like242901), not3, 4, 5, ….
Step 2 — Normalise (single execute_office_js call)
In one call on the header-grain set: add the supplier_key, norm_invnum, and currency-tagged amount columns (per CONFIG), writing all headers + the seed formula row + autoFill to the last
row in the same block (br-analytics-standards Rule 4). Do NOT apply Text ("@") format to formula
cells (it stops them evaluating); text-format only literal label cells (Rule "Excel correctness" #1).
Step 3 — Match (single execute_office_js call) (delegate the scan to br-anomaly-detection if it fires; else inline)
In one call: first compute sameAmountClusterKeys (CONFIG canonical JS) and demote every
amount-rule (R1/R2/R4) pair whose amount group has ≥3 members — without this the ERS clusters
explode (the $120.9M false positive). Also demote any amount-rule pair where ersAmountPair(a,b) is true (two distinct ERS auto-numbers = two receipts — the 2-member ERS pairs the cluster guard
misses; keep R3). Then apply R1–R6 within each supplier_key (and across OUs for
R6), build the candidate pair array (INVOICE_ID A, INVOICE_ID B — the actual INVOICE_ID column values, NOT the worksheet row index; this is the exact key the orchestrator's cross-check
joins on — supplier, both invoice numbers, both dates, both amounts, currency, rule(s) hit,
confidence, $ exposure), apply the remaining false-positive guards (tag recurring / split /
credit-memo, suppress sub-materiality), sort by $ exposure descending, create Dup_Candidates, and
write every pair with headers. Return the candidate-pair count and the distinct-invoice $ exposure
(distinctInvoiceExposure — NOT the per-pair sum), plus the candidate rate ({pairs}/{universe}) so
the plausibility gate can fire. Use the same currency on both sides of every comparison.
- Build mechanics (learned on a real run — the universe is large, so SPLIT the call): reading the
whole result range (1400+ rows × 50+ cols) into JS and writing in one call hits the office-js
memory ceiling →
GeneralExceptionatRange.valueswith no row location. Do it in two calls (br-analytics-standards #5): call 1 reads ONLY the columns listed in Step 1b (neverusedRange.values), builds the candidate-pair array, andblobs.setJSON("ap_dup_pairs", pairs); call 2blobs.getJSONs it and writesDup_Candidatesin a single.values =(not row-by-row, not chunk-and-sync). Before the write, pre-set the invoice-number /supplier_key/ invoice-id columns to text (range.numberFormat = "@") and apostrophe-prefix any string matching/^[=+\-@]/(br-analytics-standards4) — an invoice number like
"=-"otherwise throws the whole write and forces a binary-search hunt.Write the Invoice Date A/B columns as the raw serials + a date
numberFormat(br-analytics-standards8) — do NOT
formatDate()-stringify them (a live run wrote"13-Mar-2009"text that can't sortor filter as a date).
Step 4 — Recoverable vs preventable split (part of Step 3's call, or one execute_office_js call)
Tag each surviving pair Recoverable (both Paid), Preventable (≥1 Open), or Already-resolved (one credited/voided → exclude). Use the Payment Status carried from the
universe. Return the recoverable $ and preventable $ subtotals as distinct-invoice sums (distinctInvoiceExposure), never the per-pair exposure added up (see the distinct-$ rule in CONFIG).
Step 5 — Confirm the cash actually left (OPTIONAL add-on — only if the user asks, or for the top recoverable pairs)
Delegate to br-run-reports to run "AP Invoice Payments" for the OU + overlapping date range →
It joins onto Dup_Candidates, so adding it later costs no re-extract of the universe — verify Dup_Candidates is unchanged, extract only AP Invoice Payments, attach the evidence, then re-run
Steps 6–8. Never point a dashboard KPI at AP_Payments unless this step ran. AP_Payments (columns include SUPPLIER_NUMBER, INVOICE_NUMBER, PAYMENT_DATE, DOCUMENT_NUMBER = the check/payment number, PAYMENT_AMOUNT, and PAYMENTS_PER_INVOICE). Join the Recoverable candidates on SUPPLIER_NUMBER + INVOICE_NUMBER to attach the DOCUMENT_NUMBER and PAYMENT_DATE — the evidence a recovery claim needs. PAYMENTS_PER_INVOICE > 1 is itself a strong
same-invoice-paid-twice signal — surface it. A recoverable pair with no matching payment row is downgraded to Preventable (status said Paid but no disbursement found — investigate). Keep this a
targeted join on the top pairs, not a full re-scan.
Step 6 — Dashboard (two execute_office_js calls: data then charts) (delegate to br-data-dashboard if it fires; else inline)
Call 1 — all data sections on a NEW AP_Dup_Recovery sheet at position 0: a headline KPI
band (Total candidate $, Recoverable $ + pair count, Preventable $ + pair count,
candidates by confidence); a Pareto of $ exposure by rule (R1–R6); top suppliers by
recoverable $; and the top-N recovery worklist (the highest-$ Recoverable pairs with both
invoice numbers, dates, amounts, rule, and — if Step 5 ran — the check number). Every KPI is a
formula referencing Dup_Candidates (COUNTIFS/SUMIFS) so it refreshes on re-run. Call 2 — charts only: a bar chart of $ by rule and a bar chart of top suppliers.
- Build mechanics (house standard): build only on the new sheet; do not rename the report
sheet (KPIs link to it / to
Dup_Candidates). Do NOT use a multi-rowcopyToRangeto fill the worklist rows — write each row's formula explicitly. Apply the autofilter withsheet.autoFilter.apply(headerRange)—range.autoFilter()is not a function and throws. After building, scan beyond the dashboard range, clear stray cells, widen amount columns (and the chart source columns) so values don't render as######, and size the charts in the same charts call so no label is clipped — don't leave a width/sizing fix for a second pass.
Step 7 — Recommendations & narrative (single set_cell_range call)
Write all ≤6 bullets on AP_Dup_Recovery in one call: total recoverable $ and the single biggest
recoverable pair (supplier + $ + rule); how many preventable pairs are still open and should be held now before the second payment runs; the supplier(s) with the most exposure; any duplicate
vendor master (R5) to merge; how many were demoted as recurring/split (so the user trusts the
filter); and the recommended next action per bucket (raise a supplier credit/refund claim for
recoverable; place an AP invoice hold for preventable; merge the duplicate vendor).
Then, in your reply (not on the sheet), if Step 5 was skipped, offer it in one line and say it needs no re-extract of the universe — e.g. "Want the check numbers and payment dates attached to the top recoverable pairs as claim evidence? I can add that on this data."
Step 8 — Arrange tabs + final validation (single execute_office_js call + one read)
Reorder tabs to AP_Dup_Recovery, Dup_Candidates, AP_Invoices, [AP_Payments]; delete the blitz_ helper sheet; activate AP_Dup_Recovery. Then the one permitted verification read: confirm the dashboard's total candidate $ and pair
count equal the Dup_Candidates totals, and that recoverable + preventable + already-resolved =
all candidates. This is the only explicit re-read outside Step 1b.
Validation (before declaring done)
- Reconcile: dashboard candidate count/$ =
Dup_Candidatestotals; recoverable + preventable + already-resolved partitions the candidate set exactly. - Distinct-$, not pair-sum: the headline Recoverable $ / Preventable $ are distinct-invoice sums (
distinctInvoiceExposure), so the total can never exceed the universe's open/paid $. - Plausibility: candidate pairs are < ~10% of the universe; above that, the headline says "high volume — likely systematic, review", not a confident "$X about to be paid twice".
- Cluster guard fired: no
supplier_key+currency+amountgroup of ≥3 survives as headline amount-rule pairs (they're taggedSame-amount clusteron the audit tab). - Pair integrity: every pair references two distinct invoice ids of the same currency; no pair is a credit-memo-vs-its-own-positive.
- Filter sanity: spot-check 2–3 demoted
Same-amount cluster/Recurring patternseries (≥3 same-amount) and 2–3 top Recoverable pairs by reading their rows — confirm the rule genuinely holds. - Dates are real dates (serials + date
numberFormat), notformatDate()text strings. - No
######and no stray cells outside the dashboard range.
Re-run / refresh
Never re-extract when you don't have to:
- "Confirm which actually paid" / "get me the check numbers" (same universe) — run only Step 5
(which extracts
AP Invoice Paymentsalone), then Steps 6–8. Do NOT re-runAP Invoices and Lines. - "Change the materiality threshold" (same universe) — re-run only Steps 3–4 and 6–8.
- "Widen the window" / "check supplier X" / "rebuild" (new universe) — re-drive from Step 1 with the new scope, rebuilding the same set of sheets the previous run produced.
Closed-loop (roadmap, not executed here)
Placing a hold on an open preventable invoice is deliverable via a Blitz Upload (AP invoice hold); raising the supplier credit / refund claim for a recoverable pair, and merging a duplicate vendor master, are human-approved actions. This skill produces the evidenced worklist; a human recovers the cash.