wulf-pulse/lib/services/qbo-client.ts
lorentz ef9b31e7c2 feat(260519-0oz-01): add QboPaymentCreatePayload, QboDepositCreatePayload types and createPayment/createDeposit methods to QboClient
- Add QboPaymentCreatePayload + QboDepositCreatePayload interfaces to lib/types/qbo.ts
- Add createPayment(payload) and createDeposit(payload) public methods to QboClient
- Both methods use existing private this.request<T>() with POST + minorversion=65
- Both methods throw descriptively if QBO returns no Id in response
2026-05-19 00:36:01 -04:00

351 lines
12 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,
QboPaymentCreatePayload,
QboDepositCreatePayload,
} 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);
}
// ─── Entity Creators ─────────────────────────────────────────────────────────
/**
* Create a Receive Payment in QBO and apply it to one or more invoices.
* Returns the created Payment as QBO echoes it back (includes Id, SyncToken).
*/
async createPayment(payload: QboPaymentCreatePayload): Promise<QboPayment> {
const data = await this.request<{ Payment: QboPayment }>(
`/payment?minorversion=65`,
{
method: 'POST',
body: JSON.stringify(payload),
},
);
if (!data?.Payment?.Id) {
throw new Error(`QBO createPayment returned no Payment: ${JSON.stringify(data).slice(0, 500)}`);
}
return data.Payment;
}
/**
* Create a Deposit in QBO that groups one or more existing Payments into a
* single bank-deposit line (so QBO's bank-feed reconciliation matches the
* bank's actual deposit slip).
*/
async createDeposit(payload: QboDepositCreatePayload): Promise<QboDeposit> {
const data = await this.request<{ Deposit: QboDeposit }>(
`/deposit?minorversion=65`,
{
method: 'POST',
body: JSON.stringify(payload),
},
);
if (!data?.Deposit?.Id) {
throw new Error(`QBO createDeposit returned no Deposit: ${JSON.stringify(data).slice(0, 500)}`);
}
return data.Deposit;
}
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()}`);
}
// A/R Aging Detail — one row per open invoice, with customer + balance + days
// past due. Used to reconcile Pulse's qbo_invoices against QBO's view of A/R.
async getAgedReceivableDetail(asOfDate?: string): Promise<QboReport> {
const params = new URLSearchParams({
report_date: asOfDate ?? new Date().toISOString().slice(0, 10),
aging_method: 'Report_Date',
minorversion: '65',
});
return this.request<QboReport>(`/reports/AgedReceivableDetail?${params.toString()}`);
}
async getAgedReceivableSummary(asOfDate?: string): Promise<QboReport> {
const params = new URLSearchParams({
report_date: asOfDate ?? new Date().toISOString().slice(0, 10),
aging_method: 'Report_Date',
minorversion: '65',
});
return this.request<QboReport>(`/reports/AgedReceivables?${params.toString()}`);
}
}
let _instance: QboClient | null = null;
export function getQboClient(): QboClient {
if (!_instance) {
_instance = new QboClient();
}
return _instance;
}