Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { allocationRoutes } from './accounting/allocations';
import { classificationRoutes } from './routes/classification';
import { emailRoutes } from './routes/email';
import { comptrollerRoutes } from './routes/comptroller';
import { vendorChargeRoutes } from './routes/vendor-charge';
import { createDb } from './db/connection';
import { SystemStorage } from './storage/system';

Expand Down Expand Up @@ -103,6 +104,7 @@ export function createApp() {
'/api/integrations', '/api/tasks', '/api/ai-messages', '/api/ai', '/api/summary',
'/api/mercury', '/api/github', '/api/charges', '/api/forensics', '/api/portfolio', '/api/import', '/api/reports',
'/api/google', '/api/comms', '/api/workflows', '/api/leases', '/api/coa', '/api/classification', '/mcp',
'/api/vendor-charge',
];
app.use('/api/tenants', ...authAndContext);
app.use('/api/tenants/*', ...authAndContext);
Expand Down Expand Up @@ -135,6 +137,7 @@ export function createApp() {
app.route('/', classificationRoutes);
app.route('/', emailRoutes);
app.route('/', comptrollerRoutes);
app.route('/', vendorChargeRoutes);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add vendor-charge to protected API middleware

Mounting this route without adding /api/vendor-charge to protectedPrefixes leaves POST /api/vendor-charge/ingest outside the auth/tenant/storage middleware that the handler assumes. I checked server/app.ts: only the prefixes in lines 102-107 get storageMiddleware, hybridAuth, and tenantMiddleware, so a valid ingest request reaches storage.getAccountByExternalId(...) with storage/tenantId unset and fails instead of booking the charge; it also bypasses the intended service-token/tenant checks for a route that writes transactions.

Useful? React with 👍 / 👎.

app.route('/', googleRoutes);
app.route('/', commsRoutes);
app.route('/', workflowRoutes);
Expand Down
188 changes: 188 additions & 0 deletions server/routes/vendor-charge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { Hono } from 'hono';
import { z } from 'zod';
import type { HonoEnv } from '../env';
import { ledgerLog } from '../lib/ledger-client';
import { INFRA_ACCOUNT_EXTERNAL_ID } from '../lib/comptroller-sync';

// ChittyScrape (scrape.chitty.cc) extracts vendor charges from portals that
// have no API — registered-agent fees, utility bills, mortgage statements.
// This route is the canonical "ChittyScrape feeds the cost flow" wiring: it
// accepts a ChittyScrape result envelope plus the resolved charge amount and
// records it as an idempotent expense transaction in the books.
//
// The live portal scrape itself is credential-gated (portal login via
// ChittyConnect); this ingest does NOT scrape. It is driven by whatever
// dispatches the scrape (ChittyCommand cron) and posts the result here.

// Vendor category → real chart_of_accounts code. Codes verified against the
// seeded ChittyFinance COA (Neon solitary-rice-14149088). Do NOT invent codes.
// registered-agent : 5050 Legal & Professional Fees
// (Northwest Registered Agent et al. are private vendors selling a
// statutory-agent service — a professional-services fee, not a government
// license, so 5050 rather than 6040 Licenses & Permits.)
// utility-electric : 5100 / utility-gas : 5110 / utility-water : 5120 /
// utility-trash : 5130 / utility-internet : 5140
// mortgage : 5300 Mortgage Interest (the expense-recognised portion
// of a mortgage charge; principal is a 2500 liability, not an expense).
export const VENDOR_CATEGORY_COA: Record<string, string> = {
'registered-agent': '5050',
'utility-electric': '5100',
'utility-gas': '5110',
'utility-water': '5120',
'utility-trash': '5130',
'utility-internet': '5140',
utility: '5140', // generic utility fallback (internet/cable) when unspecified
mortgage: '5300',
};

// Map a ChittyScrape portalId to a default vendor category when the caller
// does not specify one explicitly.
const PORTAL_DEFAULT_CATEGORY: Record<string, string> = {
'nw-registered-agent': 'registered-agent',
'fl-registered-agent': 'registered-agent',
comed: 'utility-electric',
'peoples-gas': 'utility-gas',
'mr-cooper': 'mortgage',
};

// ChittyScrape result envelope shape (src/scrapers/base.ts ScrapeResult):
// { success, data?, error?, method:'scrape', portal, scrapedAt }
// We accept that envelope and a small ingest contract carrying the resolved
// charge (amount, vendor, category, period) since the raw scraped `data` shape
// differs per portal and the monetary amount is portal-specific.
const ingestSchema = z.object({
// The ChittyScrape envelope (required so we honour success/error + portal).
envelope: z.object({
success: z.boolean(),
portal: z.string().min(1),
scrapedAt: z.string().min(1),
error: z.string().nullish(),
data: z.unknown().optional(),
method: z.literal('scrape').optional(),
}),
// The resolved charge derived from the scrape.
charge: z.object({
vendor: z.string().min(1),
// amount is REQUIRED and must be > 0 — no defaulting to 0.
amountUsd: z.number().positive(),
period: z.string().min(1), // e.g. '2026' (annual) or '2026-06' (monthly)
category: z.string().optional(), // overrides the portal default
paymentStatus: z.string().optional(), // scraped 'ok' | 'failed'
date: z.string().optional(), // ISO; defaults to envelope.scrapedAt
description: z.string().optional(),
}),
// Account to book against, by external_id. Defaults to the infra account.
accountExternalId: z.string().optional(),
});

export const vendorChargeRoutes = new Hono<HonoEnv>();

vendorChargeRoutes.post('/api/vendor-charge/ingest', async (c) => {
const storage = c.get('storage');
const tenantId = c.get('tenantId');

let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ error: 'invalid_json' }, 400);
}

const parsed = ingestSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_payload', details: parsed.error.flatten() }, 400);
}
const { envelope, charge } = parsed.data;

// A failed scrape carries no trustworthy charge — refuse to book it.
if (!envelope.success) {
return c.json(
{ error: 'scrape_failed', message: envelope.error ?? 'ChittyScrape reported success=false' },
422,
);
}

const category = charge.category ?? PORTAL_DEFAULT_CATEGORY[envelope.portal];

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 Do not trust the scraper-supplied category as authoritative

Because charge.category takes precedence over the portal default and is immediately mapped into an authoritative coaCode, any caller that reaches this ingest can book a known portal under a different COA (for example, posting category: 'mortgage' for nw-registered-agent). Even with the route protected, this delegates classification authority to the scrape payload rather than validating the category against the portal or requiring an executor role, so a bad scrape result can misclassify tax/reporting data.

Useful? React with 👍 / 👎.

if (!category) {
return c.json(
{
error: 'unknown_category',
message: `No vendor category for portal '${envelope.portal}'. Pass charge.category explicitly.`,
},
400,
);
}
const coaCode = VENDOR_CATEGORY_COA[category];
if (!coaCode) {
return c.json(
{
error: 'unmapped_category',
message: `No COA mapping for category '${category}'. Known: ${Object.keys(VENDOR_CATEGORY_COA).join(', ')}.`,
},
400,
);
}

const accountExternalId = parsed.data.accountExternalId ?? INFRA_ACCOUNT_EXTERNAL_ID;
const account = await storage.getAccountByExternalId(accountExternalId, tenantId);
if (!account) {
return c.json(
{
error: 'account_missing',
message: `No account with external_id='${accountExternalId}' for this tenant.`,
},
400,
);
}

const date = charge.date ? new Date(charge.date) : new Date(envelope.scrapedAt);

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 Require a period date before booking scraped charges

When charge.date is omitted, this records the transaction on envelope.scrapedAt even though charge.period is the billing period. A late or backfilled scrape such as period: "2025" scraped on 2026-01-15 will be filtered into the 2026 tax/reporting window because reports use transactions.date rather than the metadata period, so the expense is omitted from the intended year. Require charge.date for periodized bills or derive the transaction date from charge.period before inserting.

Useful? React with 👍 / 👎.

if (Number.isNaN(date.getTime())) {
return c.json({ error: 'invalid_date', message: 'charge.date / envelope.scrapedAt is not a valid date' }, 400);
}

const row = await storage.upsertVendorCharge({
tenantId,
accountId: account.id,
date,
period: charge.period,
portalId: envelope.portal,
vendor: charge.vendor,
amountUsd: charge.amountUsd,
coaCode,
description: charge.description,
paymentStatus: charge.paymentStatus,
metadata: { category, scrapedAt: envelope.scrapedAt },
});

ledgerLog(
c,
{
entityType: 'audit',
action: 'vendor-charge.ingest',
metadata: {
tenantId,
portal: envelope.portal,
vendor: charge.vendor,
period: charge.period,
category,
coaCode,
amount: row.amount,
externalId: row.externalId,
transactionId: row.id,
},
},
c.env,
);

return c.json({
recorded: true,
transaction: {
id: row.id,
externalId: row.externalId,
amount: row.amount,
coaCode: row.coaCode,
type: row.type,
payee: row.payee,
},
});
});
72 changes: 72 additions & 0 deletions server/storage/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,78 @@ export class SystemStorage {
return row;
}

// ChittyScrape feeds the cost flow: a vendor charge extracted from a portal
// (registered-agent annual fee, utility bill, mortgage statement) is mirrored
// into the books as an expense transaction. Idempotent by external_id
// `scrape:{portalId}:{period}:{vendor}`
// so re-running a scrape (e.g. the ChittyCommand cron re-dispatching the
// portal) updates the same row rather than duplicating it.
//
// amount is REQUIRED — the scraper output is the source of truth for the fee.
// The route rejects (400) a charge with no amount; we never default to 0,
// because a $0 expense row would be a silent data-integrity lie.
async upsertVendorCharge(input: {
tenantId: string;
accountId: string;
date: Date;
period: string; // billing period the charge belongs to, e.g. '2026' or '2026-06'
portalId: string; // ChittyScrape portal id, e.g. 'nw-registered-agent'
vendor: string; // human vendor name, e.g. 'Northwest Registered Agent'
amountUsd: number; // > 0; the extracted fee
coaCode: string; // real chart_of_accounts code chosen by vendor category
description?: string;
paymentStatus?: string; // scraped 'ok' | 'failed' — recorded, does not gate the expense
metadata?: Record<string, unknown>;
}) {
const externalId = `scrape:${input.portalId}:${input.period}:${input.vendor}`;

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 Include account identity in the scrape idempotency key

For portals that can have more than one bill in the same period, such as two ComEd utility accounts or two mortgage accounts, both ingests use the same scrape:{portalId}:{period}:{vendor} key even when the caller passes different accountExternalIds. The second charge conflicts with the first and updates its amount/metadata instead of creating a separate transaction, so one account's bill is lost from the books; include an account, meter, or statement identifier in the key for multi-account vendors.

Useful? React with 👍 / 👎.

const amount = input.amountUsd.toFixed(2);
const now = new Date();
const metadata = {
source: 'chittyscrape',
portalId: input.portalId,
vendor: input.vendor,
period: input.period,
exactAmountUsd: input.amountUsd,
paymentStatus: input.paymentStatus ?? null,
...input.metadata,
};

const [row] = await this.db
.insert(schema.transactions)
.values({

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 Preserve property attribution for portal expenses

The new ingest path supports utilities and mortgage statements, but the inserted transaction never carries a propertyId (and the request schema has no way to pass one). Property P&L is built from getPropertyTransactions(propertyId, ...), and Schedule E puts no-property transactions into entity-level handling, so scraped property-specific bills will be missing from the property's financials/tax columns even though they were booked.

Useful? React with 👍 / 👎.

tenantId: input.tenantId,
accountId: input.accountId,
amount,
currency: 'USD',
type: 'expense',
category: 'vendor_charge',
description: input.description ?? `Vendor charge: ${input.vendor} (${input.period})`,
date: input.date,
payee: input.vendor,
externalId,
coaCode: input.coaCode,
classifiedBy: 'chittyscrape',
classifiedAt: now,
metadata,
})
.onConflictDoUpdate({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent scraper upserts from changing reconciled rows

When the same scrape:{portal}:{period}:{vendor} charge already exists, this conflict branch updates the transaction unconditionally, including amount, coaCode, and metadata. If the finance team reconciles that transaction and the scrape cron later replays or extracts a corrected amount/category, the locked row is mutated without the reconciled-row guard used by trust-path updates, corrupting a reconciled period.

Useful? React with 👍 / 👎.

target: [schema.transactions.tenantId, schema.transactions.externalId],
// Partial arbiter index (WHERE external_id IS NOT NULL) — predicate
// required so Postgres infers the partial unique index.
targetWhere: sql`${schema.transactions.externalId} IS NOT NULL`,
set: {
amount,
coaCode: input.coaCode,
classifiedBy: 'chittyscrape',
classifiedAt: now,
metadata,
updatedAt: now,
},
})
.returning();
return row;
}

async updateTransaction(id: string, tenantId: string, data: Partial<typeof schema.transactions.$inferInsert>) {
const [row] = await this.db
.update(schema.transactions)
Expand Down
Loading