diff --git a/package.json b/package.json index 2eb6aaa..b675dd0 100755 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "test:run": "vitest run" }, "dependencies": { - "@chittyos/schema": "file:../../CHITTYFOUNDATION/chittyschema", "@hookform/resolvers": "^3.9.1", "@jridgewell/trace-mapping": "^0.3.25", "@modelcontextprotocol/sdk": "^1.27.1", diff --git a/server/__tests__/reports-and-cloudflare-integration.test.ts b/server/__tests__/reports-and-cloudflare-integration.test.ts index 2a64f03..868b938 100644 --- a/server/__tests__/reports-and-cloudflare-integration.test.ts +++ b/server/__tests__/reports-and-cloudflare-integration.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Hono } from 'hono'; import type { HonoEnv } from '../env'; -import { reportRoutes } from '../routes/reports'; +import { reportRoutes } from '../accounting/reports'; import { integrationRoutes } from '../routes/integrations'; const env = { diff --git a/server/__tests__/routes-consumer-contract.test.ts b/server/__tests__/routes-consumer-contract.test.ts index d809faa..219efbb 100644 --- a/server/__tests__/routes-consumer-contract.test.ts +++ b/server/__tests__/routes-consumer-contract.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Hono } from 'hono'; import type { HonoEnv } from '../env'; -import { accountRoutes } from '../routes/accounts'; +import { accountRoutes } from '../accounting/accounts'; import { summaryRoutes } from '../routes/summary'; const mockStorage = { diff --git a/server/__tests__/scenario.test.ts b/server/__tests__/scenario.test.ts index 511efaf..3038a14 100644 --- a/server/__tests__/scenario.test.ts +++ b/server/__tests__/scenario.test.ts @@ -9,7 +9,7 @@ import { serviceAuth } from '../middleware/auth'; import { tenantMiddleware } from '../middleware/tenant'; import { propertyRoutes } from '../routes/properties'; import { valuationRoutes } from '../routes/valuation'; -import { importRoutes } from '../routes/import'; +import { importRoutes } from '../books/import'; const TEST_TOKEN = 'test-service-token'; const TENANT_ID = 'b5fa96af-10eb-4d47-b9af-8fcb2ce24f81'; // IT CAN BE LLC diff --git a/server/__tests__/tax-reporting.test.ts b/server/__tests__/tax-reporting.test.ts index 3759f7a..3e5c0ea 100644 --- a/server/__tests__/tax-reporting.test.ts +++ b/server/__tests__/tax-reporting.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Hono } from 'hono'; import type { HonoEnv } from '../env'; -import { taxRoutes } from '../routes/tax'; +import { taxRoutes } from '../accounting/tax'; import { buildScheduleEReport, buildForm1065Report, diff --git a/server/__tests__/valuation.test.ts b/server/__tests__/valuation.test.ts index c211032..ad74b59 100644 --- a/server/__tests__/valuation.test.ts +++ b/server/__tests__/valuation.test.ts @@ -81,7 +81,7 @@ describe('valuation providers', () => { describe('parseTurboTenantCSV', () => { it('parses basic CSV data', async () => { - const { parseTurboTenantCSV } = await import('../routes/import'); + const { parseTurboTenantCSV } = await import('../books/import'); const csv = `date,description,amount,category 2024-01-15,Rent Payment,1200,rent 2024-01-20,Maintenance,-85,maintenance`; @@ -95,7 +95,7 @@ describe('parseTurboTenantCSV', () => { }); it('handles quoted fields with commas', async () => { - const { parseTurboTenantCSV } = await import('../routes/import'); + const { parseTurboTenantCSV } = await import('../books/import'); const csv = `date,description,amount,category 2024-01-15,"Rent, Unit 5A",1200,rent 2024-01-20,"Repair: sink, faucet",-150,maintenance`; @@ -107,7 +107,7 @@ describe('parseTurboTenantCSV', () => { }); it('handles quoted fields with escaped quotes', async () => { - const { parseTurboTenantCSV } = await import('../routes/import'); + const { parseTurboTenantCSV } = await import('../books/import'); const csv = `date,description,amount,category 2024-01-15,"Payment for ""Studio""",1200,rent`; @@ -117,7 +117,7 @@ describe('parseTurboTenantCSV', () => { }); it('skips rows with missing date or invalid amount', async () => { - const { parseTurboTenantCSV } = await import('../routes/import'); + const { parseTurboTenantCSV } = await import('../books/import'); const csv = `date,description,amount,category ,No Date,100,rent 2024-01-15,Valid,200,rent @@ -129,7 +129,7 @@ describe('parseTurboTenantCSV', () => { }); it('returns empty for single-line CSV', async () => { - const { parseTurboTenantCSV } = await import('../routes/import'); + const { parseTurboTenantCSV } = await import('../books/import'); expect(parseTurboTenantCSV('date,description,amount')).toHaveLength(0); expect(parseTurboTenantCSV('')).toHaveLength(0); }); @@ -137,7 +137,7 @@ describe('parseTurboTenantCSV', () => { describe('deduplicationHash', () => { it('produces consistent SHA-256 based hash', async () => { - const { deduplicationHash } = await import('../routes/import'); + const { deduplicationHash } = await import('../books/import'); const hash1 = await deduplicationHash('2024-01-15', 1200, 'Rent Payment'); const hash2 = await deduplicationHash('2024-01-15', 1200, 'Rent Payment'); expect(hash1).toBe(hash2); @@ -145,7 +145,7 @@ describe('deduplicationHash', () => { }); it('produces different hashes for different inputs', async () => { - const { deduplicationHash } = await import('../routes/import'); + const { deduplicationHash } = await import('../books/import'); const hash1 = await deduplicationHash('2024-01-15', 1200, 'Rent'); const hash2 = await deduplicationHash('2024-01-15', 1200, 'Maintenance'); expect(hash1).not.toBe(hash2); diff --git a/server/__tests__/webhooks-mercury.test.ts b/server/__tests__/webhooks-mercury.test.ts index 266eccb..9a67ff8 100644 --- a/server/__tests__/webhooks-mercury.test.ts +++ b/server/__tests__/webhooks-mercury.test.ts @@ -60,7 +60,7 @@ const baseEnv = { // Lazy-import the webhook routes so module mocks above take effect async function getApp() { - const { webhookRoutes } = await import('../routes/webhooks'); + const { webhookRoutes } = await import('../books/webhooks'); const app = new Hono(); app.route('/', webhookRoutes); return app; diff --git a/server/__tests__/webhooks-wave-receiver.test.ts b/server/__tests__/webhooks-wave-receiver.test.ts index ff68d17..39e84db 100644 --- a/server/__tests__/webhooks-wave-receiver.test.ts +++ b/server/__tests__/webhooks-wave-receiver.test.ts @@ -12,7 +12,7 @@ * configured to skip network in test env via CHITTY_LEDGER_BASE). */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { webhookRoutes } from '../routes/webhooks'; +import { webhookRoutes } from '../books/webhooks'; // Real ledger-client code runs; only the network boundary (fetch) is intercepted. // Per project rule: no mocking of service modules — but stubbing the global diff --git a/server/__tests__/webhooks-wave-secret.test.ts b/server/__tests__/webhooks-wave-secret.test.ts index 2795ae9..e8beba1 100644 --- a/server/__tests__/webhooks-wave-secret.test.ts +++ b/server/__tests__/webhooks-wave-secret.test.ts @@ -6,7 +6,7 @@ * Tests use a Map-backed KV stand-in, matching the existing Mercury pattern. */ import { describe, it, expect, beforeEach } from 'vitest'; -import { webhookRoutes } from '../routes/webhooks'; +import { webhookRoutes } from '../books/webhooks'; const SERVICE_TOKEN = 'test-service-token'; const TENANT = '11111111-1111-1111-1111-111111111111'; diff --git a/server/accounting/README.md b/server/accounting/README.md new file mode 100644 index 0000000..a38c72a --- /dev/null +++ b/server/accounting/README.md @@ -0,0 +1,7 @@ +# Accounting + +Accounting **derives meaning** from the facts Books records. + +Boundary: chart of accounts, reporting, tax, and allocations. It consumes the facts written by `server/books/` (transactions, imports, webhooks) and does not ingest or journal raw activity itself. + +Owner: `chittyaccounting-agent` → coa / reporting / tax / allocations. diff --git a/server/routes/accounts.ts b/server/accounting/accounts.ts similarity index 100% rename from server/routes/accounts.ts rename to server/accounting/accounts.ts diff --git a/server/routes/allocations.ts b/server/accounting/allocations.ts similarity index 100% rename from server/routes/allocations.ts rename to server/accounting/allocations.ts diff --git a/server/routes/reports.ts b/server/accounting/reports.ts similarity index 100% rename from server/routes/reports.ts rename to server/accounting/reports.ts diff --git a/server/routes/tax.ts b/server/accounting/tax.ts similarity index 100% rename from server/routes/tax.ts rename to server/accounting/tax.ts diff --git a/server/app.ts b/server/app.ts index b6c2837..d5d9ee0 100644 --- a/server/app.ts +++ b/server/app.ts @@ -10,15 +10,15 @@ import { callerContext } from './middleware/caller'; import { tenantMiddleware } from './middleware/tenant'; import { healthRoutes } from './routes/health'; import { docRoutes } from './routes/docs'; -import { accountRoutes } from './routes/accounts'; +import { accountRoutes } from './accounting/accounts'; import { summaryRoutes } from './routes/summary'; import { tenantRoutes } from './routes/tenants'; import { propertyRoutes } from './routes/properties'; -import { transactionRoutes } from './routes/transactions'; +import { transactionRoutes } from './books/transactions'; import { integrationRoutes } from './routes/integrations'; import { taskRoutes } from './routes/tasks'; import { aiRoutes } from './routes/ai'; -import { webhookRoutes } from './routes/webhooks'; +import { webhookRoutes } from './books/webhooks'; import { mercuryRoutes } from './routes/mercury'; import { githubRoutes } from './routes/github'; import { stripeRoutes } from './routes/stripe'; @@ -27,16 +27,16 @@ import { chargeRoutes } from './routes/charges'; import { forensicRoutes } from './routes/forensics'; import { valuationRoutes } from './routes/valuation'; import { portfolioRoutes } from './routes/portfolio'; -import { importRoutes } from './routes/import'; +import { importRoutes } from './books/import'; import { mcpRoutes } from './routes/mcp'; -import { reportRoutes } from './routes/reports'; -import { taxRoutes } from './routes/tax'; +import { reportRoutes } from './accounting/reports'; +import { taxRoutes } from './accounting/tax'; import { googleRoutes, googleCallbackRoute } from './routes/google'; import { commsRoutes } from './routes/comms'; import { workflowRoutes } from './routes/workflows'; import { leaseRoutes } from './routes/leases'; import { chittyIdAuthRoutes } from './routes/chittyid-auth'; -import { allocationRoutes } from './routes/allocations'; +import { allocationRoutes } from './accounting/allocations'; import { classificationRoutes } from './routes/classification'; import { emailRoutes } from './routes/email'; import { createDb } from './db/connection'; diff --git a/server/books/README.md b/server/books/README.md new file mode 100644 index 0000000..7514bc6 --- /dev/null +++ b/server/books/README.md @@ -0,0 +1,7 @@ +# Books + +Books **writes facts**: ingest, categorize, and journal financial activity. + +Boundary: Books records what happened (transactions, imports, webhook intake). It does not derive meaning — chart of accounts, reporting, tax, and allocations live in `server/accounting/`. + +Owner: `chittybooks-agent` → ingest / categorize / journal. diff --git a/server/routes/import.ts b/server/books/import.ts similarity index 100% rename from server/routes/import.ts rename to server/books/import.ts diff --git a/server/routes/transactions.ts b/server/books/transactions.ts similarity index 100% rename from server/routes/transactions.ts rename to server/books/transactions.ts diff --git a/server/routes/webhooks.ts b/server/books/webhooks.ts similarity index 100% rename from server/routes/webhooks.ts rename to server/books/webhooks.ts diff --git a/server/lib/central-workflows.ts b/server/lib/central-workflows.ts index 7a200cf..d8a0007 100644 --- a/server/lib/central-workflows.ts +++ b/server/lib/central-workflows.ts @@ -1,59 +1,175 @@ /** - * ChittyFinance scope projector — thin adapter over @chittyos/schema/scope-projector. + * Fractal scope projector. * - * Preserves the existing call signature (tenantId as top-level field) - * while delegating to the shared fractal scope library. + * Projects local workflow lifecycle events into the canonical `scopes` + * table in ChittyOS-Core (Neon). Uses the fractal scope primitive from + * migration 002_fractal_scopes.sql — self-similar via parent_scope_id, + * lifecycle via scope_status enum, domain taxonomy via scope_type. + * + * Fire-and-forget via waitUntil so local app flows remain authoritative. + * Fall-open: no CHITTYOS_CORE_DATABASE_URL = silent no-op. * * @canon: chittycanon://gov/governance#core-types */ -import { - createScopeProjector, - type ScopeCharacterization, - type ScopeEnv, - type ScopeStatus, - SCOPE_TYPES, -} from '@chittyos/schema/scope-projector'; +import { neon } from '@neondatabase/serverless'; + +// -- Canonical scope_status enum (002_fractal_scopes.sql) ----------------- +type ScopeStatus = + | 'new' + | 'active' + | 'waiting' + | 'escalated' + | 'paused' + | 'resolved' + | 'closed' + | 'archived'; -// Re-export shared types for downstream convenience -export { SCOPE_TYPES, type ScopeStatus, type ScopeCharacterization, type ScopeEnv }; +// -- Canonical scope_characterization enum -------------------------------- +type ScopeCharacterization = + | 'Case' + | 'Session' + | 'Transaction' + | 'Incident' + | 'Project' + | 'Engagement'; + +// -- Public interface for callers ----------------------------------------- export interface ScopeProjection { + /** Local workflow ID — becomes external_id for upsert dedup */ externalId: string; + /** Tenant slug or ID — stored in metadata for filtering */ tenantId: string; + /** Free-text domain taxonomy (e.g. 'maintenance_request', 'expense_approval') */ scopeType: string; + /** Canonical characterization — workflows are typically 'Project' */ characterization?: ScopeCharacterization; + /** Display title */ title: string; + /** Optional summary/description */ summary?: string | null; + /** Local workflow status — mapped to canonical scope_status */ localStatus: string; + /** Optional status reason */ statusReason?: string; + /** Domain-specific state (stored in metadata JSONB) */ metadata?: Record; } -const financeProjector = createScopeProjector('finance.chitty.cc', { - characterization: 'Project', -}); +interface ScopeEnv { + CHITTYOS_CORE_DATABASE_URL?: string; +} + +const SOURCE = 'finance.chitty.cc'; +const CREATOR = 'service:finance.chitty.cc'; /** - * Fire-and-forget scope projection — drop-in compatible with existing callers. - * Injects tenantId into metadata (the shared library doesn't assume multi-tenancy). + * Map local workflow status strings to the canonical scope_status enum. + * + * scope_status: new | active | waiting | escalated | paused | resolved | closed | archived */ -export function scopeLog( - c: { executionCtx: { waitUntil(p: Promise): void } }, +function toScopeStatus(localStatus: string): ScopeStatus { + switch (localStatus) { + case 'requested': + return 'new'; + case 'approved': + case 'in_progress': + return 'active'; + case 'completed': + return 'resolved'; + case 'rejected': + return 'closed'; + case 'blocked': + return 'waiting'; + case 'cancelled': + return 'closed'; + default: + return 'new'; + } +} + +/** + * Upsert a scope row in chittyos-core's public.scopes table. + * + * Uses the unique index on (source, external_id) for idempotent upsert. + * On conflict (same workflow projected again), updates status + metadata. + * The DB trigger `trg_scopes_transitions` auto-logs state changes to + * scope_events — no manual event inserts needed. + */ +export async function projectScope( projection: ScopeProjection, env: ScopeEnv, -): void { - financeProjector(c, env, { - externalId: projection.externalId, +): Promise { + if (!env.CHITTYOS_CORE_DATABASE_URL) return; + + const sql = neon(env.CHITTYOS_CORE_DATABASE_URL); + const status = toScopeStatus(projection.localStatus); + const characterization = projection.characterization ?? 'Project'; + const metadata = JSON.stringify({ + tenantId: projection.tenantId, scopeType: projection.scopeType, - characterization: projection.characterization, - title: projection.title, - summary: projection.summary, localStatus: projection.localStatus, - statusReason: projection.statusReason, - metadata: { - tenantId: projection.tenantId, - ...(projection.metadata ?? {}), - }, + ...(projection.metadata ?? {}), }); + + try { + await sql` + INSERT INTO public.scopes ( + canon_type, + characterization, + scope_type, + status, + status_reason, + creator_id, + current_agent_id, + title, + summary, + source, + external_id, + metadata + ) VALUES ( + 'E', + ${characterization}::scope_characterization, + ${projection.scopeType}, + ${status}::scope_status, + ${projection.statusReason ?? null}, + ${CREATOR}, + ${CREATOR}, + ${projection.title}, + ${projection.summary ?? null}, + ${SOURCE}, + ${projection.externalId}, + ${metadata}::jsonb + ) + ON CONFLICT (source, external_id) + WHERE external_id IS NOT NULL AND deleted_at IS NULL + DO UPDATE SET + status = ${status}::scope_status, + status_reason = ${projection.statusReason ?? null}, + current_agent_id = ${CREATOR}, + title = ${projection.title}, + summary = ${projection.summary ?? null}, + metadata = ${metadata}::jsonb + `; + } catch (err) { + console.warn('[scope-projector] upsert failed:', err); + } +} + +/** + * Fire-and-forget scope projection via executionCtx.waitUntil. + * Drop-in replacement for the old centralWorkflowLog. + */ +export function scopeLog( + c: { executionCtx: { waitUntil(p: Promise): void } }, + projection: ScopeProjection, + env: ScopeEnv, +): void { + const promise = projectScope(projection, env); + try { + c.executionCtx.waitUntil(promise); + } catch { + // Test environment / non-Workers runtime — swallow. + } } diff --git a/server/routes/classification.ts b/server/routes/classification.ts index ee6e590..08744aa 100644 --- a/server/routes/classification.ts +++ b/server/routes/classification.ts @@ -1,3 +1,4 @@ +// Straddles the books/accounting boundary: the COA-definition side belongs to accounting; the categorize-action side belongs to books. Left in routes/ until split. import { Hono } from 'hono'; import { z } from 'zod'; import type { HonoEnv } from '../env';