DEV Community

Violet Vault Team
Violet Vault Team

Posted on Edited on Originally published at merchantcleanup.pages.dev

From raw bank descriptors to spending charts: merchant names, categories and a review queue in JavaScript

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
Enter fullscreen mode Exit fullscreen mode

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"]}'
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Three fields matter for charts:

  • category is one of 23 fixed values (meals, groceries, health...), so map each to a color once.
  • confidence is 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_subscription flags recurring-looking charges: the gym line is other but true here, 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)) }));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Real stderr, first import then repeat:

looked up: 11, requests: 2
looked up: 0, requests: 0
Enter fullscreen mode Exit fullscreen mode

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 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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" }
]
Enter fullscreen mode Exit fullscreen mode

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 429 with Retry-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)