Skip to content

feat: Phase 5 ecosystem integration (5 features)#92

Merged
chitcommit merged 1 commit intomainfrom
feat/phase-5-ecosystem-integration
Apr 13, 2026
Merged

feat: Phase 5 ecosystem integration (5 features)#92
chitcommit merged 1 commit intomainfrom
feat/phase-5-ecosystem-integration

Conversation

@chitcommit
Copy link
Copy Markdown
Contributor

@chitcommit chitcommit commented Apr 13, 2026

Summary

5 independent features completing the ChittyOS ecosystem integration for ChittyFinance's COA trust-path system.

Features

1. Reconciled-row visibility

Endpoint Method Purpose
/api/classification/reconciled GET List locked L3 transactions
/api/classification/unreconcile POST L3 unlock with full audit trail

Classification page gains a Queue / Reconciled tab switcher. Reconciled tab shows each locked row with COA code, reconciled-by, and an Unlock button.

2. Wave webhook real-time classification

POST /api/webhooks/wave — identical flow to Mercury webhook (#90): service-auth, zod envelope, KV dedup (7-day TTL), keyword auto-classification, ChittySchema advisory, DB persist. externalId = wave:{waveTransactionId}.

3. ChittyChronicle audit logging

New server/lib/chittychronicle.ts — fire-and-forget, Workers-native. Posts events to chronicle.chitty.cc/api/events. Falls open on error (404 expected until Chronicle deploys ingestion routes). Wired into: classify (L2), reconcile (L3), unreconcile (L3), COA create (L4).

4. Schema registry payload

database/schema-registry-payload.json — complete JSON schema for chart_of_accounts and classification_audit. Ready for operator to register with schema.chitty.cc once the chittyfinance database namespace is added. Includes all columns, types, constraints, indexes, and enums.

5. ChittyID SSO as default auth

Login page redesigned: ChittyID is the primary action (lime green button, full width, "Recommended" label). Email/password collapsed by default behind a "Use email & password instead" link. Auto-expands on ChittyID errors so users fall back gracefully. Email sign-in button visually demoted to outline style.

Test plan

  • TypeScript check passes (0 errors)
  • 253 tests pass (no regressions)
  • Vite build succeeds
  • Manual: /classification → Reconciled tab → verify locked rows display
  • Manual: /classification → Reconciled → Unlock → verify row moves back to Queue
  • Manual: POST /api/webhooks/wave with test payload → verify 201 with suggestedCoaCode
  • Manual: Check console for [chittychronicle] warnings (expected 404 until API deploys)
  • Manual: /login → verify ChittyID button is primary, email form is collapsed

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Added "Reconciled" transactions tab with ability to unlock reconciled transactions
    • Enhanced login flow with improved SSO presentation and conditional email fallback option
    • Added Wave Accounting webhook integration support
    • Added comprehensive audit trail for transaction classifications and chart of accounts modifications

Bundles 5 independent features that complete the ChittyOS ecosystem
integration for ChittyFinance's COA trust-path system.

### 1. Reconciled-row visibility (#11)
- GET /api/classification/reconciled — list locked L3 transactions
- POST /api/classification/unreconcile — L3 unlock with audit trail
- Classification page: Queue/Reconciled tab switcher, Unlock button,
  reconciled-by metadata display

### 2. Wave webhook real-time classification (#12)
- POST /api/webhooks/wave — same flow as Mercury webhook: service-auth,
  zod envelope, KV dedup (7-day TTL), keyword auto-classification,
  ChittySchema advisory validation, DB persist with suggestedCoaCode
- externalId = wave:{waveTransactionId}

### 3. ChittyChronicle audit logging (#13)
- New: server/lib/chittychronicle.ts — fire-and-forget Workers-native client
- logToChronicle() POSTs to chronicle.chitty.cc/api/events (404 expected
  until Chronicle deploys its ingestion routes — client falls open)
- logClassificationEvent() + logCoaEvent() convenience helpers
- Wired into: classify (L2), reconcile (L3), unreconcile (L3), COA create (L4)
- Never blocks responses; errors logged and swallowed

### 4. Schema registry payload (#14)
- database/schema-registry-payload.json — complete JSON schema for
  chart_of_accounts and classification_audit tables
- Ready for operator to POST to schema.chitty.cc when the chittyfinance
  database namespace is added (no self-registration API exists)

### 5. ChittyID SSO as default auth (#15)
- Login page: ChittyID button is now primary (lime green, full width)
  with "Recommended" label
- Email/password collapsed by default behind "Use email & password
  instead" link
- Auto-expands on ChittyID errors (auth_unavailable, token_exchange,
  no_account) so users can fall back gracefully
- Email sign-in button visually demoted (outline style vs filled primary)

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

@coderabbitai review

Please evaluate:

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR introduces reconciliation state management for classified transactions, adds audit event logging infrastructure (ChittyChronicle), implements Wave Accounting webhook ingestion, expands the Classification page with a reconciled transactions view, updates database schema definitions, and refines the Login UI with conditional email form visibility.

Changes

Cohort / File(s) Summary
Client Reconciliation Feature
client/src/hooks/use-classification.ts, client/src/pages/Classification.tsx
Added hooks useReconciledTransactions() and useUnreconcileTransaction() for fetching and managing reconciled transactions. Updated Classification page with tab-based UI switching between queue and reconciled views, including per-transaction unlock actions and separate loading/empty states.
Server Reconciliation & Storage
server/storage/system.ts, server/routes/classification.ts
Added storage methods getReconciledTransactions() and unreconciledTransaction() for reconciliation state management. Added endpoints GET /api/classification/reconciled and POST /api/classification/unreconcile with validation and error handling.
Audit Event Logging
server/lib/chittychronicle.ts, server/routes/classification.ts
Implemented non-blocking audit event client logToChronicle() with helpers logClassificationEvent() and logCoaEvent(). Integrated event logging into classification endpoints (classify, reconcile, unreconcile) and COA creation for audit trail tracking.
Database Schema
database/schema-registry-payload.json
Defined schema registry payload with chart_of_accounts (tenant-scoped COA records) and classification_audit (immutable classification change audit trail) table specifications with indexes and constraints.
Login UI Refinement
client/src/pages/Login.tsx
Added conditional email/password form visibility triggered by error state detection. Updated button styling and labels; replaced always-visible form with collapsible fallback triggered by auth-related errors.
Wave Webhook Integration
server/routes/webhooks.ts
Added POST /api/webhooks/wave endpoint with Wave-specific authentication, deduplication, and transaction ingestion logic mirroring Mercury webhook flow.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Client as Client App
    participant Server as Server
    participant DB as Database
    participant Chronicle as ChittyChronicle

    Note over User,Chronicle: Unreconcile Transaction Flow

    User->>Client: Click "Unlock" on reconciled transaction
    Client->>Server: POST /api/classification/unreconcile
    Server->>DB: Fetch transaction (check reconciliation status)
    Server->>DB: Begin transaction
    Server->>DB: Update transaction (reconciled=false, clear reconciliation fields)
    Server->>DB: Insert classification_audit record (action: unreconcile)
    Server->>DB: Commit transaction
    Server->>Chronicle: POST /api/events (logClassificationEvent)
    Chronicle-->>Server: 2xx or error (fire-and-forget)
    Server-->>Client: 200 with updated transaction
    Client->>Client: Invalidate reconciled queries cache
    Client->>Client: Re-fetch reconciled transactions list
    Client-->>User: Updated UI with transaction removed
Loading
sequenceDiagram
    actor External as Wave App
    participant Server as Server
    participant KV as KV Store
    participant DB as Database
    participant Chronicle as ChittyChronicle

    Note over External,Chronicle: Wave Webhook Ingestion Flow

    External->>Server: POST /api/webhooks/wave (with signature + event-id)
    Server->>Server: Validate Wave auth token
    Server->>Server: Parse and validate webhook envelope
    Server->>KV: Check dedup key (webhook:wave:eventId)
    alt Duplicate found
        KV-->>Server: Key exists
        Server-->>External: 202 Accepted
    else New event
        KV-->>Server: Key not found
        Server->>KV: Set dedup key (7-day TTL)
        Server->>DB: Create transaction record
        Server->>Chronicle: POST /api/events (webhook.wave.transaction_ingested)
        Chronicle-->>Server: 2xx or error
        DB-->>Server: Transaction created
        Server-->>External: 201 Created
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #86: Extends classification/reconciliation feature with reconciled/unreconcile endpoints and client hooks, directly sharing modified classification route handlers and use-classification hook file.
  • PR #84: Introduces classification_audit table schema and COA classification columns that this PR builds upon for audit trail storage and management.
  • PR #72: Modifies client/src/pages/Login.tsx to update ChittyID SSO button and login form UI/flow, overlapping with email form visibility changes in this PR.

Poem

🐰 Hop, hop! The transactions now reconcile,
Unreconcile too—flexibility in style!
With Wave webhooks flowing and audits so clear,
ChittyChronicle logs every action we hear! 🎉
From login to ledger, the features align,
A hopping great refactor—the system now shines!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'feat: Phase 5 ecosystem integration (5 features)' is vague and overly broad, using a generic numbering phrase that lacks specificity about the primary change. Consider a more specific title highlighting the main feature (e.g., 'feat: Add reconciled transactions view and Wave webhook support' or narrow to one primary change if this is truly a phase).
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ 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/phase-5-ecosystem-integration

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.

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

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 7f7cf1d Apr 13 2026, 10:22 AM

@chitcommit chitcommit merged commit d76f70b into main Apr 13, 2026
12 of 13 checks passed
@chitcommit chitcommit deleted the feat/phase-5-ecosystem-integration branch April 13, 2026 10:23
@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

PR Review — feat: Phase 5 ecosystem integration (5 features)

Overall this is solid work. Five independent features, clean separation, and the PR description is unusually clear about what each piece does. The ChittyChronicle fire-and-forget pattern and Wave webhook mirror Mercury well. A few things worth addressing before merge.


🔴 Critical: Fire-and-forget in Cloudflare Workers requires waitUntil

In server/routes/classification.ts and server/routes/webhooks.ts, logClassificationEvent and logCoaEvent are called without await and without c.executionCtx.waitUntil():

// classification.ts ~L603
logClassificationEvent(c.env, { ... });  // unawaited

In Cloudflare Workers, when a response is returned the runtime may terminate the isolate before any unawaited fetch() inside the promise completes. Fire-and-forget in Workers must be handed off via c.executionCtx.waitUntil():

c.executionCtx.waitUntil(logClassificationEvent(c.env, { ... }));

Without this, Chronicle events will be silently dropped at production load. The logToChronicle function already returns a Promise<boolean> — just pass it to waitUntil.


🟡 Security: Wave webhook trusts tenantId from request body

POST /api/webhooks/wave in server/routes/webhooks.ts:

const created = await storage.createTransaction({
  tenantId: tx.tenantId,   // caller-controlled
  accountId: tx.accountId, // caller-controlled
  ...
});

Any service that holds CHITTY_AUTH_SERVICE_TOKEN can write transactions into any tenantId. This is consistent with how Mercury is handled, so it's an existing pattern, but it's worth noting: if ChittyConnect ever handles multiple orgs or if the token leaks, a compromised caller could inject transactions into arbitrary tenants. A minimum safeguard would be validating that tx.tenantId exists in the tenants table before inserting. Same concern applies to accountId — no check that the account belongs to the specified tenant.


🟡 Naming: unreconciledTransaction is an adjective, not a verb

server/storage/system.ts:820:

async unreconciledTransaction(txId: string, tenantId: string, actorId: string)

unreconciled reads as past-tense/adjective (describing a state), not the action being performed. The route POST /api/classification/unreconcile implies the verb unreconcileTransaction. Rename to unreconcileTransaction to match the route and audit action name.


🟡 Type mismatch: useReconciledTransactions returns UnclassifiedTransaction[]

client/src/hooks/use-classification.ts:10:

return useQuery<UnclassifiedTransaction[]>({
  queryKey: ['/api/classification/reconciled', ...],

Reconciled transactions are typed as UnclassifiedTransaction[]. The backend returns the same shape, so it works, but the type name creates a semantic contradiction. Consider a type alias: type ClassifiedTransaction = UnclassifiedTransaction or reuse a shared Transaction type. Low priority but will confuse future readers.


🟡 Idempotency gap in unreconcile

server/storage/system.ts:823:

if (!tx.reconciled) return tx;  // early return, no audit entry

When POST /api/classification/unreconcile is called on a transaction that's already not reconciled, the route returns 200 OK with the transaction. This is fine for idempotency but the early return skips the classificationAudit insert, so a double-unreconcile produces no trace. Whether that matters depends on whether you want the audit trail to record "was asked to unreconcile but was already unlocked". Current behavior is probably acceptable — just flagging it as an intentional gap.


🟢 Minor: unreconcile action missing from logClassificationEvent params docs

server/lib/chittychronicle.ts:531:

action: string; // classify, reclassify, reconcile, unreconcile, suggest

'unreconcile' is listed in the comment but logCoaEvent hardcodes actor.type: 'user' regardless of actorType. Not a bug today (COA mutations are always user-initiated) but worth noting if system/agent COA mutations are added later.


🟢 Minor: schema-registry-payload.json description says 80 accounts, CLAUDE.md says 60

database/schema-registry-payload.json:5:

"description": "... COA ... (80 seeded)"

CLAUDE.md says 60 REI accounts seeded. One of these needs updating.


🟢 Minor: Login fallback useEffect reads from window.location.search without decoding

client/src/pages/Login.tsx:257:

window.location.search.includes(e)

This works for well-formed error codes, but URL-encoded values (e.g. %5F instead of _) won't match. Not a security issue here — just a subtle edge case if ChittyID ever returns URL-encoded error codes. new URLSearchParams(window.location.search).get('error') is the idiomatic alternative.


Positive callouts

  • The AbortSignal.timeout(TIMEOUT_MS) in chittychronicle.ts is exactly right for Workers-native code.
  • KV dedup key namespaced as webhook:wave:{eventId} — consistent with the Mercury pattern, easy to trace.
  • The login UX change is well-reasoned: ChittyID primary, email as graceful fallback with auto-expand on ChittyID errors.
  • The schema registry JSON is a useful artifact; the column-level descriptions and enum lists make it genuinely useful for downstream consumers.

The waitUntil issue on fire-and-forget Chronicle calls is the one to fix before merge — everything else is non-blocking.

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: 7f7cf1d656

ℹ️ 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".

async unreconciledTransaction(txId: string, tenantId: string, actorId: string) {
const tx = await this.getTransaction(txId, tenantId);
if (!tx) return undefined;
if (!tx.reconciled) return tx;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject no-op unreconcile requests

Returning the transaction when tx.reconciled is already false makes the caller treat /api/classification/unreconcile as a successful unlock. The route then emits classification.unreconcile ledger/Chronicle events for a state change that never happened, which can create misleading audit history if this endpoint is called directly on an unreconciled row. Return a distinct error/flag for this case so no-op calls do not generate unlock audit events.

Useful? React with 👍 / 👎.

export function useReconciledTransactions(limit = 50) {
const tenantId = useTenantId();
return useQuery<UnclassifiedTransaction[]>({
queryKey: ['/api/classification/reconciled', tenantId, limit],
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Send reconciled limit in request URL

This hook takes a limit argument but only stores it in the query key; the shared query function fetches queryKey[0] as the URL, so the request is always /api/classification/reconciled without ?limit=. The backend then uses its default limit (50), so the reconciled tab can silently truncate results/counts when more than 50 rows exist. Include the limit in the URL itself.

Useful? React with 👍 / 👎.

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

Implements Phase 5 “ecosystem integration” features across server + client: reconciled (locked) transaction visibility/unlock, Wave webhook ingest with real-time auto-classification, Chronicle fire-and-forget audit logging, schema registry payload export, and a ChittyID-first login UX.

Changes:

  • Add reconciled transaction listing + unreconcile (unlock) flow end-to-end (storage + API + UI + hooks).
  • Add /api/webhooks/wave ingest endpoint mirroring Mercury’s classification + advisory schema validation + persistence.
  • Introduce server/lib/chittychronicle.ts client and wire Chronicle logging into classification + COA creation.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
server/storage/system.ts Adds storage methods to list reconciled transactions and to unreconcile (unlock) a transaction with audit insert.
server/routes/webhooks.ts Adds Wave webhook envelope validation + KV dedup + auto-classify + advisory schema validation + persist flow.
server/routes/classification.ts Adds reconciled listing endpoint and unreconcile endpoint; wires Chronicle logging into COA + classification actions.
server/lib/chittychronicle.ts New Workers-native Chronicle client with timeout + fall-open behavior.
database/schema-registry-payload.json Adds schema registry payload describing COA and classification audit tables.
client/src/pages/Login.tsx Makes ChittyID SSO the primary login path and collapses email/password behind a toggle with fallback behavior.
client/src/pages/Classification.tsx Adds Queue/Reconciled tab switcher and “Unlock” action for reconciled rows.
client/src/hooks/use-classification.ts Adds reconciled query hook and unreconcile mutation hook.

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

Comment on lines +91 to +96
export function useReconciledTransactions(limit = 50) {
const tenantId = useTenantId();
return useQuery<UnclassifiedTransaction[]>({
queryKey: ['/api/classification/reconciled', tenantId, limit],
enabled: !!tenantId,
});
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

useReconciledTransactions(limit) currently never sends limit to the server: the default queryFn only fetches queryKey[0] as the URL, so the extra limit element in the key is ignored and the endpoint will always use its default (50). Consider encoding limit into the URL itself (e.g. /api/classification/reconciled?limit=...) and keep the tenantId as a separate queryKey element for cache scoping.

Copilot uses AI. Check for mistakes.
Comment on lines +455 to +468
const result = await storage.unreconciledTransaction(parsed.data.transactionId, tenantId, userId);
if (!result) return c.json({ error: 'Transaction not found' }, 404);

ledgerLog(c, {
entityType: 'audit',
action: 'classification.unreconcile',
metadata: { transactionId: parsed.data.transactionId, actorId: userId },
}, c.env);

logClassificationEvent(c.env, {
transactionId: parsed.data.transactionId, tenantId, action: 'unreconcile',
coaCode: result.coaCode ?? '', actorId: userId, actorType: 'user',
});

Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

unreconcile always emits ledgerLog + Chronicle events even when storage.unreconciledTransaction() performs a no-op (it returns early when !tx.reconciled and does not insert an audit row). This can produce misleading external audit entries on duplicate requests. Consider having storage return a { changed } flag (or re-fetching the pre-state) and only log when an actual unlock occurred.

Suggested change
const result = await storage.unreconciledTransaction(parsed.data.transactionId, tenantId, userId);
if (!result) return c.json({ error: 'Transaction not found' }, 404);
ledgerLog(c, {
entityType: 'audit',
action: 'classification.unreconcile',
metadata: { transactionId: parsed.data.transactionId, actorId: userId },
}, c.env);
logClassificationEvent(c.env, {
transactionId: parsed.data.transactionId, tenantId, action: 'unreconcile',
coaCode: result.coaCode ?? '', actorId: userId, actorType: 'user',
});
const beforeAudit = await storage.getClassificationAudit(parsed.data.transactionId, tenantId);
const result = await storage.unreconciledTransaction(parsed.data.transactionId, tenantId, userId);
if (!result) return c.json({ error: 'Transaction not found' }, 404);
const afterAudit = await storage.getClassificationAudit(parsed.data.transactionId, tenantId);
const changed = afterAudit.length > beforeAudit.length;
if (changed) {
ledgerLog(c, {
entityType: 'audit',
action: 'classification.unreconcile',
metadata: { transactionId: parsed.data.transactionId, actorId: userId },
}, c.env);
logClassificationEvent(c.env, {
transactionId: parsed.data.transactionId, tenantId, action: 'unreconcile',
coaCode: result.coaCode ?? '', actorId: userId, actorType: 'user',
});
}

Copilot uses AI. Check for mistakes.
Comment on lines +1132 to +1137
await trx.insert(schema.classificationAudit).values({
transactionId: txId,
tenantId,
previousCoaCode: tx.coaCode,
newCoaCode: tx.coaCode ?? '9010',
action: 'unreconcile',
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

In the classification_audit insert for unreconcile, newCoaCode falls back to '9010' when tx.coaCode is null. That would record a COA change that didn’t actually happen and can corrupt downstream audit/analytics. Since reconciled transactions should already have a non-null coaCode (reconcile enforces this), it’s safer to enforce that invariant here (throw/return an error if missing) and write newCoaCode as the actual tx.coaCode.

Copilot uses AI. Check for mistakes.
.limit(limit);
}

async unreconciledTransaction(txId: string, tenantId: string, actorId: string) {
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

Method name unreconciledTransaction is grammatically confusing (reads like a getter for an already-unreconciled row). Consider renaming to unreconcileTransaction (and updating call sites) to match the route name and the action being performed.

Suggested change
async unreconciledTransaction(txId: string, tenantId: string, actorId: string) {
async unreconcileTransaction(txId: string, tenantId: string, actorId: string) {

Copilot uses AI. Check for mistakes.
Comment on lines +254 to +342
// POST /api/webhooks/wave — Wave Accounting webhook with real-time classification
// Same flow as Mercury: service-auth → zod → KV dedup → classify → ChittySchema advisory → persist
webhookRoutes.post('/api/webhooks/wave', async (c) => {
const expected = c.env.CHITTY_AUTH_SERVICE_TOKEN;
if (!expected) return c.json({ error: 'auth_not_configured' }, 500);

const auth = c.req.header('authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!token || token !== expected) return c.json({ error: 'unauthorized' }, 401);

const rawBody = await c.req.json().catch(() => null);
const envelope = waveWebhookEnvelopeSchema.safeParse(rawBody);
if (!envelope.success) {
return c.json({ error: 'invalid_envelope', details: envelope.error.flatten() }, 400);
}

const eventId = c.req.header('x-event-id') || envelope.data.id || envelope.data.eventId;
if (!eventId) return c.json({ error: 'missing_event_id' }, 400);

const kv = c.env.FINANCE_KV;
const dedupKey = `webhook:wave:${eventId}`;
const existing = await kv.get(dedupKey);
if (existing) return c.json({ received: true, duplicate: true }, 202);
await kv.put(dedupKey, JSON.stringify(rawBody || {}), { expirationTtl: 604800 });

const tx = envelope.data.data?.transaction;
if (!tx) return c.json({ received: true }, 202);

const suggestedCoaCode = findAccountCode(tx.description, tx.category ?? undefined);
const isSuspense = suggestedCoaCode === '9010';
const classificationConfidence = isSuspense ? '0.100' : '0.700';
const externalId = `wave:${tx.waveTransactionId}`;

const schemaResult = await validateRow(c.env, 'FinancialTransactionsInsertSchema', {
tenantId: tx.tenantId,
accountId: tx.accountId,
amount: String(tx.amount),
type: tx.amount >= 0 ? 'income' : 'expense',
description: tx.description,
date: tx.postedAt,
externalId,
});

if (!schemaResult.ok && schemaResult.errors) {
console.warn('[webhook:wave] ChittySchema validation failed (advisory)', { eventId, errors: schemaResult.errors });
}

const db = createDb(c.env.DATABASE_URL);
const storage = new SystemStorage(db);

const dupRow = await storage.getTransactionByExternalId(externalId, tx.tenantId);
if (dupRow) return c.json({ received: true, duplicate: true, transactionId: dupRow.id }, 202);

const created = await storage.createTransaction({
tenantId: tx.tenantId,
accountId: tx.accountId,
amount: String(tx.amount),
type: tx.amount >= 0 ? 'income' : 'expense',
category: tx.category ?? null,
description: tx.description,
date: new Date(tx.postedAt),
payee: tx.payee ?? null,
externalId,
suggestedCoaCode,
classificationConfidence,
metadata: { source: 'wave_webhook', waveTransactionId: tx.waveTransactionId, eventId },
});

ledgerLog(c, {
entityType: 'audit',
action: 'webhook.wave.transaction_ingested',
metadata: {
tenantId: tx.tenantId,
accountId: tx.accountId,
transactionId: created.id,
suggestedCoaCode,
confidence: classificationConfidence,
schemaAdvisory: schemaResult.advisory,
},
}, c.env);

return c.json({
received: true,
transactionId: created.id,
suggestedCoaCode,
classificationConfidence,
schemaAdvisory: schemaResult.advisory,
}, 201);
});
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

New /api/webhooks/wave handler mirrors the Mercury ingest flow, but there’s no corresponding automated test coverage (Mercury has server/__tests__/webhooks-mercury.test.ts). Adding Wave webhook tests (auth rejection, envelope validation, KV dedup, externalId dedup, suspense vs matched codes, advisory schema failure) would help prevent regressions and keep parity with Mercury.

Copilot uses AI. Check for mistakes.
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