Disclosure: this post is written by the team that builds Merchant Cleanup API, the service used below. All merchants in it are made up.
A spending chart is only as good as its labels. Group raw bank lines and a user who bought coffee twice at one shop sees two bars, neither saying "coffee". This post builds the pipeline between a transactions table and a chart: clean each descriptor into a merchant and category, aggregate, queue the unsure lines for review, and never look up the same descriptor twice.
For what SQ *, TST* and the trailing numbers mean, the first post in this series covers that plus a DIY regex cleaner.
The input
Fourteen made-up rows of {date, description, amount}, some with an optional mcc, as they'd arrive from a bank export. The descriptions:
SQ *MAPLE STREET COFFEE 1234 OAKLAND CA (x2)
TST* JOES DINER #0042 AUSTIN TX (x2)
MAPLE STREET MARKET 0091 (x2, MCC 5411)
RIVERSIDE FUEL 00123 DENVER CO (MCC 5541)
BRAMBLEGATE PHARMACY #212 PORTLAND OR
OAKHOLLOW FITNESS MONTHLY DUES
LANTERNWOOD CINEMA 8 SPOKANE WA
QUILLBROOK WATER UTIL ONLINE PMT
PINEHILLBOOK 4029357733 CA
ZPHR*KT 88412 WWW
GLIMMERBY BAKERY 0007 BOISE ID
Only eleven are distinct. That gap is the first optimization.
What one call returns
Get a free key at /signup (email only) and keep it server-side. A batch call with two of the lines above:
curl -X POST https://merchantcleanup.pages.dev/v1/clean \
-H "Authorization: Bearer $MERCHANT_CLEANUP_KEY" \
-H "Content-Type: application/json" \
-d '{"descriptors": ["OAKHOLLOW FITNESS MONTHLY DUES", "PINEHILLBOOK 4029357733 CA"]}'
Real response:
{
"results": [
{"input": "OAKHOLLOW FITNESS MONTHLY DUES", "merchant": "Oakhollow Fitness Monthly Dues", "category": "other", "confidence": 0.6, "match": "keyword", "processor": null, "is_subscription": true},
{"input": "PINEHILLBOOK 4029357733 CA", "merchant": "Pine Hill Book", "category": "other", "confidence": 0.45, "match": "cleaned_text", "processor": null, "is_subscription": false}
],
"count": 2
}
Three fields matter for charts:
-
categoryis one of 23 fixed values (meals,groceries,health...), so map each to a color once. -
confidenceis a heuristic score (0.6 for a keyword rule, about 0.7 with an MCC, 0.45 or lower for cleaned raw text). It measures evidence strength, not how often results are right, but it makes a good threshold. -
is_subscriptionflags recurring-looking charges: the gym line isotherbuttruehere, which suits a "recurring charges" widget.
The pipeline
Node 18+, no dependencies. It dedupes, checks a cache, batches up to 10 per request (the Free plan limit) and backs off on 429:
import { readFile } from "node:fs/promises";
const API = "https://merchantcleanup.pages.dev/v1/clean";
const BATCH = 10; // Free plan: up to 10 descriptors per request
const REVIEW_BELOW = 0.5; // confidence threshold for the review queue
const keyOf = (d) => d.trim().toUpperCase().replace(/\s+/g, " ");
async function cleanBatch(items) {
const res = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MERCHANT_CLEANUP_KEY}`,
"Content-Type": "application/json",
"User-Agent": "budget-demo/1.0",
},
body: JSON.stringify({ descriptors: items }),
});
if (res.status === 429) {
const wait = Number(res.headers.get("retry-after") || 30);
await new Promise((r) => setTimeout(r, wait * 1000));
return cleanBatch(items);
}
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return (await res.json()).results; // same order as items
}
// cache: any Map-like store keyed by normalized descriptor
export async function enrich(txns, cache) {
const todo = new Map();
for (const t of txns) {
const k = keyOf(t.description);
if (!cache.has(k) && !todo.has(k))
todo.set(k, t.mcc ? { descriptor: t.description, mcc: t.mcc } : t.description);
}
const keys = [...todo.keys()];
for (let i = 0; i < keys.length; i += BATCH) {
const chunk = keys.slice(i, i + BATCH);
const results = await cleanBatch(chunk.map((k) => todo.get(k)));
chunk.forEach((k, j) => cache.set(k, results[j]));
}
console.error(`looked up: ${keys.length}, requests: ${Math.ceil(keys.length / BATCH)}`);
return txns.map((t) => ({ ...t, clean: cache.get(keyOf(t.description)) }));
}
Results come back in input order, so zipping them with chunk is safe. An unreadable line returns an error in its slot instead of failing the batch.
Aggregate into chart-ready JSON
Anything below the threshold goes into a needs_review bucket instead of polluting a real category:
export function toChart(rows) {
const buckets = new Map();
const review = [];
for (const { date, description, amount, clean } of rows) {
const unsure = clean.error || clean.confidence < REVIEW_BELOW;
if (unsure) review.push({ date, description, amount, suggestion: clean.merchant ?? null,
category: clean.category ?? null, confidence: clean.confidence ?? null });
const cat = unsure ? "needs_review" : clean.category;
const b = buckets.get(cat) ?? { category: cat, total: 0, count: 0, merchants: new Set() };
b.total += -amount; b.count++; b.merchants.add(clean.merchant ?? description);
buckets.set(cat, b);
}
const series = [...buckets.values()]
.map((b) => ({ ...b, total: Math.round(b.total * 100) / 100, merchants: [...b.merchants] }))
.sort((a, b) => b.total - a.total);
return { series, review };
}
const txns = JSON.parse(await readFile("./transactions.json", "utf8"));
const cache = new Map();
console.log(JSON.stringify(toChart(await enrich(txns, cache)), null, 2));
await enrich(txns, cache); // the same import again
Real stderr, first import then repeat:
looked up: 11, requests: 2
looked up: 0, requests: 0
Real stdout (review trimmed to one of five entries):
{
"series": [
{ "category": "groceries", "total": 109.95, "count": 2, "merchants": ["Maple Street Market"] },
{ "category": "needs_review", "total": 83.89, "count": 5,
"merchants": ["Bramblegate Pharmacy", "Lanternwood Cinema", "Pine Hill Book", "Zphr Kt", "Glimmerby Bakery"] },
{ "category": "meals", "total": 64.7, "count": 4, "merchants": ["Maple Street Coffee", "Joes Diner"] },
{ "category": "utilities_and_phone", "total": 61.33, "count": 1, "merchants": ["Quillbrook Water Util"] },
{ "category": "auto_and_transport", "total": 38.2, "count": 1, "merchants": ["Riverside Fuel"] },
{ "category": "other", "total": 35, "count": 1, "merchants": ["Oakhollow Fitness Monthly Dues"] }
],
"review": [
{ "date": "2026-10-11", "description": "ZPHR*KT 88412 WWW", "amount": -12,
"suggestion": "Zphr Kt", "category": "other", "confidence": 0.45 }
]
}
series maps straight onto most chart libraries: labels = series.map(s => s.category) and data = series.map(s => s.total). The two coffee visits and two diner visits merged into one meals bar.
The second import hit the cache for everything, so nothing went over the network. In production, back it with a table keyed on the normalized descriptor; merchants repeat month to month, so the cache does most of the work after the first import.
Working the review queue
Five rows landed in review, all at 0.45 with match: "cleaned_text": the name was cleaned, but nothing said what kind of business it was. Two ways to shrink the queue.
Send the MCC when you have it. If your data source gives a merchant category code, include it. Resending the pharmacy and cinema lines with MCCs 5912 and 7832 gave this real response (trimmed to the relevant fields):
[
{ "merchant": "Bramblegate Pharmacy", "category": "health", "confidence": 0.7,
"match": "cleaned_text+mcc", "mcc_description": "Drug Stores and Pharmacies" },
{ "merchant": "Lanternwood Cinema", "category": "entertainment", "confidence": 0.7,
"match": "cleaned_text+mcc", "mcc_description": "Motion Picture Theaters" }
]
Let the user decide, then remember it. Show review items with the suggested name prefilled and a category picker. Save the confirmed answer under the same cache key with source: "user", and never overwrite it with an API result. Only the buyer can label ZPHR*KT 88412 WWW.
Limits worth knowing
- Sending 11 descriptors on the Free plan returned
413 batch_too_large("At most 10 descriptors per request on this plan."). Chunk first. - Per-minute numbers are fair-use guidelines, not caps. Going well over can return
429withRetry-After, which the code honors. - Results are automated suggestions; keep the review step for anything tax-related.
Pricing
The Free plan is available now: a free key with just an email, up to 10 descriptors per request. Paid plans are coming soon. Full reference is in the docs, and statement-code explainers are in the guides.
How does your app handle low-confidence lines? Share a made-up or scrubbed descriptor that trips your categorizer in the comments.
Top comments (0)