sh-mcp/packages/qbo/src/client.ts
Adam Moussa 0d1fefb326
Some checks are pending
deploy / deploy (push) Waiting to run
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

126 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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).',
);
}
}