Nicholai a7494397f2
docs(all): comprehensive documentation overhaul (#57)
Restructure docs/ into architecture/, modules/, and
development/ directories. Add thorough documentation
for Compass Core platform and HPS Compass modules.
Rewrite CLAUDE.md as a lean quick-reference that
points to the full docs. Rename files to lowercase,
consolidate old docs, add gotchas section.

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-07 19:17:37 -07:00

323 lines
14 KiB
Markdown
Executable File

NetSuite Integration
===
The NetSuite module is a bidirectional REST API integration that syncs customers, vendors, projects, invoices, and vendor bills between Compass (D1/SQLite) and NetSuite (Oracle's ERP). It handles OAuth 2.0 authentication, encrypted token storage, rate limiting, delta sync with conflict resolution, and per-record error tracking.
The integration exists because construction companies using NetSuite need their financial and contact data accessible in Compass without manual re-entry. NetSuite's REST API is powerful but full of surprising behaviors that this module works hard to handle gracefully.
architecture overview
---
```
src/lib/netsuite/
config.ts # account config, URL builders
auth/
oauth-client.ts # OAuth 2.0 authorize/exchange/refresh
token-manager.ts # encrypted storage, auto-refresh at 80% lifetime
crypto.ts # delegates to shared AES-GCM crypto with netsuite salt
client/
base-client.ts # HTTP client with retry, circuit breaker
record-client.ts # CRUD for individual records
suiteql-client.ts # SuiteQL query execution
errors.ts # error classification (the hard part)
types.ts # API response types, record interfaces
rate-limiter/
concurrency-limiter.ts # semaphore with adaptive reduction
request-queue.ts # priority-based FIFO wrapper
sync/
sync-engine.ts # orchestrates pull and push operations
delta-sync.ts # pull remote changes since last sync
push.ts # push local changes to netsuite
conflict-resolver.ts # four strategies for handling conflicts
idempotency.ts # deterministic keys for safe retries
mappers/
base-mapper.ts # abstract bidirectional field mapping
customer-mapper.ts
vendor-mapper.ts
project-mapper.ts
invoice-mapper.ts
vendor-bill-mapper.ts
```
configuration
---
`config.ts` reads environment variables and builds URLs. The URL construction handles NetSuite's inconsistent format -- sandbox accounts use different separators (`123456-sb1` in URLs vs `123456_SB1` in the account ID):
```typescript
export function getRestBaseUrl(accountId: string): string {
const urlId = accountId.toLowerCase().replace("_", "-")
return `https://${urlId}.suitetalk.api.netsuite.com`
}
```
Required environment variables:
- `NETSUITE_ACCOUNT_ID` -- the account identifier (e.g., `1234567` or `1234567_SB1` for sandbox)
- `NETSUITE_CLIENT_ID` / `NETSUITE_CLIENT_SECRET` -- OAuth 2.0 integration credentials
- `NETSUITE_REDIRECT_URI` -- callback URL for the OAuth flow
- `NETSUITE_TOKEN_ENCRYPTION_KEY` -- AES-GCM key for encrypting tokens at rest
- `NETSUITE_CONCURRENCY_LIMIT` -- optional, defaults to 15
OAuth 2.0 flow
---
`auth/oauth-client.ts` implements the standard authorization code flow. The user is redirected to NetSuite's authorize endpoint with scopes `rest_webservices` and `suite_analytics`, then the callback exchanges the code for tokens using HTTP Basic authentication (client credentials in the Authorization header).
`auth/token-manager.ts` manages token lifecycle. Tokens are encrypted with AES-GCM before storage in the `netsuite_auth` table. The manager refreshes proactively at 80% of the token's lifetime to avoid edge-case expiry during a request:
```typescript
private shouldRefresh(tokens: OAuthTokens): boolean {
const elapsed = Date.now() - tokens.issuedAt
const threshold = tokens.expiresIn * 1000 * REFRESH_THRESHOLD
return elapsed >= threshold
}
```
Tokens are cached in memory for the duration of a request to avoid redundant decryption.
HTTP client
---
`client/base-client.ts` wraps every NetSuite request with:
**Retry with exponential backoff.** Up to 3 retries with jittered delay (base 1s, max 30s). Only retryable errors trigger retries -- rate limits, timeouts, server errors, and expired auth (which triggers a token refresh).
**Circuit breaker.** After 5 consecutive failures, the client stops sending requests for 60 seconds. This prevents cascading failures when NetSuite is down or the account is locked out.
**Concurrency limiting.** Every request passes through the `ConcurrencyLimiter` before execution. More on this below.
The client delegates error handling to `errors.ts`, which is where most of the NetSuite-specific knowledge lives.
error classification
---
NetSuite's error responses are misleading. The `classifyError` function in `errors.ts` handles the known traps:
```typescript
if (status === 401) {
// netsuite sometimes returns 401 for timeouts
if (bodyStr.includes("timeout") || bodyStr.includes("ETIMEDOUT")) {
return {
category: "timeout",
message: "Request timed out (disguised as 401)",
retryAfter: null,
}
}
if (bodyStr.includes("Invalid Login Attempt")) {
return {
category: "rate_limited",
message: "Rate limited (disguised as auth error)",
retryAfter: 5000,
}
}
}
```
Key classifications:
- **401 + "timeout" in body** = actually a timeout, not an auth failure. Retryable.
- **401 + "Invalid Login Attempt"** = actually rate limiting on SOAP connections. Retryable with 5s backoff.
- **403 + "does not exist"** = permission denied, not a missing field. The REST API returns "field doesn't exist" when the integration role lacks permission to read that field.
- **429** = actual rate limit. Parses `Retry-After` header if present, defaults to 5s.
Error categories determine retryability: `rate_limited`, `timeout`, `server_error`, `network`, and `auth_expired` are retryable. Everything else (`permission_denied`, `validation`, `not_found`) is terminal.
rate limiter
---
NetSuite enforces a limit of 15 concurrent requests across ALL integrations on an account -- SOAP, REST, RESTlets, everything. If your REST integration sends 10 requests and a SOAP integration sends 6, you'll get 429s on the 16th.
`rate-limiter/concurrency-limiter.ts` implements a semaphore with priority queuing:
```typescript
async execute<T>(fn: () => Promise<T>, priority = 0): Promise<T> {
await this.acquire(priority)
try {
return await fn()
} finally {
this.release()
}
}
```
The limiter also adapts: when a 429 response comes back, it reduces concurrency to 70% of the current limit. After successful requests, it gradually restores back to the original value. This handles the case where other integrations are consuming part of the shared pool.
`rate-limiter/request-queue.ts` adds named priorities on top: `critical` (30), `high` (20), `normal` (10), `low` (0). User-triggered actions get higher priority than background sync operations.
record client and SuiteQL client
---
Two client types sit on top of the base HTTP client:
`RecordClient` handles CRUD operations on individual NetSuite records. It supports field selection, query filtering, pagination (`listAll` auto-pages through results), and record transformations (e.g., converting a sales order to an invoice).
```typescript
async create(
recordType: string,
data: Record<string, unknown>,
idempotencyKey?: string
): Promise<{ id: string }>
```
The `create` method accepts an optional idempotency key via the `X-NetSuite-Idempotency-Key` header. More on this in the sync section.
`SuiteQLClient` executes SQL-like queries against NetSuite's analytics engine. Delta sync uses SuiteQL to fetch only records modified since the last sync. The client auto-pages results and caps at 100,000 rows as a safety limit.
mappers
---
Mappers handle bidirectional field translation between Compass's flat D1 records and NetSuite's nested record structure. `BaseMapper` is an abstract class that provides:
- `toRemote(local)` -- convert a Compass record to NetSuite format
- `toLocal(remote)` -- convert a NetSuite record to Compass format
- `getNetSuiteRecordType()` -- the REST API record type string
- `getLocalTable()` -- the D1 table name
- `buildSelectQuery()` / `buildDeltaQuery()` -- SuiteQL generation
Here's `CustomerMapper.toLocal` as an example of the field mapping:
```typescript
toLocal(remote: NetSuiteCustomer): Partial<LocalCustomer> {
return {
name: remote.companyName,
email: remote.email ?? null,
phone: remote.phone ?? null,
netsuiteId: remote.id,
updatedAt: new Date().toISOString(),
}
}
```
There are currently five mappers: customer, vendor, project, invoice, and vendor-bill.
sync engine
---
`sync/sync-engine.ts` orchestrates sync operations. It wires together config, auth, clients, and limiters into a single `SyncEngine` class:
```typescript
constructor(
db: DrizzleD1Database,
env: Record<string, string | undefined>,
conflictStrategy: ConflictStrategy = "newest_wins"
)
```
The engine supports three operations:
- `pull(mapper, upsertLocal)` -- fetch changes from NetSuite, apply them locally
- `push(mapper, getLocalRecord)` -- send local changes to NetSuite
- `fullSync(mapper, upsertLocal, getLocalRecord)` -- pull then push
Every sync run is logged in the `netsuite_sync_log` table with timestamps, record counts, and error summaries.
delta sync (pull)
---
`sync/delta-sync.ts` implements the pull logic. On first sync, it runs the mapper's full `buildSelectQuery()`. On subsequent syncs, it uses `buildDeltaQuery(since)` to fetch only records modified after the last successful sync.
For each remote record, the function:
1. Looks up existing sync metadata by NetSuite internal ID
2. If the local record has `pending_push` status (local changes waiting to be sent), it runs conflict resolution
3. Otherwise, upserts the local record and updates sync metadata to `synced`
4. For new records, creates both the local record and the sync metadata entry
Conflict resolution uses one of four strategies:
- `newest_wins` -- compare timestamps, newer version wins (default)
- `remote_wins` -- always accept the remote version
- `local_wins` -- always keep the local version
- `manual` -- flag for human review in the conflict dialog UI
push
---
`sync/push.ts` sends local changes to NetSuite. It queries for all sync metadata records with `pending_push` status, then for each one:
1. Loads the current local record
2. Runs it through the mapper's `toRemote`
3. If the record has a NetSuite internal ID, sends a PATCH update
4. If it doesn't, sends a POST create with an idempotency key
The idempotency key is deterministic: `operation:recordType:localId:hourBucket`. This means retrying a failed create within the same hour reuses the key, preventing duplicate records in NetSuite. After an hour, a new key is generated so the operation can be retried if the previous attempt genuinely didn't go through.
Failed pushes are retried up to 3 times for retryable errors. Non-retryable errors mark the sync metadata as `error` with the failure message.
schema
---
The NetSuite module defines three sync-infrastructure tables and four financial tables in `src/db/schema-netsuite.ts`:
- `netsuite_auth` -- encrypted OAuth tokens per account
- `netsuite_sync_metadata` -- per-record sync status, conflict data, retry counts
- `netsuite_sync_log` -- sync run history with timing and error summaries
- `invoices` -- customer invoices (tied to customers and projects)
- `vendor_bills` -- vendor bills (tied to vendors and projects)
- `payments` -- incoming/outgoing payments
- `credit_memos` -- customer credit memos
All financial tables have a `netsuiteId` column for tracking the link to NetSuite's internal record ID.
The core schema also includes `netsuiteId` on `customers` and `vendors`, and `netsuiteJobId` on `projects`. These columns are only meaningful when the NetSuite module is active.
server actions
---
`src/app/actions/netsuite-sync.ts` exposes the sync functionality to the UI:
- `getNetSuiteConnectionStatus()` -- checks if tokens exist
- `initiateNetSuiteOAuth()` -- generates the authorize URL with a random state parameter
- `disconnectNetSuite()` -- clears stored tokens
- `syncCustomers()` / `syncVendors()` -- trigger pull operations for each entity type
- `getSyncHistory()` -- returns recent sync log entries
- `getConflicts()` -- returns records in conflict state
- `resolveConflict(metaId, resolution)` -- applies "use_local" or "use_remote" resolution
Every action checks RBAC permissions (`finance:read` for queries, `organization:update` for connection management).
UI components
---
Four components in `src/components/netsuite/`:
- `connection-status.tsx` -- shows whether NetSuite is configured and connected, with the account ID
- `sync-controls.tsx` -- trigger sync buttons for customers and vendors, shows sync history
- `conflict-dialog.tsx` -- modal for reviewing and resolving sync conflicts
- `sync-status-badge.tsx` -- inline badge showing sync state (synced, pending, error, conflict)
gotchas
---
If you're working on the NetSuite integration, know these:
1. **401 can mean timeout.** NetSuite sometimes returns 401 when a request times out. The error classifier checks the response body for "timeout" or "ETIMEDOUT" to detect this.
2. **"Field doesn't exist" usually means permission denied.** When the integration role can't access a field, the REST API says the field doesn't exist instead of returning 403.
3. **15 concurrent requests, shared globally.** The limit applies across ALL integrations on the account. Your REST integration competes with SOAP integrations, RESTlets, and scheduled scripts.
4. **No batch create/update via REST.** Every record must be created or updated individually. The SuiteQL client can batch-read, but writes are always one-at-a-time.
5. **Sandbox URLs use different separators.** Account ID `123456_SB1` becomes `123456-sb1` in REST URLs. The config handles this with `.toLowerCase().replace("_", "-")`.
6. **Omitting the "line" parameter on line items adds a new line.** If you PATCH an invoice and include line items without the `line` field, NetSuite creates new line items instead of updating existing ones. This is by design in the REST API.
7. **"Invalid Login Attempt" on 401 is often rate limiting.** SOAP connections that exceed the concurrent limit get this error. The classifier treats it as rate limiting with a 5-second backoff.