feat: Phase 5 ecosystem integration (5 features)#92
Conversation
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>
|
@coderabbitai review Please evaluate:
|
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughThis 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
chittyfinance | 7f7cf1d | Apr 13 2026, 10:22 AM |
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
|
There was a problem hiding this comment.
💡 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; |
There was a problem hiding this comment.
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], |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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/waveingest endpoint mirroring Mercury’s classification + advisory schema validation + persistence. - Introduce
server/lib/chittychronicle.tsclient 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.
| export function useReconciledTransactions(limit = 50) { | ||
| const tenantId = useTenantId(); | ||
| return useQuery<UnclassifiedTransaction[]>({ | ||
| queryKey: ['/api/classification/reconciled', tenantId, limit], | ||
| enabled: !!tenantId, | ||
| }); |
There was a problem hiding this comment.
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.
| 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', | ||
| }); | ||
|
|
There was a problem hiding this comment.
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.
| 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', | |
| }); | |
| } |
| await trx.insert(schema.classificationAudit).values({ | ||
| transactionId: txId, | ||
| tenantId, | ||
| previousCoaCode: tx.coaCode, | ||
| newCoaCode: tx.coaCode ?? '9010', | ||
| action: 'unreconcile', |
There was a problem hiding this comment.
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.
| .limit(limit); | ||
| } | ||
|
|
||
| async unreconciledTransaction(txId: string, tenantId: string, actorId: string) { |
There was a problem hiding this comment.
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.
| async unreconciledTransaction(txId: string, tenantId: string, actorId: string) { | |
| async unreconcileTransaction(txId: string, tenantId: string, actorId: string) { |
| // 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); | ||
| }); |
There was a problem hiding this comment.
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.
Summary
5 independent features completing the ChittyOS ecosystem integration for ChittyFinance's COA trust-path system.
Features
1. Reconciled-row visibility
/api/classification/reconciled/api/classification/unreconcileClassification 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 tochronicle.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 forchart_of_accountsandclassification_audit. Ready for operator to register with schema.chitty.cc once thechittyfinancedatabase 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
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes