Skip to content

feat: COA classification API + auto-suggest on import#86

Merged
chitcommit merged 3 commits intomainfrom
feat/coa-classification-api
Apr 10, 2026
Merged

feat: COA classification API + auto-suggest on import#86
chitcommit merged 3 commits intomainfrom
feat/coa-classification-api

Conversation

@chitcommit
Copy link
Copy Markdown
Contributor

@chitcommit chitcommit commented Apr 9, 2026

Summary

  • 10 new endpoints for COA management and trust-path classification workflow
  • Auto-classification at TurboTenant import (keyword-match → suggested_coa_code)
  • Storage layer methods for chart_of_accounts CRUD, classification, reconciliation, and stats
  • Database applied: chart_of_accounts (80 global accounts), classification_audit, and 7 new columns on transactions — all live on Neon

Endpoints

Endpoint Trust Level Purpose
GET /api/coa List COA (tenant + global)
GET /api/coa/global List global template accounts
POST /api/coa L4 Create tenant-specific account
PATCH /api/coa/:id L4 Update account definition
GET /api/classification/stats Classification progress
GET /api/classification/unclassified Transactions needing COA code
POST /api/classification/suggest L1 Suggest COA (writes suggested_coa_code)
POST /api/classification/classify L2 Set authoritative coa_code
POST /api/classification/reconcile L3 Lock transaction
POST /api/classification/batch-suggest L1 Auto-suggest via keyword match
GET /api/classification/audit/:txId Audit trail

Test plan

  • TypeScript check passes (0 errors)
  • 212 tests pass (26 files)
  • Neon schema verified: 80 COA accounts seeded, indexes created
  • Manual: POST /api/import/turbotenant → verify suggested_coa_code populated
  • Manual: POST /api/classification/classify → verify audit trail written
  • Manual: POST /api/classification/reconcile → verify lock prevents L2 reclassification

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Multi-level transaction classification: suggestion, authoritative classification, and reconciliation with audit logging.
    • Tenant-scoped Chart of Accounts (COA) plus global COA templates; COA create/update gated by tenant roles.
    • Protected classification and COA endpoints (require authentication/context).
    • Endpoints for unclassified transactions, bulk suggestions, classification stats, and per-transaction audit trails.
    • CSV import now includes COA auto-suggestions with confidence scoring.

- Add 10 classification endpoints: COA CRUD, suggest (L1), classify (L2),
  reconcile (L3), batch-suggest, stats, unclassified list, audit trail
- Wire keyword-based auto-suggestion into TurboTenant import (L0 ingest)
- Add SystemStorage methods for chart_of_accounts and trust-path operations
- Reconciled transactions locked from modification (except L3/L4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 9, 2026 06:34
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Apr 9, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
chittyfinance 854bbf7 Apr 10 2026, 08:03 PM

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c658ccee-88c4-458b-a9c3-a44f58d38322

📥 Commits

Reviewing files that changed from the base of the PR and between 33dd1d5 and 854bbf7.

📒 Files selected for processing (2)
  • server/routes/classification.ts
  • server/storage/system.ts

📝 Walkthrough

Walkthrough

Adds tenant-scoped Chart of Accounts (COA) APIs and a tiered transaction classification workflow (suggest/classify/reconcile) with audit logging, integrates COA suggestion into CSV imports, and implements storage methods and errors for COA, classification, stats, and audit retrieval.

Changes

Cohort / File(s) Summary
Application Routing
server/app.ts
Imported classificationRoutes, added /api/coa and /api/classification to protected prefixes, and mounted classification routes with existing auth/tenant/storage middleware.
Classification Endpoints
server/routes/classification.ts
New Hono router providing COA CRUD (GET/POST /api/coa, PATCH /api/coa/:id, GET /api/coa/global), classification flows (/api/classification/suggest, /classify, /reconcile, /batch-suggest), stats (/api/classification/stats), unclassified listing, and audit retrieval (/api/classification/audit/:transactionId) with zod validation, role checks, centralized ClassificationError → HTTP mapping, and audit logging.
CSV Import Enhancement
server/routes/import.ts
During TurboTenant CSV imports, derives suggestedCoaCode using findAccountCode and sets classificationConfidence (suspense vs non-suspense) when creating transactions.
Storage Layer
server/storage/system.ts
Added ClassificationError class and extended SystemStorage with COA persistence/lookup (getChartOfAccounts, getGlobalChartOfAccounts, getChartOfAccountByCode, createChartOfAccount, updateChartOfAccount), tenant role lookup, classification/reconcile APIs (classifyTransaction, reconcileTransaction), retrieval helpers (getTransaction, getUnclassifiedTransactions, getClassificationAudit, getClassificationStats), and related transactional audit writes and SQL queries.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant API as Classification API
    participant Storage as SystemStorage
    participant DB as Database

    Client->>API: POST /api/classification/suggest\n(txId, description, category)
    API->>Storage: findAccountCode(description, category)
    Storage-->>API: suggestedCoaCode, confidence
    API->>Storage: classifyTransaction(txId, suggestedCoaCode, {isSuggestion:true,...})
    Storage->>DB: UPDATE transactions (suggestedCoaCode/confidence)
    Storage->>DB: INSERT classification_audit (action: suggest)
    API-->>Client: 200 Suggestion recorded

    Client->>API: POST /api/classification/classify\n(txId, coaCode)
    API->>Storage: getChartOfAccountByCode(coaCode, tenant)
    alt COA exists
        API->>Storage: classifyTransaction(txId, coaCode, {isSuggestion:false,...})
        Storage->>DB: UPDATE transactions (coaCode, confidence, reconciled=false)
        Storage->>DB: INSERT classification_audit (action: classify)
        API-->>Client: 200 Classified
    else COA missing
        API-->>Client: 404 / 400
    end

    Client->>API: POST /api/classification/reconcile\n(txId)
    API->>Storage: reconcileTransaction(txId, tenant, actorId)
    Storage->>DB: VERIFY coaCode exists && not reconciled
    Storage->>DB: UPDATE transactions (reconciled=true, reconciledBy)
    Storage->>DB: INSERT classification_audit (action: reconcile)
    API-->>Client: 200 Reconciled
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Poem

🐰 I hopped through ledgers, nose to page,

I nudged lost rows into their charted cage,
L1 whispered hints, L2 stamped the claim,
L3 locked truth and signed my name,
A little rabbit proud of tidy books.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main changes: adding a COA classification API and auto-suggest functionality during import.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/coa-classification-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown

claude bot commented Apr 9, 2026

Code Review — PR #86: COA Classification API + Auto-Suggest on Import

The overall structure is solid and the trust-path concept is well-designed. The storage abstraction, audit trail, and import integration all follow existing patterns correctly. A few issues need attention before merge — one is a security gap, the rest are correctness/performance problems.


Critical: Trust Level Is Declared but Never Enforced

The PR description marks endpoints with L1/L2/L3/L4 labels, but classification.ts has no authorization checks — any authenticated user can call any endpoint. The reconcile and COA-modify endpoints are particularly exposed.

// POST /api/classification/reconcile — L3: lock a classified transaction
classificationRoutes.post('/api/classification/reconcile', async (c) => {
  // ❌ No check that the caller has L3 (auditor) role

Minimum fix for L2/L3 (using the existing role field on tenant_users):

const caller = c.get('callerContext');
if (!['auditor', 'admin', 'owner'].includes(caller.role)) {
  return c.json({ error: 'L2 classification requires auditor role or above' }, 403);
}

L4 (COA governance) should similarly gate POST /api/coa and PATCH /api/coa/:id to owner or admin only.


Critical: Cross-Tenant Audit Leak

getClassificationAudit fetches by transactionId only — no tenantId filter:

// server/storage/system.ts
async getClassificationAudit(transactionId: string) {
  return this.db
    .select()
    .from(schema.classificationAudit)
    .where(eq(schema.classificationAudit.transactionId, transactionId));
    // ❌ No tenantId filter
}

If Tenant A knows a valid transaction UUID from Tenant B, they can read its full audit trail. Add tenantId to the filter — the route already has it available:

// route
const audit = await storage.getClassificationAudit(transactionId, tenantId);

// storage
async getClassificationAudit(transactionId: string, tenantId: string) {
  return this.db.select().from(schema.classificationAudit)
    .where(and(
      eq(schema.classificationAudit.transactionId, transactionId),
      eq(schema.classificationAudit.tenantId, tenantId),
    ))
    .orderBy(desc(schema.classificationAudit.createdAt));
}

High: updateChartOfAccount Has No Tenant Scope Check

The PATCH handler passes id directly to updateChartOfAccount, which will match any row — including global accounts (tenantId IS NULL) or accounts owned by other tenants. A caller who discovers a global account UUID could modify it for everyone.

// storage — current behavior:
async updateChartOfAccount(id: string, data: ...) {
  // ❌ updates any row with that id, regardless of ownership
  .where(eq(schema.chartOfAccounts.id, id))

Fix: add a tenant scope to the WHERE, and verify the returned row isn't a global account:

async updateChartOfAccount(id: string, tenantId: string, data: ...) {
  const [row] = await this.db
    .update(schema.chartOfAccounts)
    .set({ ...data, updatedAt: new Date() })
    .where(and(eq(schema.chartOfAccounts.id, id), eq(schema.chartOfAccounts.tenantId, tenantId)))
    .returning();
  return row; // undefined if not found or not owned
}

High: N+1 Queries in batch-suggest

The loop issues ~3 DB round-trips per transaction (getTransaction + update + insert audit). For the default limit of 100, that's 300 queries:

for (const tx of txns) {
  if (tx.suggestedCoaCode) continue;
  const code = findAccountCode(tx.description, tx.category ?? undefined);
  await storage.classifyTransaction(tx.id, tenantId, code, { ... }); // 3 queries
}

A single bulk UPDATE + bulk INSERT into classification_audit would be far more efficient. Since keyword matching is deterministic, you don't need a per-row read:

const toUpdate = txns
  .filter(tx => !tx.suggestedCoaCode)
  .map(tx => ({ id: tx.id, code: findAccountCode(tx.description, tx.category ?? undefined) }));

// One UPDATE per distinct code value (small number), or use a CASE expression

At minimum, cap the route's maximum limit and document the current cost. Also worth noting: batch-suggest is silent in the ledger — unlike classify and reconcile, it emits no ledgerLog. This makes bulk auto-classification invisible in audit systems, which undermines the trust-path design.


Medium: Suggestion Confidence Is Not Differentiated

Both import and batch-suggest assign 0.700 for any non-suspense match, regardless of whether the match came from an exact category hit or a loose description substring match. A category match is meaningfully more reliable:

// database/chart-of-accounts.ts — findAccountCode
// Category match (line ~253): exact, high confidence
// Description keyword match (line ~260): substring, lower confidence

// Suggested fix in callers:
const categoryCode = TURBOTENANT_CATEGORY_MAP[category ?? ''];
const code = categoryCode ?? findAccountCode(description, category);
const confidence = categoryCode
  ? '0.850'  // exact category hit
  : (code === '9010' ? '0.100' : '0.650');  // keyword vs. suspense

Medium: No COA Code Validation on L1 Suggest

POST /api/classification/suggest stores coaCode as suggested_coa_code without verifying the code exists. The L2 classify endpoint does validate — the L1 endpoint should too (or at least verify it's in the 80 global accounts).


Medium: Use Drizzle's or() Instead of Raw SQL Template

getChartOfAccounts uses a raw SQL fragment where Drizzle provides a type-safe combinator:

// Current (works, but opaque to the query planner type system):
.where(sql`${schema.chartOfAccounts.tenantId} = ${tenantId} OR ${schema.chartOfAccounts.tenantId} IS NULL`)

// Preferred (consistent with the rest of the file):
import { or } from 'drizzle-orm';
.where(or(eq(schema.chartOfAccounts.tenantId, tenantId), isNull(schema.chartOfAccounts.tenantId)))

Low: TOCTOU Race in classifyTransaction

The reconcile guard reads the transaction, checks tx.reconciled, then issues a separate UPDATE. Under concurrent requests, two L2 calls on the same transaction could both pass the guard and write conflicting classifications. Moving the reconcile check into the WHERE clause of the UPDATE would make it atomic:

.where(and(
  eq(schema.transactions.id, txId),
  eq(schema.transactions.tenantId, tenantId),
  eq(schema.transactions.reconciled, false), // guard in DB, not application
))
// Then check rows affected — 0 means either not found or already reconciled

Low: No Upper Bound on limit Parameter

Both /api/classification/unclassified and /api/classification/batch-suggest accept an unbounded limit from the query string. Consider capping at a reasonable maximum (e.g. 500) to prevent runaway queries:

const limit = Math.min(parseInt(c.req.query('limit') || '50', 10), 500);

Summary

Issue Severity File
Trust level not enforced (any user can reconcile / govern COA) Critical routes/classification.ts
Cross-tenant audit trail leak (no tenantId filter) Critical storage/system.ts
updateChartOfAccount lacks tenant scope High storage/system.ts
N+1 queries in batch-suggest + no ledger audit High routes/classification.ts
Suggestion confidence not differentiated Medium routes/classification.ts, routes/import.ts
No COA code validation on L1 suggest Medium routes/classification.ts
Raw SQL template instead of or() Medium storage/system.ts
TOCTOU race in reconcile guard Low storage/system.ts
Unbounded limit parameter Low routes/classification.ts

The two critical and one high security issues should be addressed before merge. Happy to look at a revised diff.

🤖 Generated with Claude Code

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1415924c90

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/routes/classification.ts`:
- Around line 109-130: The suggest route missing a catch for errors from
storage.classifyTransaction leads to inconsistent responses; wrap the call in a
try/catch in the POST /api/classification/suggest handler (the function handling
classificationRoutes.post('/api/classification/suggest')) and mirror the sibling
handlers (classify and reconcile): if storage.classifyTransaction throws the
"locked transaction" error (same sentinel/message/type they check for), return
c.json({ error: 'Transaction locked' }, 403); otherwise rethrow or pass the
error to the global handler so other failures remain unchanged.
- Around line 23-84: The POST /api/coa and PATCH /api/coa/:id handlers
(classificationRoutes.post and classificationRoutes.patch) are missing an L4
trust-level guard; add a check at the start of each handler to enforce the
advertised "L4 only" gate (e.g., read the request context like
c.get('trustLevel') or call the existing trust-check helper/middleware and
return a 403 if the caller is not L4), or wire an existing
ensureL4/requireTrustLevel middleware to these routes so only L4-trusted users
can run storage.createChartOfAccount and storage.updateChartOfAccount and
perform ledgerLog.
- Around line 101-106: The unvalidated `limit` query in the route handler for
classificationRoutes.get('/api/classification/unclassified') (and the similar
handler around lines 200-205) can be NaN, negative, or huge; use a Zod schema
from `@shared/schema` to parse and validate the query (e.g., an integer with min
and max bounds and a default) and replace the current parseInt usage with the
validated/clamped value before calling
storage.getUnclassifiedTransactions(tenantId, limit); ensure the handler uses
the schema.parse or safeParse result and falls back to the default or returns a
400 on invalid input so the limit passed into getUnclassifiedTransactions is
always a sane integer.

In `@server/storage/system.ts`:
- Around line 927-1011: Both classifyTransaction and reconcileTransaction
perform an update to schema.transactions and then insert into
schema.classificationAudit as separate statements; wrap each method's DB
operations in a single this.db.transaction(...) so the update and the insert run
inside the same transaction and will commit or roll back together. Specifically,
in classifyTransaction (handling both opts.isSuggestion and the authoritative
branch) wrap the update to schema.transactions and the subsequent
this.db.insert(schema.classificationAudit) call in one transaction callback, and
likewise in reconcileTransaction wrap the update to schema.transactions and the
subsequent audit insert in one transaction; keep all existing field values and
error handling but move the inserts/updates into the same transactional block to
ensure atomicity.
- Around line 1031-1036: The getClassificationAudit method currently only
filters by transactionId and must be tenant-scoped: change the signature of
getClassificationAudit(transactionId: string) to include tenantId (e.g.,
getClassificationAudit(transactionId: string, tenantId: string)), update the
query on schema.classificationAudit to add a where clause for
eq(schema.classificationAudit.tenantId, tenantId) alongside the existing
transactionId filter, and update any callers/routes that invoke
getClassificationAudit to pass the tenantId from the incoming request so the
audit lookup returns only tenant-scoped records.
- Around line 916-922: The updateChartOfAccount method currently updates rows by
id only; change its signature to accept tenantId (e.g., tenantId: string | null)
and add a tenant-scoping condition to the UPDATE WHERE clause on
schema.chartOfAccounts.tenantId so only the matching tenant (or a NULL tenantId
when appropriate) can be updated; ensure you handle the NULL case explicitly
(tenantId IS NULL) rather than comparing to the string "null", and keep
returning the updated row as before from updateChartOfAccount.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 61e5e726-6211-416f-95ca-e3289bdc587b

📥 Commits

Reviewing files that changed from the base of the PR and between 62cfd87 and 1415924.

📒 Files selected for processing (4)
  • server/app.ts
  • server/routes/classification.ts
  • server/routes/import.ts
  • server/storage/system.ts

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Chart of Accounts (COA) + transaction classification workflow to the server, including tenant/global COA retrieval, classification/reconciliation operations, and automatic COA suggestions during TurboTenant imports.

Changes:

  • Extends SystemStorage with COA CRUD + classification/audit/stats helpers.
  • Introduces new /api/coa and /api/classification/* routes to drive the trust-path workflow.
  • Auto-suggests suggested_coa_code (and confidence) during TurboTenant CSV import, and wires the new routes into the app + protected middleware prefixes.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.

File Description
server/storage/system.ts Adds storage methods for COA queries/mutations and classification/reconciliation/audit/statistics operations.
server/routes/import.ts Adds keyword-based COA auto-suggestion fields during TurboTenant transaction ingestion.
server/routes/classification.ts New route group implementing COA endpoints and classification workflow endpoints.
server/app.ts Registers the new route group and ensures /api/coa + /api/classification are protected by auth+tenant middleware.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@chitcommit chitcommit enabled auto-merge (squash) April 10, 2026 19:26
Addresses CodeRabbit/Copilot review comments:

- POST/PATCH /api/coa now require L4 (owner/admin) role via
  getUserRoleForTenant — enforces the advertised trust gate
- updateChartOfAccount now tenant-scoped (prevents cross-tenant writes
  and protects global templates from edits)
- getClassificationAudit now tenant-scoped (prevents cross-tenant audit
  trail leakage)
- classifyTransaction and reconcileTransaction wrap update + audit
  insert in db.transaction() for atomicity
- POST /api/classification/suggest wrapped in try/catch to mirror
  classify/reconcile sibling handlers
- All query params and bodies validated via Zod (limit bounded 1-500,
  transactionId required UUID, COA create/update schemas)
- batch-suggest continues past locked rows instead of failing hard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@claude
Copy link
Copy Markdown

claude bot commented Apr 10, 2026

Code Review: COA Classification API + Auto-Suggest on Import

Good foundational work on the trust-path classification system. The architecture aligns well with the L0–L4 levels documented in CLAUDE.md, and the atomic transaction pattern in classifyTransaction is the right call. A few issues need addressing before merge.


🔴 Security / Auth Issues

1. L2 and L3 operations have no role enforcement

/api/classification/classify (L2) and /api/classification/reconcile (L3) accept requests from any authenticated user. Per the trust-path table in CLAUDE.md, L2 is an "Executor" operation and L3 is "Auditor" — but right now any viewer can classify or reconcile a transaction. The requireL4 guard exists for COA mutations, but the same pattern is missing for classify/reconcile.

Suggested fix — add a requireMinRole helper analogous to requireL4:

const CLASSIFY_ROLES = new Set(['owner', 'admin', 'manager']);
const RECONCILE_ROLES = new Set(['owner', 'admin']);

And call it at the top of each handler.

2. getClassificationAudittransactionId param not UUID-validated

GET /api/classification/audit/:transactionId passes the raw route param directly to the DB without format validation. Add a UUID parse before the query:

const txIdParsed = z.string().uuid().safeParse(c.req.param('transactionId'));
if (!txIdParsed.success) return c.json({ error: 'invalid_transaction_id' }, 400);

3. requireL4 uses any type

async function requireL4(c: any): Promise<Response | null>

This drops type safety. Should be Context<HonoEnv> (import Context from 'hono').

4. POST /api/classification/suggest doesn't validate the COA code exists

/suggest writes any string to suggested_coa_code with no existence check. /classify correctly validates coaCode against the COA table; suggest should do the same (or at least validate format against a regex like /^\d{4}$/).


🟡 Bug: Partial Number() conversion on stats

In the stats route:

const total = Number(stats.total);
const classified = Number(stats.classified);
return c.json({
  ...stats,                    // ← spreads raw bigint strings for `reconciled` and `suggested`
  unclassified: total - classified,
  classifiedPct: ...
});

PostgreSQL count() returns bigint strings. reconciled and suggested are spread from stats as-is before conversion, so clients will receive strings like "42" for those two fields. Either convert all four or use sql<string> and convert explicitly.


🟡 Race condition in classifyTransaction

The method reads tx first, checks tx.reconciled, then opens a transaction and updates. In a concurrent environment, the row can be reconciled between the read and the update, so the guard is not atomic.

Fix: add a WHERE reconciled IS NOT TRUE clause to the update and check rowCount:

const result = await trx
  .update(schema.transactions)
  .set(updateSet)
  .where(and(
    eq(schema.transactions.id, txId),
    eq(schema.transactions.tenantId, tenantId),
    // Prevents the TOCTOU race
    sql`${schema.transactions.reconciled} IS NOT TRUE`,
  ))
  .returning();
if (result.length === 0) throw new Error('Transaction is reconciled — only L3/L4 can modify');

🟡 Audit action is 'classify' / 'reclassify' for L1 suggestions

When isSuggestion: true, the audit trail still records action: 'classify' or 'reclassify'. This makes it impossible to distinguish "user suggested via L1" from "executor classified via L2" in the audit log. Consider a 'suggest' action for L1 writes.


🔵 Nit: limitQuerySchema and batchLimitQuerySchema are identical

Both are z.coerce.number().int().min(1).max(500) with different defaults. Define one and just vary the default:

const paginationLimit = (def: number) =>
  z.coerce.number().int().min(1).max(500).default(def);

🔵 Nit: Missing pagination on /api/classification/unclassified

With a 500-transaction ceiling and no cursor, a tenant with thousands of unclassified transactions can only ever see the newest 500. An offset query param or cursor would be easy to add now while the shape is still fresh.


✅ What's good

  • Atomic db.transaction() wrapping update + audit insert is exactly right.
  • getChartOfAccountByCode correctly prioritizes tenant-specific over global with two targeted queries.
  • batch-suggest correctly skips transactions that already have a suggestion (if (tx.suggestedCoaCode) continue).
  • Zod validation on all request bodies/query params throughout.
  • ledgerLog calls on L2/L3/L4 mutations for the audit trail — consistent with the rest of the codebase.
  • 212 tests passing is a good sign.

The security gaps on L2/L3 role enforcement and the stats bug are the blockers. The race condition is worth fixing now since it's a two-line change. Happy to re-review after those are addressed.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 10, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Examine the new classification routes file
echo "=== classification.ts ==="
cat server/routes/classification.ts 2>/dev/null || fd -i "classification.ts" --exec cat {}

echo ""
echo "=== system.ts (new methods) ==="
cat server/storage/system.ts 2>/dev/null || fd -i "system.ts" -p "storage" --exec cat {}

echo ""
echo "=== import.ts changes ==="
cat server/routes/import.ts 2>/dev/null || fd -i "import.ts" -p "routes" --exec cat {}

echo ""
echo "=== app.ts ==="
cat server/app.ts 2>/dev/null || fd -i "app.ts" -p "server" --exec cat {}

echo ""
echo "=== package.json for new dependencies ==="
cat package.json 2>/dev/null

echo ""
echo "=== Look for findAccountCode implementation ==="
rg -n "findAccountCode" --type ts -C5

echo ""
echo "=== Check auth middleware ==="
rg -n "authAndContext\|tenantMiddleware\|auth" server/app.ts -C3 2>/dev/null

echo ""
echo "=== Check for any hardcoded credentials or secrets ==="
rg -n "password|secret|apikey|api_key|token|credential" --type ts -i -g "!**/*.test.*" -g "!**/node_modules/**" server/

echo ""
echo "=== Check for SQL injection risks ==="
rg -n "raw\|sql\`\|execute\|query(" --type ts server/ -C3

echo ""
echo "=== Check classification route input validation ==="
rg -n "zod\|z\.\|validate\|schema" server/routes/classification.ts -C3 2>/dev/null

echo ""
echo "=== Check for rate limiting ==="
rg -n "rateLimit\|throttle\|rate.limit" --type ts server/ -C3

echo ""
echo "=== Check git diff for new dependencies ==="
git diff HEAD~1..HEAD -- package.json package-lock.json 2>/dev/null || git log --oneline -5

Length of output: 106397


🏁 Script executed:

#!/bin/bash
echo "=== Check if PATCH /api/transactions checks reconciled flag ==="
rg -n "reconcil" server/routes/transactions.ts -C3 2>/dev/null || fd -i "transactions.ts" -p "routes" --exec rg -n "reconcil" {} -C3

echo ""
echo "=== Full transactions route PATCH handler ==="
rg -n "patch\|\.patch\|update" server/routes/transactions.ts -i -C5 2>/dev/null || fd -i "transactions.ts" -p "routes" --exec cat {}

echo ""
echo "=== Check findAccountCode fallback/default for unknown input ==="
cat database/chart-of-accounts.ts | grep -A 40 "function findAccountCode"

echo ""
echo "=== Check if there's any input sanitization on description before findAccountCode ==="
rg -n "sanitize\|escape\|strip" --type ts server/ -C2

echo ""
echo "=== Check if batch-suggest has any logging on error ==="
cat server/routes/classification.ts | grep -A 10 "batch-suggest\|catch"

Length of output: 6425


@github-actions[bot] — here is the full security-focused evaluation of PR #86.


🔐 Security Implications

🔴 Critical — Reconcile Lock Bypass via PATCH /api/transactions

The entire trust-path model relies on reconciled transactions being immutable, but the existing PATCH /api/transactions/:id handler (Line 190 of server/routes/transactions.ts) allows any authenticated user to directly write reconciled: false:

if (body.reconciled !== undefined) updates.reconciled = body.reconciled;

A user can call PATCH /api/transactions/:id with { "reconciled": false } to unlock a reconciled transaction, then re-classify it via L2 — completely bypassing the storage-level guard in classifyTransaction. The L3 enforcement is only in classifyTransaction, not in updateTransaction. This needs to be fixed before merge.

Fix: In updateTransaction (or the PATCH route handler), check the current reconciled state before applying changes. Alternatively, strip reconciled from the allowed fields in the PATCH body schema.


🟠 High — No Role Gate on L2 (Classify) and L3 (Reconcile) Endpoints

requireL4 is correctly applied to COA mutations, but any authenticated tenant member (no role check at all) can call:

  • POST /api/classification/classify — sets authoritative coa_code (L2)
  • POST /api/classification/reconcile — locks a transaction (L3)
  • POST /api/classification/batch-suggest — writes suggestions to up to 500 transactions

A viewer-role user could reconcile financial records. Consider requiring at minimum member or bookkeeper for L2, and owner/admin for L3/reconcile.


🟠 High — PATCH /api/coa/:id Missing UUID Validation on :id

The route parameter id in classificationRoutes.patch('/api/coa/:id', ...) is passed directly to storage.updateChartOfAccount(id, tenantId, ...) with no format validation:

const id = c.req.param('id');
// id is used directly — no UUID check

Add a z.string().uuid() parse on :id consistent with how transactionId is validated elsewhere.


🟡 Medium — POST /api/classification/batch-suggest is a Synchronous DoS Surface

The endpoint runs up to 500 individual DB write transactions in a serial for loop with no concurrency control, no rate-limiting, and no role gate. Any authenticated tenant member can trigger this repeatedly.

for (const tx of txns) {           // up to 500
  await storage.classifyTransaction(...)  // DB write per iteration
}

Recommendation: Add the requireL4 (or at least a bookkeeper role) gate, and consider batching the writes or adding a server-side debounce/lock to prevent concurrent runs.


🟡 Medium — Silent Error Suppression in batch-suggest

} catch {
  // Skip locked/reconciled rows; continue with the rest
  continue;
}

The bare catch swallows all errors silently — including DB connection failures, schema errors, or unexpected throws. This makes operational debugging impossible. Add at minimum console.warn('[batch-suggest] skipped tx:', tx.id, err).


🟡 Medium — requireL4 Uses c: any, Bypassing TypeScript Safety

async function requireL4(c: any): Promise<Response | null>

This should be typed as Context<HonoEnv> to prevent accidental misuse and maintain type safety across the auth helper.


🟡 Medium — Error Response Reflects User-Supplied coaCode with No Length Cap

return c.json({ error: `COA code ${coaCode} not found` }, 400);

coaCode is validated as z.string().min(1) with no maximum length. While the Zod schema prevents empty strings, a very long value will be stored in the audit log and reflected verbatim in the error response. Consider adding .max(20) or similar.


🔑 Credential Exposure Risk

No new credentials or secrets are introduced. All secrets (CHITTY_AUTH_SERVICE_TOKEN, DATABASE_URL, etc.) continue to be accessed via c.env bindings, consistent with the existing pattern. The hardcoded string 'auto:keyword-match' as actorId in batch-suggest is a system actor identifier, not a secret.

✅ No credential exposure risk in this PR.


📦 Dependency Supply Chain Concerns

Zero new dependencies were added. findAccountCode is sourced from the internal database/chart-of-accounts module. All new routes rely on existing locked dependencies (hono, zod, drizzle-orm).

✅ No supply chain risk introduced.


💥 Breaking API Changes

Change Type Risk
7 new nullable columns on transactions table Additive schema Low — nullable, no existing constraint changed
GET /api/transactions now returns new fields (coaCode, suggestedCoaCode, reconciled, etc.) Additive response fields Low for REST; could break strict typed deserialization clients
Reconciled transactions now locked in classifyTransaction Behavioral Medium — pre-existing client workflows calling classify on old reconciled rows will receive 400/403 (though currently bypassable via PATCH)
POST /api/import/turbotenant now auto-populates suggestedCoaCode Behavioral Low — additive fields on created records
New protected prefixes /api/coa, /api/classification Net-new paths None — no existing consumers

The most impactful behavioral change is the reconcile lock — once the PATCH bypass is fixed, existing integrations that modify transactions post-reconciliation will start receiving errors and should be documented in the changelog.


Summary

Severity Issue
🔴 Critical Reconcile lock fully bypassable via PATCH /api/transactions
🟠 High No role gate on L2 classify / L3 reconcile / batch-suggest
🟠 High :id param in PATCH /api/coa/:id not UUID-validated
🟡 Medium batch-suggest DoS — 500 serial writes, no rate limit, no role gate
🟡 Medium Silent catch {} in batch-suggest — zero observability
🟡 Medium requireL4(c: any) — untyped context
🟡 Medium coaCode reflected in error with no max length
✅ None Credential exposure
✅ None Supply chain (zero new deps)
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
server/routes/classification.ts (1)

198-235: ⚠️ Potential issue | 🔴 Critical

Enforce real L2/L3 authorization before these writes.

Both handlers hardcode trustLevel: 'L2' / trustLevel: 'L3' after only checking tenant membership, so any tenant user can authoritatively classify or reconcile a transaction. That bypasses the trust-path model entirely.

Also applies to: 238-263

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/classification.ts` around lines 198 - 235, The handler sets
trustLevel: 'L2' without verifying the caller is authorized to assert L2; before
calling storage.classifyTransaction in
classificationRoutes.post('/api/classification/classify') (and the similar
reconcile handler around lines 238-263), verify the caller's authority to assign
that trust level (e.g., call an existing auth utility or add
authorizeTrustLevel(userId, tenantId, 'L2'|'L3') that returns true/throws) and
return 403 if not authorized; only then pass the validated trustLevel into
storage.classifyTransaction (and storage.reconcileTransaction), otherwise do not
elevate trustLevel based solely on tenant membership.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/routes/classification.ts`:
- Around line 193-195: The three catch blocks in server/routes/classification.ts
(the catches at the shown ranges) currently return 403 with the raw exception
message for every error; change each to only map known domain errors to
appropriate HTTP statuses and user-safe messages (e.g., if (err instanceof
ValidationError) return c.json({ error: "Invalid input" }, 400); if (err
instanceof NotFoundError) return c.json({ error: "Not found" }, 404); if (err
instanceof AuthorizationError) return c.json({ error: "Forbidden" }, 403);), and
for all other/unexpected errors either rethrow/pass to the shared/global error
handler or return a 500 with a generic message (do not include err.message).
Update the catch blocks at the three locations accordingly and reference the
domain error classes/types used across the module to detect known errors.
- Around line 179-189: The /suggest handler is currently accepting any coaCode
from parsed.data and passing it into storage.classifyTransaction (transactionId,
tenantId, coaCode...), which can create unresolvable suggestions; add the same
validation used by the /classify path to verify the submitted coaCode exists in
the tenant/global COA list before calling storage.classifyTransaction. Locate
the handler that reads const { transactionId, coaCode, confidence, reason } =
parsed.data and, before invoking storage.classifyTransaction, call the existing
COA lookup/validation routine (the same helper or storage method / validation
logic used by /classify) and return a 4xx error if the code is unknown; only
call storage.classifyTransaction when validation succeeds.

In `@server/storage/system.ts`:
- Around line 965-998: The audit insert treats suggestion updates as
authoritative; update the logic around updateSet and the classification audit
insert so that when opts.isSuggestion is true you record an audit row that
distinguishes suggestions (e.g., action = 'suggest' or 'suggest_reclassify', or
add a boolean like isSuggestion and a targetField = 'suggestedCoaCode') instead
of 'classify'/'reclassify'; modify the values passed to
trx.insert(schema.classificationAudit).values(...) to include this flag/action
and ensure newCoaCode still reflects coaCode and action determination uses
opts.isSuggestion (and previousCoaCode as needed) so the audit trail accurately
reflects suggestion vs authoritative classification.
- Around line 953-985: The current check of tx.reconciled (from getTransaction)
happens before starting the DB transaction and can be raced; fix by enforcing
the reconciled lock in the atomic update: add a WHERE clause to the
trx.update(...) that requires schema.transactions.reconciled = false (or NOT
reconciled) so the UPDATE fails if the row was reconciled concurrently, and then
check the update result/row count from the trx.update call — if no rows were
updated, throw the same 'Transaction is reconciled — only L3/L4 can modify'
error. Reference getTransaction, tx.reconciled, db.transaction,
trx.update(...).set(updateSet), and schema.transactions in your change.

---

Duplicate comments:
In `@server/routes/classification.ts`:
- Around line 198-235: The handler sets trustLevel: 'L2' without verifying the
caller is authorized to assert L2; before calling storage.classifyTransaction in
classificationRoutes.post('/api/classification/classify') (and the similar
reconcile handler around lines 238-263), verify the caller's authority to assign
that trust level (e.g., call an existing auth utility or add
authorizeTrustLevel(userId, tenantId, 'L2'|'L3') that returns true/throws) and
return 403 if not authorized; only then pass the validated trustLevel into
storage.classifyTransaction (and storage.reconcileTransaction), otherwise do not
elevate trustLevel based solely on tenant membership.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bd124be4-ab2f-4246-95b5-da8f985d6699

📥 Commits

Reviewing files that changed from the base of the PR and between 1415924 and 33dd1d5.

📒 Files selected for processing (2)
  • server/routes/classification.ts
  • server/storage/system.ts

Addresses the 4 post-fix CodeRabbit critical/major findings:

- **Concurrency: reconciled lock bypass (critical)**
  classifyTransaction now enforces the reconciled lock inside the
  UPDATE WHERE clause itself (eq(reconciled, false)) for L0/L1/L2 writes.
  If the row was reconciled between the initial SELECT and the UPDATE,
  the transaction rolls back and a ClassificationError is thrown.
  L3/L4 continue to have unconditional write access.

- **Audit naming: suggestions labeled 'classify' (major)**
  classification_audit.action now uses 'suggest'/'re-suggest' when
  isSuggestion is true, distinguishing L1 writes from authoritative
  L2+ writes. previousCoaCode also tracks the prior suggestion (not
  the authoritative code) for suggestion-path audits.

- **Suggest missing COA validation (major)**
  /api/classification/suggest now validates coaCode against the tenant's
  COA (same check as /classify) to prevent dangling unresolvable suggestions.

- **Error mapping: 403 catch-all (major)**
  Introduced ClassificationError sentinel with codes: reconciled_locked,
  not_classified, transaction_not_found. Routes use mapClassificationError()
  to return stable HTTP responses (403/400/404) and re-throw unknown errors
  to the shared handler so DB/runtime failures surface as 500 instead of
  being masked as 403 with raw exception text.

- batch-suggest now only swallows reconciled_locked errors; other
  failures propagate instead of being silently ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 10, 2026 20:02
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@chitcommit chitcommit merged commit 067c728 into main Apr 10, 2026
10 of 12 checks passed
@chitcommit chitcommit deleted the feat/coa-classification-api branch April 10, 2026 20:02
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 10, 2026

Tip

For best results, initiate chat on the files or code changes.

@github-actions — here is the full security-focused evaluation of PR #86.


🔐 Security Implications

🔴 Critical — Reconcile Lock Bypass via PATCH /api/transactions

The entire trust-path model relies on reconciled transactions being immutable, but the existing PATCH /api/transactions/:id handler allows any authenticated user to directly write reconciled: false:

if (body.reconciled !== undefined) updates.reconciled = body.reconciled;

A user can call PATCH /api/transactions/:id with { "reconciled": false } to unlock a reconciled transaction, then re-classify it via L2 — completely bypassing the storage-level guard in classifyTransaction. The L3 enforcement is only in classifyTransaction, not in updateTransaction. This needs to be fixed before merge.

Fix: In updateTransaction (or the PATCH route handler), check the current reconciled state before applying changes. Alternatively, strip reconciled from the allowed fields in the PATCH body schema.


🟠 High — No Role Gate on L2 (Classify) and L3 (Reconcile) Endpoints

requireL4 is correctly applied to COA mutations, but any authenticated tenant member (no role check at all) can call:

  • POST /api/classification/classify — sets authoritative coa_code (L2)
  • POST /api/classification/reconcile — locks a transaction (L3)
  • POST /api/classification/batch-suggest — writes suggestions to up to 500 transactions

A viewer-role user could reconcile financial records. Consider requiring at minimum member or bookkeeper for L2, and owner/admin for L3/reconcile.


🟠 High — PATCH /api/coa/:id Missing UUID Validation on :id

The route parameter id in classificationRoutes.patch('/api/coa/:id', ...) is passed directly to storage.updateChartOfAccount(id, tenantId, ...) with no format validation:

const id = c.req.param('id');
// id is used directly — no UUID check

Add a z.string().uuid() parse on :id consistent with how transactionId is validated elsewhere.


🟡 Medium — POST /api/classification/batch-suggest is a Synchronous DoS Surface

The endpoint runs up to 500 individual DB write transactions in a serial for loop with no concurrency control, no rate-limiting, and no role gate. Any authenticated tenant member can trigger this repeatedly.

for (const tx of txns) {           // up to 500
  await storage.classifyTransaction(...)  // DB write per iteration
}

Recommendation: Add the requireL4 (or at least a bookkeeper role) gate, and consider batching the writes or adding a server-side debounce/lock to prevent concurrent runs.


🟡 Medium — Silent Error Suppression in batch-suggest

} catch {
  // Skip locked/reconciled rows; continue with the rest
  continue;
}

The bare catch swallows all errors silently — including DB connection failures, schema errors, or unexpected throws. This makes operational debugging impossible. Add at minimum console.warn('[batch-suggest] skipped tx:', tx.id, err).


🟡 Medium — requireL4 Uses c: any, Bypassing TypeScript Safety

async function requireL4(c: any): Promise<Response | null>

This should be typed as Context<HonoEnv> to prevent accidental misuse and maintain type safety across the auth helper.


🟡 Medium — Error Response Reflects User-Supplied coaCode with No Length Cap

return c.json({ error: `COA code ${coaCode} not found` }, 400);

coaCode is validated as z.string().min(1) with no maximum length. While the Zod schema prevents empty strings, a very long value will be stored in the audit log and reflected verbatim in the error response. Consider adding .max(20) or similar.


🔑 Credential Exposure Risk

No new credentials or secrets are introduced. All secrets (CHITTY_AUTH_SERVICE_TOKEN, DATABASE_URL, etc.) continue to be accessed via c.env bindings, consistent with the existing pattern. The hardcoded string 'auto:keyword-match' as actorId in batch-suggest is a system actor identifier, not a secret.

✅ No credential exposure risk in this PR.


📦 Dependency Supply Chain Concerns

Zero new dependencies were added. findAccountCode is sourced from the internal database/chart-of-accounts module. All new routes rely on existing locked dependencies (hono, zod, drizzle-orm).

✅ No supply chain risk introduced.


💥 Breaking API Changes

Change Type Risk
7 new nullable columns on transactions table Additive schema Low — nullable, no existing constraint changed
GET /api/transactions now returns new fields (coaCode, suggestedCoaCode, reconciled, etc.) Additive response fields Low for REST; could break strict typed deserialization clients
Reconciled transactions now locked in classifyTransaction Behavioral Medium — pre-existing client workflows calling classify on old reconciled rows will receive 400/403 (though currently bypassable via PATCH)
POST /api/import/turbotenant now auto-populates suggestedCoaCode Behavioral Low — additive fields on created records
New protected prefixes /api/coa, /api/classification Net-new paths None — no existing consumers

The most impactful behavioral change is the reconcile lock — once the PATCH bypass is fixed, existing integrations that modify transactions post-reconciliation will start receiving errors and should be documented in the changelog.


Summary

Severity Issue
🔴 Critical Reconcile lock fully bypassable via PATCH /api/transactions
🟠 High No role gate on L2 classify / L3 reconcile / batch-suggest
🟠 High :id param in PATCH /api/coa/:id not UUID-validated
🟡 Medium batch-suggest DoS — 500 serial writes, no rate limit, no role gate
🟡 Medium Silent catch {} in batch-suggest — zero observability
🟡 Medium requireL4(c: any) — untyped context
🟡 Medium coaCode reflected in error with no max length
✅ None Credential exposure
✅ None Supply chain (zero new deps)

@claude
Copy link
Copy Markdown

claude bot commented Apr 10, 2026

Code Review — COA Classification API + Auto-Suggest on Import

Overall this is solid, well-structured work. The ClassificationError sentinel, conditional-update pattern for reconcile-lock enforcement, and audit trail inside a single DB transaction are all good patterns. A few issues worth tracking before the next iteration:


Critical — Trust-level role enforcement is missing for L1/L2/L3

File: server/routes/classification.ts

requireL4 is the only role gate in this PR. Per the trust-path table in CLAUDE.md, POST /api/classification/classify (L2) and POST /api/classification/reconcile (L3) should require specific roles too. Right now any authenticated tenant user — including viewer — can set the authoritative coa_code or lock a transaction.

// classify endpoint — no role check before calling classifyTransaction with trustLevel: 'L2'
classificationRoutes.post('/api/classification/classify', async (c) => {
  const storage = c.get('storage');
  // ❌ missing: requireL2(c) or equivalent

Suggested fix: add role guards consistent with the trust model (e.g. manager+ for L2, admin+ for L3).


Bug — Race condition in reconcileTransaction

File: server/storage/system.ts ~line 1075

The not_classified guard is a pre-flight read outside the transaction:

const tx = await this.getTransaction(txId, tenantId);  // read #1 — outside txn
if (!coaCode) { throw new ClassificationError('not_classified', ...) }

await this.db.transaction(async (trx) => {             // update #2 — inside txn
  await trx.update(schema.transactions).set({ reconciled: true, ... })

Between read #1 and the update, a concurrent L2 write could clear coaCode. The fix is the same conditional-update pattern used in classifyTransaction: add isNotNull(schema.transactions.coaCode) to the WHERE clause and check updated.length === 0 inside the transaction.


Type safety — c: any in requireL4 and mapClassificationError

File: server/routes/classification.ts lines 14, 62

Both helpers accept c: any, which defeats Hono's context typing. Use Context<HonoEnv>:

import type { Context } from 'hono';

function mapClassificationError(err: unknown, c: Context<HonoEnv>): Response { ... }
async function requireL4(c: Context<HonoEnv>): Promise<Response | null> { ... }

Bug — reconcileTransaction can double-reconcile silently

File: server/storage/system.ts

The reconcile update has no guard against re-reconciling an already-reconciled transaction. The WHERE clause doesn't include reconciled = false, so calling reconcile twice succeeds both times and writes two audit entries. Add the condition:

.where(and(
  eq(schema.transactions.id, txId),
  eq(schema.transactions.tenantId, tenantId),
  eq(schema.transactions.reconciled, false),  // ← add this
))

Missing input validation — confidence range not enforced

File: server/routes/classification.tsclassifyBodySchema

confidence: z.string().optional(),

This accepts arbitrary strings ("99.999", "abc"). Given the DB column is decimal(4,3), an out-of-range value will either be rejected at the DB layer with an opaque error or silently truncated. Validate it at the Zod layer:

confidence: z.string()
  .regex(/^\d\.\d{1,3}$/)
  .refine(v => +v >= 0 && +v <= 1, 'confidence must be 0.000–1.000')
  .optional(),

Minor — Inconsistent 409 error shape

File: server/routes/classification.ts line 96

return c.json({ error: `Account code ${body.code} already exists for this tenant` }, 409);

All other error responses in this file use { error: string, message: string }. This one puts the message in error. Should be:

return c.json({ error: 'conflict', message: `Account code ${body.code} already exists for this tenant` }, 409);

Minor — getClassificationAudit URL param not validated as UUID

File: server/routes/classification.ts — audit endpoint

transactionId from :transactionId is passed directly to the DB query without UUID format validation. Not a security issue (parameterized query), but a malformed ID will return an empty array silently rather than a 400. Small QoL fix:

if (!z.string().uuid().safeParse(transactionId).success) {
  return c.json({ error: 'invalid_id' }, 400);
}

Performance note — classifyTransaction makes up to 4 DB round-trips

getTransaction (pre-flight) → conditional UPDATE → SELECT (reconcile check on failure) → getTransaction (return value). The final getTransaction round-trip can be eliminated by adding the needed fields to the UPDATE's RETURNING clause. Not urgent, but worth noting for high-volume classification workflows.


Summary: The conditional-update reconcile-lock mechanism is the right approach, and the audit trail within a DB transaction is well done. The two things I'd want addressed before production use are the missing L2/L3 role gates and the race condition in reconcileTransaction. The rest are lower priority.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +896 to +903
// Return tenant-specific overrides + global defaults (tenant_id IS NULL)
return this.db
.select()
.from(schema.chartOfAccounts)
.where(
sql`${schema.chartOfAccounts.tenantId} = ${tenantId} OR ${schema.chartOfAccounts.tenantId} IS NULL`,
)
.orderBy(asc(schema.chartOfAccounts.code));
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getChartOfAccounts() currently returns both tenant-specific rows and global (tenant_id IS NULL) rows, which means if a tenant overrides a global code you’ll return duplicate codes. If callers expect “overrides + defaults”, consider de-duping by code (prefer tenant row) via a UNION/CTE/window function or by querying tenant rows first and merging in code order.

Suggested change
// Return tenant-specific overrides + global defaults (tenant_id IS NULL)
return this.db
.select()
.from(schema.chartOfAccounts)
.where(
sql`${schema.chartOfAccounts.tenantId} = ${tenantId} OR ${schema.chartOfAccounts.tenantId} IS NULL`,
)
.orderBy(asc(schema.chartOfAccounts.code));
// Return the effective chart of accounts for the tenant:
// tenant-specific rows override global defaults with the same code.
const tenantAccounts = await this.db
.select()
.from(schema.chartOfAccounts)
.where(eq(schema.chartOfAccounts.tenantId, tenantId))
.orderBy(asc(schema.chartOfAccounts.code));
const globalAccounts = await this.db
.select()
.from(schema.chartOfAccounts)
.where(isNull(schema.chartOfAccounts.tenantId))
.orderBy(asc(schema.chartOfAccounts.code));
const tenantCodes = new Set(tenantAccounts.map((account) => account.code));
return [...tenantAccounts, ...globalAccounts.filter((account) => !tenantCodes.has(account.code))].sort(
(a, b) => a.code.localeCompare(b.code),
);

Copilot uses AI. Check for mistakes.
Comment on lines +971 to +999
const tx = await this.getTransaction(txId, tenantId);
if (!tx) return undefined;

const previousCoaCode = tx.coaCode;
const previousSuggested = tx.suggestedCoaCode;
const now = new Date();

// Decide which fields to update
const updateSet = opts.isSuggestion
? {
// L1: write to suggested_coa_code only
suggestedCoaCode: coaCode,
classificationConfidence: opts.confidence ?? null,
updatedAt: now,
}
: {
// L2+: write to authoritative coa_code
coaCode,
classifiedBy: opts.actorId,
classifiedAt: now,
classificationConfidence: opts.confidence ?? null,
updatedAt: now,
};

// Reconciled lock: L0/L1/L2 writes must reject rows that were reconciled
// between the initial SELECT and this UPDATE. We enforce this inside the
// WHERE clause of the UPDATE itself (conditional update) so concurrent
// reconciliations can't race us.
const canWriteReconciled = opts.trustLevel === 'L3' || opts.trustLevel === 'L4';
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

classifyTransaction() reads the transaction (and captures previousCoaCode/previousSuggested) outside the DB transaction that performs the UPDATE + audit INSERT. If the transaction is modified concurrently (e.g., another classification), the audit trail can record an incorrect previousCoaCode/action for this write. To keep the audit accurate, fetch the current row inside the same transaction (optionally with row locking) and derive previous*/action from that snapshot.

Suggested change
const tx = await this.getTransaction(txId, tenantId);
if (!tx) return undefined;
const previousCoaCode = tx.coaCode;
const previousSuggested = tx.suggestedCoaCode;
const now = new Date();
// Decide which fields to update
const updateSet = opts.isSuggestion
? {
// L1: write to suggested_coa_code only
suggestedCoaCode: coaCode,
classificationConfidence: opts.confidence ?? null,
updatedAt: now,
}
: {
// L2+: write to authoritative coa_code
coaCode,
classifiedBy: opts.actorId,
classifiedAt: now,
classificationConfidence: opts.confidence ?? null,
updatedAt: now,
};
// Reconciled lock: L0/L1/L2 writes must reject rows that were reconciled
// between the initial SELECT and this UPDATE. We enforce this inside the
// WHERE clause of the UPDATE itself (conditional update) so concurrent
// reconciliations can't race us.
const canWriteReconciled = opts.trustLevel === 'L3' || opts.trustLevel === 'L4';
const now = new Date();
return await this.db.transaction(async (trx) => {
const [tx] = await trx
.select({
coaCode: schema.transactions.coaCode,
suggestedCoaCode: schema.transactions.suggestedCoaCode,
})
.from(schema.transactions)
.where(and(
eq(schema.transactions.id, txId),
eq(schema.transactions.tenantId, tenantId),
))
.limit(1);
if (!tx) return undefined;
const previousCoaCode = tx.coaCode;
const previousSuggested = tx.suggestedCoaCode;
// Decide which fields to update
const updateSet = opts.isSuggestion
? {
// L1: write to suggested_coa_code only
suggestedCoaCode: coaCode,
classificationConfidence: opts.confidence ?? null,
updatedAt: now,
}
: {
// L2+: write to authoritative coa_code
coaCode,
classifiedBy: opts.actorId,
classifiedAt: now,
classificationConfidence: opts.confidence ?? null,
updatedAt: now,
};
// Reconciled lock: L0/L1/L2 writes must reject rows that were reconciled
// after the in-transaction snapshot and before this UPDATE. We enforce
// this inside the WHERE clause of the UPDATE itself (conditional update)
// so concurrent reconciliations can't race us.
const canWriteReconciled = opts.trustLevel === 'L3' || opts.trustLevel === 'L4';

Copilot uses AI. Check for mistakes.
Comment on lines +1073 to +1077
await trx
.update(schema.transactions)
.set({ reconciled: true, reconciledBy: actorId, reconciledAt: now, updatedAt: now })
.where(and(eq(schema.transactions.id, txId), eq(schema.transactions.tenantId, tenantId)));

Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reconcileTransaction() unconditionally updates the row and always inserts a new audit record. Calling reconcile twice (or racing requests) will generate duplicate 'reconcile' audit entries and re-write reconciledBy/reconciledAt. Consider making this a conditional update (e.g., WHERE reconciled=false) and returning a stable error (or no-op) when the transaction is already reconciled.

Suggested change
await trx
.update(schema.transactions)
.set({ reconciled: true, reconciledBy: actorId, reconciledAt: now, updatedAt: now })
.where(and(eq(schema.transactions.id, txId), eq(schema.transactions.tenantId, tenantId)));
const updatedRows = await trx
.update(schema.transactions)
.set({ reconciled: true, reconciledBy: actorId, reconciledAt: now, updatedAt: now })
.where(and(
eq(schema.transactions.id, txId),
eq(schema.transactions.tenantId, tenantId),
eq(schema.transactions.reconciled, false),
))
.returning({ id: schema.transactions.id });
if (updatedRows.length === 0) {
return;
}

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +150
const account = await storage.updateChartOfAccount(id, tenantId, {
...parsed.data,
modifiedBy: userId,
});

Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PATCH /api/coa/:id doesn’t handle uniqueness conflicts when updating code (there is a unique index on (tenant_id, code)). If a client changes the code to one that already exists for the tenant, this will likely surface as a 500 from the DB. Consider pre-checking for an existing code (excluding this id) or catching the constraint error and returning a 409 with a stable error shape.

Copilot uses AI. Check for mistakes.
Comment on lines +308 to +312
const code = findAccountCode(tx.description, tx.category ?? undefined);
try {
await storage.classifyTransaction(tx.id, tenantId, code, {
actorId: 'auto:keyword-match',
actorType: 'system',
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

batch-suggest performs a per-transaction classifyTransaction() call in a sequential loop; with limit up to 500 this can translate into hundreds of DB transactions and round-trips, increasing latency and timeout risk. Consider batching (single transaction for the whole batch, or bulk UPDATE + bulk INSERT audit) and/or running updates with a small concurrency limit.

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +79
// GET /api/coa — list Chart of Accounts for current tenant (includes global defaults)
classificationRoutes.get('/api/coa', async (c) => {
const storage = c.get('storage');
const tenantId = c.get('tenantId');
const accounts = await storage.getChartOfAccounts(tenantId);
return c.json(accounts);
});
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces a new route group with multiple behaviors (COA CRUD, suggest/classify/reconcile, batch-suggest, stats/audit) but there are no new tests covering these endpoints. The repo already has route-level tests using mocked storage (e.g., server/tests/routes-tenants.test.ts, routes-consumer-contract.test.ts); adding similar tests here would help prevent regressions and validate error mapping/role enforcement.

Copilot uses AI. Check for mistakes.
Comment on lines +893 to +897
// ── CHART OF ACCOUNTS ──

async getChartOfAccounts(tenantId: string) {
// Return tenant-specific overrides + global defaults (tenant_id IS NULL)
return this.db
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SystemStorage gained substantial new COA/classification logic (COA listing/CRUD, trust-path classify/reconcile, stats/audit queries) but existing SystemStorage tests only cover basic construction/getAccounts/getSummary. Adding unit tests for the new methods (especially reconciled locking behavior and audit writes) would improve confidence and guard against subtle regressions.

Copilot uses AI. Check for mistakes.
chitcommit added a commit that referenced this pull request Apr 10, 2026
Frontend for the trust-path classification workflow added in #86/#87.

New: client/src/pages/Classification.tsx
- Stats cards (total, classified, reconciled, unclassified + %)
- Unclassified queue sorted by date/amount/confidence (ascending or
  descending)
- Per-row actions: accept suggested code (one-click), classify as any
  COA code (dropdown), reconcile (L3 lock)
- Three bulk actions:
  - Keyword Suggest: runs /api/classification/batch-suggest (L1)
  - AI Suggest (GPT): runs /api/classification/ai-suggest (L1, 25 at a time)
  - Accept High-Confidence: writes authoritative coa_code (L2) for rows
    passing the bulkAcceptCandidates guardrails

bulkAcceptCandidates rules (defense in depth):
- Confidence >= 0.8 (AI "success" threshold)
- suggestedCoaCode !== '9010' (suspense means "needs a human")
- |amount| <= $500 (large-dollar → manual review regardless of confidence)

New: client/src/hooks/use-classification.ts
- TanStack Query hooks scoped by tenantId for automatic cache invalidation
  on tenant switch
- Queries: coa, stats, unclassified, audit trail
- Mutations: classify (L2), suggest (L1 manual), reconcile (L3),
  batch-suggest (L1 keyword), ai-suggest (L1 GPT)

Wiring:
- /classification route added to App.tsx
- Sidebar link added (cfo, accountant, bookkeeper roles)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chitcommit added a commit that referenced this pull request Apr 10, 2026
* feat: AI-assisted COA classification with GPT-4o-mini

Adds a new POST /api/classification/ai-suggest endpoint that batches
up to 25 unclassified transactions through GPT-4o-mini with structured
JSON output, falling back to keyword matching on any failure.

Library (server/lib/classification-ai.ts):
- classifyBatchWithAI() — per-request OpenAI client (Workers-safe)
- Validates AI-returned codes against the tenant's COA (rejects hallucinations)
- Clamps confidence to [0, 1]
- Always returns one suggestion per input (gap-fills with keyword fallback)
- Respects CF AI Gateway via AI_GATEWAY_ENDPOINT env

Route:
- Only processes transactions without an existing suggested_coa_code
  (respects prior human/auto work)
- Writes via L1 suggest (never authoritative coa_code)
- Returns {processed, suggested, aiCount, keywordCount, aiAvailable}

Tests (9 new):
- Empty input, missing key, API error, malformed JSON, missing choices
- Confidence clamping, hallucinated-code rejection, gap-filling
- Batch size guard (MAX_BATCH=25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: PR #87 review feedback — docstring, silent catch, processed field

- Docstring now correctly describes gpt-4o-mini default (was: GPT-4o)
- Remove unused `date` field from ClassifiableTransaction interface
- ai-suggest only swallows ClassificationError.reconciled_locked;
  DB/runtime failures now propagate to the shared error handler
  instead of being silently skipped
- `processed` field is now consistent: all branches return txns.length
  (count of transactions fetched and considered), matching the semantics
  of the early-return paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: classification review page with bulk-accept guardrails

Frontend for the trust-path classification workflow added in #86/#87.

New: client/src/pages/Classification.tsx
- Stats cards (total, classified, reconciled, unclassified + %)
- Unclassified queue sorted by date/amount/confidence (ascending or
  descending)
- Per-row actions: accept suggested code (one-click), classify as any
  COA code (dropdown), reconcile (L3 lock)
- Three bulk actions:
  - Keyword Suggest: runs /api/classification/batch-suggest (L1)
  - AI Suggest (GPT): runs /api/classification/ai-suggest (L1, 25 at a time)
  - Accept High-Confidence: writes authoritative coa_code (L2) for rows
    passing the bulkAcceptCandidates guardrails

bulkAcceptCandidates rules (defense in depth):
- Confidence >= 0.8 (AI "success" threshold)
- suggestedCoaCode !== '9010' (suspense means "needs a human")
- |amount| <= $500 (large-dollar → manual review regardless of confidence)

New: client/src/hooks/use-classification.ts
- TanStack Query hooks scoped by tenantId for automatic cache invalidation
  on tenant switch
- Queries: coa, stats, unclassified, audit trail
- Mutations: classify (L2), suggest (L1 manual), reconcile (L3),
  batch-suggest (L1 keyword), ai-suggest (L1 GPT)

Wiring:
- /classification route added to App.tsx
- Sidebar link added (cfo, accountant, bookkeeper roles)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants