sh-mcp/packages/qbo/src/client.ts

127 lines
4.8 KiB
TypeScript
Raw Normal View History

Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2) * Phase 0b slice: monorepo scaffold + shared core + integration packages The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers. - Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate), eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu). - @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry, redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2. - 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base, gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind injected client interfaces; finance handlers call redact(). Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally deferred until the 0a spike resolves it (G16/§0.4). * Complete Cognito auth provider + Phase 1 build brief Finish the WIP CognitoAuthProvider (client_id allow-list as audience boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with its test suite, and check in docs/build-plan-phase-1.md so the Phase 1 work has its governing brief in-tree (design.md §2.5). * ci: disable cdk synth for Phase 0b (no CDK app yet) The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails with '--app is required'. Disable it here; Phase 1 re-enables it with the server CDK stubs.
2026-06-26 12:42:17 -04:00
/**
* QBO client interface and implementation.
*
* The real QuickBooks Online API is reached via OAuth 2.0 with a server-held
* refresh token (stored in AWS Secrets Manager). The implementation below
* stubs the actual HTTP call so no real network I/O happens at import time.
*
* TODO (DEFERRED — auth layer, phase 3): Replace the stub in QboClientImpl with
* a real intuit-oauth + node-quickbooks (or raw fetch) call that:
* 1. Reads the refresh token from Secrets Manager at
* arn:aws:secretsmanager:us-east-1:328440206208:secret:sh-mcp/qbo-oauth
* 2. Exchanges it for a short-lived access token on demand (and rotates the
* stored refresh token when QBO returns a new one).
* 3. Issues the GET /v3/company/{realmId}/query?query=... request.
* 4. Never logs or returns the access/refresh tokens.
* The QboClientInterface below is the stable contract; the implementation is
* injected, so tests and the real server each provide their own.
*/
/** A vendor record as returned by QBO's vendor query endpoint. */
export interface QboVendor {
/** QBO internal vendor ID */
id: string;
/** Display name of the vendor */
displayName: string;
/** Primary contact email, if present */
email?: string;
/** Primary phone number, if present */
phone?: string;
/** Whether the vendor is currently active */
active: boolean;
/** Vendor balance (amount owed), expressed as a number */
balance?: number;
/**
* Tax identification number. Treated as sensitive — callers MUST pass this
* through redact() before including it in a tool response.
*/
taxId?: string;
}
/** Parameters forwarded to the QBO vendor query. */
export interface SearchVendorsParams {
/** Free-text search term matched against vendor display name */
query: string;
/** Maximum number of results to return (1–100, default 20) */
maxResults?: number;
}
/** Result envelope returned by the QBO vendor query. */
export interface SearchVendorsResult {
vendors: QboVendor[];
/** Total matching vendors in QBO (may exceed vendors.length if paginated) */
totalCount: number;
}
/**
* The interface every consumer codes against.
* The real implementation, a test mock, and any future adapters all satisfy
* this shape — nothing in src/ imports a concrete HTTP library directly.
*/
export interface QboClientInterface {
/**
* Search QBO vendors by display name.
*
* @throws {QboThrottleError} when QBO returns HTTP 429.
* @throws {QboApiError} for any other non-2xx response.
*/
searchVendors(params: SearchVendorsParams): Promise<SearchVendorsResult>;
}
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
export class QboApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
) {
super(message);
this.name = 'QboApiError';
}
}
export class QboThrottleError extends Error {
constructor(
/** Seconds to wait before retrying, if provided by QBO */
public readonly retryAfterSeconds?: number,
) {
super('QBO rate limit exceeded');
this.name = 'QboThrottleError';
}
}
// ---------------------------------------------------------------------------
// Thin implementation (real call stubbed — see TODO above)
// ---------------------------------------------------------------------------
/**
* Thin wrapper around the QuickBooks Online Accounting API.
*
* STUBBED: the actual HTTP call is replaced with a NotImplementedError so
* this class can be imported without triggering real network I/O or requiring
* AWS credentials. Inject a mock (see test/qbo.test.ts) in unit tests.
*/
export class QboClientImpl implements QboClientInterface {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async searchVendors(_params: SearchVendorsParams): Promise<SearchVendorsResult> {
// TODO (DEFERRED — auth layer): implement real QBO API call.
// Steps:
// 1. Retrieve OAuth refresh token from Secrets Manager.
// 2. Exchange for QBO access token (rotate stored refresh token if renewed).
// 3. Build QBO SQL query:
// SELECT * FROM Vendor WHERE DisplayName LIKE '%{query}%'
// STARTPOSITION 1 MAXRESULTS {maxResults}
// 4. GET https://quickbooks.api.intuit.com/v3/company/{realmId}/query
// with Authorization: Bearer {accessToken}
// 5. Map QBO QueryResponse.Vendor[] → SearchVendorsResult.
// 6. On HTTP 429 → throw QboThrottleError(retryAfterSeconds).
// 7. On other non-2xx → throw QboApiError(message, statusCode).
throw new Error(
'QboClientImpl.searchVendors is not yet implemented. ' +
'Inject a mock QboClientInterface for tests, or implement the real call (see TODO above).',
);
}
}