- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts) - Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts) - Add QBO types (lib/types/qbo.ts) - Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect - Add /admin/qbo status and sync management page - Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment) - Add QBO nav link under Admin - Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all - Add CashFlow report type alongside P&L and BalanceSheet - Add NoReportData check to skip empty report months - Add intuit_tid capture in error messages - Add redirect: follow for cluster routing - Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables Also includes earlier work: - Ping flap suppression pipeline step - Ticket digest reports with LLM analysis - Zabbix WAN monitor and gap analysis - Kiosk is_deleted filter fixes - Datto RMM ping target enrichment - Entity sync soft-delete detection
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
/**
|
|
* QuickBooks Online API Client
|
|
* Handles OAuth2 token management and all QBO REST API calls.
|
|
*/
|
|
|
|
import postgresClient from './postgres-client';
|
|
import {
|
|
QboTokenRecord,
|
|
QboTokenResponse,
|
|
QboInvoice,
|
|
QboPayment,
|
|
QboDeposit,
|
|
QboPurchase,
|
|
QboJournalEntry,
|
|
QboReport,
|
|
QboQueryResponse,
|
|
} from '@/lib/types/qbo';
|
|
|
|
const QBO_PRODUCTION_URL = 'https://quickbooks.api.intuit.com';
|
|
const QBO_SANDBOX_URL = 'https://sandbox-quickbooks.api.intuit.com';
|
|
const QBO_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
|
|
|
|
export class QboClient {
|
|
private clientId: string;
|
|
private clientSecret: string;
|
|
private realmId: string;
|
|
private baseUrl: string;
|
|
private _reportBasis: string | null = null;
|
|
|
|
constructor() {
|
|
this.clientId = process.env.QBO_CLIENT_ID || '';
|
|
this.clientSecret = process.env.QBO_CLIENT_SECRET || '';
|
|
this.realmId = process.env.QBO_REALM_ID || '';
|
|
this.baseUrl = process.env.QBO_SANDBOX === 'true' ? QBO_SANDBOX_URL : QBO_PRODUCTION_URL;
|
|
|
|
if (!this.clientId || !this.clientSecret || !this.realmId) {
|
|
throw new Error('QBO_CLIENT_ID, QBO_CLIENT_SECRET, and QBO_REALM_ID must be set');
|
|
}
|
|
|
|
console.log(`[QboClient] Using ${process.env.QBO_SANDBOX === 'true' ? 'SANDBOX' : 'PRODUCTION'} environment`);
|
|
}
|
|
|
|
// ─── Token Management ───────────────────────────────────────────────────────
|
|
|
|
async getValidAccessToken(): Promise<string> {
|
|
const token = await this.loadToken();
|
|
if (!token) {
|
|
throw new Error('No QBO token found. Complete OAuth2 authorization first via /api/qbo/auth');
|
|
}
|
|
|
|
if (new Date() < new Date(token.access_token_expires_at)) {
|
|
return token.access_token;
|
|
}
|
|
|
|
if (new Date() >= new Date(token.refresh_token_expires_at)) {
|
|
throw new Error('QBO refresh token has expired. Re-authorization required via /api/qbo/auth');
|
|
}
|
|
|
|
return this.refreshAccessToken(token.refresh_token);
|
|
}
|
|
|
|
async loadToken(): Promise<QboTokenRecord | null> {
|
|
const result = await postgresClient.query<QboTokenRecord>(
|
|
`SELECT * FROM qbo_tokens WHERE realm_id = $1 LIMIT 1`,
|
|
[this.realmId]
|
|
);
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
async saveToken(token: QboTokenResponse): Promise<void> {
|
|
const now = new Date();
|
|
const accessExpiry = new Date(now.getTime() + token.expires_in * 1000);
|
|
const refreshExpiry = new Date(now.getTime() + token.x_refresh_token_expires_in * 1000);
|
|
|
|
await postgresClient.query(
|
|
`INSERT INTO qbo_tokens (realm_id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
ON CONFLICT (realm_id) DO UPDATE SET
|
|
access_token = EXCLUDED.access_token,
|
|
refresh_token = EXCLUDED.refresh_token,
|
|
access_token_expires_at = EXCLUDED.access_token_expires_at,
|
|
refresh_token_expires_at = EXCLUDED.refresh_token_expires_at,
|
|
updated_at = NOW()`,
|
|
[this.realmId, token.access_token, token.refresh_token, accessExpiry, refreshExpiry]
|
|
);
|
|
}
|
|
|
|
async exchangeCodeForToken(authCode: string, redirectUri: string): Promise<QboTokenResponse> {
|
|
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
|
|
const body = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code: authCode,
|
|
redirect_uri: redirectUri,
|
|
});
|
|
|
|
const res = await fetch(QBO_TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Basic ${credentials}`,
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
throw new Error(`QBO token exchange failed: ${res.status} ${err}`);
|
|
}
|
|
|
|
const tokenData: QboTokenResponse = await res.json();
|
|
await this.saveToken(tokenData);
|
|
return tokenData;
|
|
}
|
|
|
|
private async refreshAccessToken(refreshToken: string): Promise<string> {
|
|
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
|
|
const body = new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
});
|
|
|
|
const res = await fetch(QBO_TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Basic ${credentials}`,
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
throw new Error(`QBO token refresh failed: ${res.status} ${err}`);
|
|
}
|
|
|
|
const tokenData: QboTokenResponse = await res.json();
|
|
await this.saveToken(tokenData);
|
|
console.log('[QboClient] Access token refreshed successfully');
|
|
return tokenData.access_token;
|
|
}
|
|
|
|
getAuthorizationUrl(redirectUri: string, state: string): string {
|
|
const params = new URLSearchParams({
|
|
client_id: this.clientId,
|
|
scope: 'com.intuit.quickbooks.accounting',
|
|
redirect_uri: redirectUri,
|
|
response_type: 'code',
|
|
state,
|
|
});
|
|
return `https://appcenter.intuit.com/connect/oauth2?${params.toString()}`;
|
|
}
|
|
|
|
// ─── Core API Request ────────────────────────────────────────────────────────
|
|
|
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const accessToken = await this.getValidAccessToken();
|
|
const url = `${this.baseUrl}/v3/company/${this.realmId}${path}`;
|
|
|
|
const res = await fetch(url, {
|
|
...options,
|
|
redirect: 'follow',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
});
|
|
|
|
const intuitTid = res.headers.get('intuit_tid') || res.headers.get('intuit-tid') || 'unknown';
|
|
|
|
if (!res.ok) {
|
|
const err = await res.text();
|
|
throw new Error(`QBO API error ${res.status} on ${path} [intuit_tid=${intuitTid}]: ${err}`);
|
|
}
|
|
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
// ─── Paginated Query ─────────────────────────────────────────────────────────
|
|
|
|
private async queryAll<T>(entity: string, extraWhere = ''): Promise<T[]> {
|
|
const all: T[] = [];
|
|
let startPos = 1;
|
|
const pageSize = 1000;
|
|
|
|
while (true) {
|
|
const where = extraWhere ? ` WHERE ${extraWhere}` : '';
|
|
const sql = encodeURIComponent(
|
|
`SELECT * FROM ${entity}${where} STARTPOSITION ${startPos} MAXRESULTS ${pageSize}`
|
|
);
|
|
const data = await this.request<QboQueryResponse<T>>(`/query?query=${sql}&minorversion=65`);
|
|
const items: T[] = (data.QueryResponse[entity] as T[]) || [];
|
|
all.push(...items);
|
|
|
|
if (items.length < pageSize) break;
|
|
startPos += pageSize;
|
|
}
|
|
|
|
return all;
|
|
}
|
|
|
|
// ─── Entity Fetchers ─────────────────────────────────────────────────────────
|
|
|
|
async getInvoices(updatedSince?: Date): Promise<QboInvoice[]> {
|
|
const where = updatedSince
|
|
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
|
: undefined;
|
|
return this.queryAll<QboInvoice>('Invoice', where);
|
|
}
|
|
|
|
async getPayments(updatedSince?: Date): Promise<QboPayment[]> {
|
|
const where = updatedSince
|
|
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
|
: undefined;
|
|
return this.queryAll<QboPayment>('Payment', where);
|
|
}
|
|
|
|
async getDeposits(updatedSince?: Date): Promise<QboDeposit[]> {
|
|
const where = updatedSince
|
|
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
|
: undefined;
|
|
return this.queryAll<QboDeposit>('Deposit', where);
|
|
}
|
|
|
|
async getPurchases(updatedSince?: Date): Promise<QboPurchase[]> {
|
|
const where = updatedSince
|
|
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
|
: undefined;
|
|
return this.queryAll<QboPurchase>('Purchase', where);
|
|
}
|
|
|
|
async getJournalEntries(updatedSince?: Date): Promise<QboJournalEntry[]> {
|
|
const where = updatedSince
|
|
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
|
: undefined;
|
|
return this.queryAll<QboJournalEntry>('JournalEntry', where);
|
|
}
|
|
|
|
async getPreferences(): Promise<{ reportBasis: string }> {
|
|
if (this._reportBasis) return { reportBasis: this._reportBasis };
|
|
const data = await this.request<{ Preferences?: { ReportPrefs?: { ReportBasis?: string } } }>('/preferences?minorversion=65');
|
|
this._reportBasis = data?.Preferences?.ReportPrefs?.ReportBasis ?? 'Accrual';
|
|
return { reportBasis: this._reportBasis };
|
|
}
|
|
|
|
async getProfitAndLoss(startDate: string, endDate: string, accountingMethod?: string): Promise<QboReport> {
|
|
const params = new URLSearchParams({
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
accounting_method: accountingMethod ?? 'Accrual',
|
|
showrows: 'all',
|
|
showcols: 'all',
|
|
minorversion: '65',
|
|
});
|
|
return this.request<QboReport>(`/reports/ProfitAndLoss?${params.toString()}`);
|
|
}
|
|
|
|
async getBalanceSheet(startDate: string, endDate: string, accountingMethod?: string): Promise<QboReport> {
|
|
const params = new URLSearchParams({
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
accounting_method: accountingMethod ?? 'Accrual',
|
|
showrows: 'all',
|
|
showcols: 'all',
|
|
minorversion: '65',
|
|
});
|
|
return this.request<QboReport>(`/reports/BalanceSheet?${params.toString()}`);
|
|
}
|
|
|
|
async getCashFlow(startDate: string, endDate: string): Promise<QboReport> {
|
|
const params = new URLSearchParams({
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
minorversion: '65',
|
|
});
|
|
return this.request<QboReport>(`/reports/CashFlow?${params.toString()}`);
|
|
}
|
|
}
|
|
|
|
let _instance: QboClient | null = null;
|
|
|
|
export function getQboClient(): QboClient {
|
|
if (!_instance) {
|
|
_instance = new QboClient();
|
|
}
|
|
return _instance;
|
|
}
|