-
Notifications
You must be signed in to change notification settings - Fork 0
feat(vendor-charge): ingest ChittyScrape portal charges into the books #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/comptroller-cost-bridge
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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, | ||
| }, | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new ingest path supports utilities and mortgage statements, but the inserted transaction never carries a 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({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the same 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mounting this route without adding
/api/vendor-chargetoprotectedPrefixesleavesPOST /api/vendor-charge/ingestoutside the auth/tenant/storage middleware that the handler assumes. I checkedserver/app.ts: only the prefixes in lines 102-107 getstorageMiddleware,hybridAuth, andtenantMiddleware, so a valid ingest request reachesstorage.getAccountByExternalId(...)withstorage/tenantIdunset 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 👍 / 👎.