291 lines
10 KiB
TypeScript
291 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;
|
||
|
|
}
|