CadenseCadense
  • Docs
  • Blog
  • Integrations
  • Pricing
  • Docs
  • Blog
  • Integrations
  • Pricing

What should we get done?

Docs
Cadense

Product

HarnessWorkflowsKnowledge BaseTablesMCPAPISelf HostingPricingChangelogFiles

Resources

BlogDocsPricingPartnersChangelog

Blocks

AgentRouterFunctionConditionAPIWorkflowParallelGuardrailsEvaluatorLoop

Integrations

All IntegrationsConfluenceSlackGitHubGmailHubSpotSalesforceNotionGoogle DriveGoogle SheetsSupabaseStripeJiraLinearAirtableFirecrawlDiscordMicrosoft TeamsTelegram

Legal

Terms of ServicePrivacy Policy
Back to Blog
How we built a GL categorization engine a controller will sign off on

How we built a GL categorization engine a controller will sign off on

A real walkthrough of the five-stage cascade Cadense uses to suggest GL codes — deterministic rules first, embeddings as a fallback, abstention when nothing fits — and the trade-offs we made along the way.

Apr 15, 2026
Em
Emir Karabeg

A controller I respect once told me she had "AI category suggestion" muted in three accounting tools because all of them had, at various points, coded a federal payroll deposit as office supplies. The conversation that ended each tool went the same way: she'd ask the vendor why the system suggested that account, and nobody could answer. The model just did.

We started building Cadense's categorization engine after a similar demo. The pitch was that you could replace your bookkeeper with a single LLM call. The reality was that the LLM picked plausible-sounding accounts, often confidently, with no way to challenge the answer. That's not a categorization engine. That's a generator of accounting questions.

TL;DR

  • The categorization engine is a five-stage cascade. Earlier stages are deterministic; later stages get progressively more ML-flavored. The first stage to clear its threshold wins.
  • Suppression rules at confidence 0.99 block dangerous categorizations (credit-card payments, internal transfers) before any embedding can interfere.
  • Embeddings sit at stage 3 with a default threshold of 0.55 — deliberately conservative. Below that, we fall back to a keyword heuristic; below 0.25, we abstain.
  • Every result carries a decision trace (which stage matched, what the score was, why fallback kicked in) so a controller can audit the suggestion in seconds.

The three things finance teams actually need

Before a line of code, we wrote down what a "good" categorization looks like from an AP team's perspective. Three constraints stuck:

  1. Determinism for the obvious. A check to American Express should never be coded as office supplies because some embedding score happened to favor it.
  2. Coverage for the long tail. The 5% that's hard — obfuscated merchant names, new vendors, multi-currency invoices — has to land somewhere reasonable.
  3. A graceful "I don't know." When the system isn't confident, it should abstain rather than guess. False negatives (a transaction in the review queue) cost a controller minutes. False positives (a wrong code that gets posted to the GL) cost an audit cycle.

These are the three pressure points the cascade is engineered around.

The five-stage cascade, from above

1
incoming transaction
2
       │
3
       ▼
4
[ 1. Rule pack (incl. suppression rules) ] ──► matched? ──► done
5
       │
6
       ▼
7
[ 2. Taxonomy phrase / MCC matcher       ] ──► matched? ──► done
8
       │
9
       ▼
10
[ 3. Embedding-based semantic search     ] ──► above 0.55? ──► done
11
       │
12
       ▼
13
[ 4. Keyword fallback                    ] ──► above 0.25? ──► done
14
       │
15
       ▼
16
[ 5. Abstain (confidence 0)              ] ──► review queue

Categorization cascade flow The categorizer advances from deterministic to probabilistic stages. A winning stage emits a category, confidence, and decision trace; no winning stage emits a review item instead of a weak guess.

Earlier stages are cheap, deterministic, and high precision. Later stages get progressively more ML-flavored. The first stage that passes its threshold wins.

The entry point in packages/banking-categorization/src/classify-transactions.ts looks roughly like this:

1
export async function classifyTransactions(
2
  transactions: BankTransaction[],
3
  context: ClassificationContext
4
): Promise<ClassificationResult[]> {
5
  const results: ClassificationResult[] = []
6
  for (const tx of transactions) {
7
    const trace = createDecisionTrace(tx)
8
 
9
    // Stage 1: rules + suppression
10
    const ruleHit = evaluateRulePack(tx, context.rulePack, trace)
11
    if (ruleHit) { results.push(ruleHit); continue }
12
 
13
    // Stage 2: taxonomy phrase + MCC
14
    const phraseHit = evaluatePhraseIndex(tx, context.phraseIndex, trace)
15
    if (phraseHit) { results.push(phraseHit); continue }
16
 
17
    // Stage 3: embeddings (cosine similarity over cached category vectors)
18
    const embeddingHit = await scoreAgainstEmbeddingIndex(tx, context, trace)
19
    if (embeddingHit && embeddingHit.confidence >= context.embeddingConfidenceThreshold) {
20
      results.push(embeddingHit); continue
21
    }
22
 
23
    // Stage 4: token-overlap keyword fallback
24
    const keywordHit = scoreByKeywordOverlap(tx, context.taxonomy, trace)
25
    if (keywordHit && keywordHit.confidence >= context.keywordConfidenceFloor) {
26
      results.push(keywordHit); continue
27
    }
28
 
29
    // Stage 5: abstain
30
    results.push({ confidence: 0, status: 'requires_review', trace })
31
  }
32
  return results
33
}

Every result carries the same trace — the audit trail that turns a suggestion into a defensible recommendation.

Categorization result schema A categorization result records the transaction, selected category, stage, confidence, threshold snapshot, and model version when embeddings participate. Controller corrections become review feedback without silently mutating past explanations.

Walking a real transaction through the cascade

Let's run a real-looking example end to end. Say the bank feed delivers:

1
date:        2026-04-15
2
amount:      -412.40 USD
3
descriptor:  ACME OFFICE SUP 412.40
4
mcc:         5943      // "Stationery, Office, School Supply Stores"
5
provider:    Visa

Stage 1 — Rules. No suppression rule fires (this isn't a credit-card payment, not an internal transfer). No user rule for "Acme Office" exists in this workspace. Rule pack returns nothing. Move on.

Stage 2 — Phrase + MCC. The MCC 5943 maps cleanly to Office Supplies via the MCC rule path at confidence 0.98. Match. Cascade stops here. The trace shows: mcc:5943 → office_supplies (0.98).

Now imagine the same transaction without an MCC (very common — most small merchants don't transmit one):

Stage 2 — Phrase + MCC. No MCC. We try merchant phrase matching against the taxonomy. "Acme office sup" tokenizes to [acme, office, sup]. The category index built from category-hints.ts includes office, supplies, stationery for Office Supplies. Token overlap is partial but the phrase score doesn't clear the merchant phrase ceiling of 0.86. Move on.

Stage 3 — Embeddings. We generate an embedding for acme office sup outflow visa usd and compare to every category vector in the cached index. Office Supplies scores 0.71, well above the 0.55 threshold. Match. Trace: embedding → office_supplies (0.71).

If the merchant string had been more obfuscated — say AMZ*8KYT4Q OUTFLOW USD — embeddings might score the top category at only 0.47. We'd drop to Stage 4 (keyword fallback). If the keyword overlap also fails to clear 0.25, Stage 5 abstains and the transaction lands in the review queue with confidence 0.

That's the point of the design: the answer always lives at the highest-confidence stage that fires, and the trace tells you which stage that was.

Stage 1 deep dive: suppression and the priority game

The rule pack is the highest-priority stage and it's deliberately rigid. Rules live in default-rulepack.ts and can be extended per workspace. Each rule has a source — suppression-rule, merchant-rule, provider-rule, mcc-rule, or user-rule — and a confidence baseline.

Suppression rules sit at confidence 0.99 and exist to block categorization in cases where any other signal would mislead the system. Examples:

  • A credit-card payment from your operating account to your AP card. Suppression rule fires; categorization halts. Without it, the embedding stage would happily code this as "Software & SaaS" because that's what your card paid for last month.
  • An intra-company transfer between two of your own accounts. Should not be categorized as anything.
  • A bank fee. Has its own ledger account; should not be inferred from the descriptor.

The rule engine in rule-engine.ts:evaluateRulePack ranks matches by three things in order:

  1. Source priority — suppression beats user beats merchant beats provider beats MCC.
  2. Specificity — a 3-token phrase match beats a 1-token match. Longer phrases are less ambiguous.
  3. Confidence — ties broken by the rule's stated confidence.

If any rule fires, the cascade ends. No embedding, no LLM, no fuzziness. That covers a surprising fraction of real AP volume — payroll, rent, recurring SaaS, intra-company transfers — and it covers it deterministically.

Stage 2 deep dive: phrase + MCC matching

If no rule matches, we hit the phrase index built from the workspace's chart of accounts, the category taxonomy, payment-channel hints, and a hand-curated category-hints.ts file (entries like anthropic, openai, perplexity map to AI/ML services). This is still deterministic — it's just lookup, not learning.

Sub-pathConfidence ceilingNotes
MCC code match0.98When the bank feed includes one
Provider phrase exact match0.93E.g. provider category "Restaurants" → Meals
Provider phrase contains0.88Substring match within a longer string
Merchant phrase match0.86The most common path

In practice, merchant phrase matching carries this stage because most small merchants don't transmit MCC.

Stage 3 deep dive: embeddings, conservatively

If we still don't have an answer, we generate an text-embedding-3-small vector for the transaction's query — merchant, provider category, payment channel, amount direction (inflow vs. outflow), currency. We score this query against a pre-computed embedding for every category in the taxonomy and pick the top match.

A few opinions baked into this stage:

  • The threshold defaults to 0.55, not 0.7 or higher. That's deliberately conservative for finance. We'd rather drop into keyword fallback than return a confident-sounding semantic match that's actually weak.
  • The embedding index is cached. Vectors are computed once per category definition and held in Redis with a 24h TTL. Reclassifying a 50k-transaction history isn't an OpenAI bill problem; it's a single batch over the bank-side query embeddings.
  • The model name carries a version suffix (text-embedding-3-small@v1). Switching embedding models is a one-line config change and it's tracked alongside every classification. If we move to a new model, the trace tells you which model categorized which transaction.

Stage 4 deep dive: keyword fallback

If the embedding score is below 0.55, we drop to a token-overlap heuristic. We tokenize the transaction text and the category definition, count shared tokens, and weight "primary" tokens (category name, aliases, examples, hints) higher than secondary tokens (description, parent labels). Provider tokens get an additional 0.75x weight because Visa/Mastercard processors so often obscure merchant identity.

The keyword fallback is honest about what it is — a pattern match of last resort. The floor is 0.25. Below that, we abstain.

Stage 5: abstention beats a wrong answer

If nothing in the cascade passes its threshold, the engine returns confidence 0 and tags the transaction requires_review. It shows up in the AP team's coding queue with no suggestion attached.

This is the most controversial choice in the whole engine. Many vendors return something — a low-confidence guess — because empty suggestions feel like the system isn't doing its job. We came down on the other side. A wrong code that gets posted to the GL is much more expensive to unwind than a transaction that sits in a review queue for an extra 30 minutes.

The naive approach vs. the cascade

ConcernNaive single-LLM callThe cascade
Common cases (payroll, rent, SaaS)Generates an answer; quality depends on promptSuppression and rule packs cover them deterministically
Auditability"The model picked it"Decision trace shows which stage matched and why
Failure modeConfident wrong answerDrops to fallback, then abstains
Drift on model upgradesSilentVersioned model names; old classifications can be re-scored
Cost per transactionOne LLM callOne embedding call (only for stage 3+)
Threshold controlNonePer-workspace config

The cascade is more code. It's also the only shape that holds up to a controller asking "why did you suggest that?".

Tunable per workspace

Every threshold mentioned above (embeddingConfidenceThreshold, keywordConfidenceFloor, mccBaseConfidence, etc.) is exposed via apps/sim/app/api/workspaces/[id]/banking/categorization. A controller can dial precision/recall to taste:

  • 0.7 if they want fewer auto-coded items but higher trust per item.
  • 0.4 if they want broader coverage and are willing to review more.

The defaults are tuned for the median finance team: conservative enough to keep the GL clean, aggressive enough to clear the high-volume recurring stuff without human input.

What we deliberately left out

A few things we get asked about that aren't in the engine yet, with the reasons:

  • No feedback loop. When a user overrides a suggestion, that signal isn't fed back into the embedding index or the rule pack. We'd rather wait until we have enough volume to do this responsibly than ship a learning loop that drifts in an opaque way.
  • No cross-currency normalization. A USD merchant and a EUR merchant with the same name are scored independently; we don't FX-normalize the embedding space.
  • No LLM in the hot path. We use embeddings, not generative models, for the core classification step. LLMs sit one layer up — generating category descriptions, alias lists, and examples that feed into the deterministic stages — but they don't make the live decision. That's been good for cost, latency, and predictability.

The point of all this

Categorization isn't a place to be clever. It's a place to be right when you can be, honest when you can't, and traceable in both cases. The cascade gives us a way to push every transaction as far down the determinism axis as the data allows, and to stop at the right step. The rest is a tuning exercise, and we let each workspace own that.

This same shape — deterministic-first, ML-as-fallback, abstain-when-unsure — is the bias that runs through everything we build on the banking side. The next two posts in this series describe the systems that sit alongside the categorizer: the reconciliation engine that matches bank transactions to invoices, and the anomaly detector that flags unusual spend without crying wolf. They share the cached category index, the abstention philosophy, and the same allergy to confident wrong answers.

If you want to dig in further, the engine lives in packages/banking-categorization. The entry point is classifyTransactions in classify-transactions.ts. The rule pack in default-rulepack.ts and the category hints in category-hints.ts are both worth a read.

FAQ

Why use a cascade instead of just one ML model?

A cascade lets us hit the easy 70-80% of cases deterministically (rules, phrase matching), where confidence is justified by structure rather than learned weights. Then we use embeddings only on the harder long tail. This keeps the average GL code defensible to a controller while still covering novel vendors.

What model does Cadense use for embeddings?

OpenAI text-embedding-3-small. The model name is versioned (e.g. text-embedding-3-small@v1) and stored alongside every classification, so if we change models we know which classifications to recompute.

Will the engine learn from my corrections?

Not yet. Today, when a user overrides a suggestion, that signal is captured but does not retrain the embedding index or the rule pack. We plan to add a feedback loop, but only when we can do it without introducing opaque drift.

How are confidence thresholds set?

They default to safe values (0.55 for embeddings, 0.25 for keyword fallback, 0.99 for suppression rules) and are exposed per workspace via the categorization settings API. A controller can tighten thresholds for higher precision or loosen them for more coverage.

What happens when nothing in the cascade matches?

The transaction is returned with confidence 0 and tagged for manual review. We deliberately do not return a low-confidence guess — a wrong GL code is much more expensive to unwind than a transaction that sits in a coding queue.

Does the engine handle MCC (Merchant Category Codes)?

Yes. When the bank feed includes an MCC, we map it via a dedicated rule path with 0.98 confidence. Most small merchants do not transmit MCC, so this path is taken less than you might expect.

Spotting unusual spend without crying wolf: Cadense's anomaly detector
Apr 26

Spotting unusual spend without crying wolf: Cadense's anomaly detector

Why a single $50k legal invoice in your baseline can blind a classical z-score, how robust statistics fix it, and the five signals Cadense uses to flag spend spikes without drowning AP teams in noise.

Why one signal can't reconcile a bank statement (and the five we use instead)
Apr 26

Why one signal can't reconcile a bank statement (and the five we use instead)

A worked example of how Cadense matches bank transactions to invoices and payments using five independent signals — amount, date, identity, history, and topology — with weekend-aware tolerance, a rules layer, and a 15-category exception taxonomy.

Introducing Harness
Mar 26

Introducing Harness

A technical look at Harness, the Cadense control plane that lets AP agents read finance context, take approved actions, keep evidence, and run recurring operations safely.