feat: add Finance MCP workflow
Some checks failed
Some checks failed
This commit is contained in:
394
apps/finance-mcp-service/src/client/finance-client.ts
Normal file
394
apps/finance-mcp-service/src/client/finance-client.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
interface FinanceEnvelope<T> {
|
||||
code: number;
|
||||
message?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface BasicCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface FinanceClientConfig {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
basicAuth?: BasicCredentials;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ListTransactionsParams {
|
||||
type?: string;
|
||||
statuses?: string[];
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface ListReimbursementsParams {
|
||||
type?: string;
|
||||
statuses?: string[];
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface ListMediaParams {
|
||||
limit?: number;
|
||||
fileTypes?: string[];
|
||||
}
|
||||
|
||||
export interface ListExchangeRatesParams {
|
||||
fromCurrency?: string;
|
||||
toCurrency?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface TelegramConfigPayload {
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
chatId?: string;
|
||||
notificationTypes?: string[];
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class FinanceClient {
|
||||
private readonly apiKey?: string;
|
||||
|
||||
private readonly baseUrl: string;
|
||||
|
||||
private readonly basicAuth?: BasicCredentials;
|
||||
|
||||
private readonly timeoutMs?: number;
|
||||
|
||||
constructor(config: FinanceClientConfig) {
|
||||
if (!config?.baseUrl) {
|
||||
throw new Error('FinanceClient requires a baseUrl');
|
||||
}
|
||||
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = config.apiKey;
|
||||
this.basicAuth = validateBasicAuth(config.basicAuth);
|
||||
this.timeoutMs = config.timeoutMs;
|
||||
}
|
||||
|
||||
createBudget(payload: unknown) {
|
||||
return this.post('/api/finance/budgets', payload);
|
||||
}
|
||||
|
||||
createCategory(payload: unknown) {
|
||||
return this.post('/api/finance/categories', payload);
|
||||
}
|
||||
|
||||
createReimbursement(payload: unknown) {
|
||||
return this.post('/api/finance/reimbursements', payload);
|
||||
}
|
||||
|
||||
createReimbursementMedia(payload: unknown) {
|
||||
return this.post('/api/finance/media', payload);
|
||||
}
|
||||
|
||||
createTelegramConfig(payload: TelegramConfigPayload) {
|
||||
return this.post('/api/telegram/notifications', payload);
|
||||
}
|
||||
|
||||
createTransaction(payload: unknown) {
|
||||
return this.post('/api/finance/transactions', payload);
|
||||
}
|
||||
|
||||
deleteBudget(id: number) {
|
||||
return this.delete(`/api/finance/budgets/${id}`);
|
||||
}
|
||||
|
||||
deleteCategory(id: number) {
|
||||
return this.delete(`/api/finance/categories/${id}`);
|
||||
}
|
||||
|
||||
deleteTelegramConfig(id: number) {
|
||||
return this.delete(`/api/telegram/notifications/${id}`);
|
||||
}
|
||||
|
||||
deleteTransaction(id: number) {
|
||||
return this.delete(`/api/finance/transactions/${id}`);
|
||||
}
|
||||
|
||||
downloadMedia(id: number) {
|
||||
return this.download(`/api/finance/media/${id}/download`);
|
||||
}
|
||||
|
||||
getMediaById(id: number) {
|
||||
return this.get(`/api/finance/media/${id}`);
|
||||
}
|
||||
|
||||
listAccounts(params: { currency?: string } = {}) {
|
||||
return this.get('/api/finance/accounts', params);
|
||||
}
|
||||
|
||||
listBudgets() {
|
||||
return this.get('/api/finance/budgets');
|
||||
}
|
||||
|
||||
listCategories(params: { type?: string } = {}) {
|
||||
return this.get('/api/finance/categories', params);
|
||||
}
|
||||
|
||||
listCurrencies() {
|
||||
return this.get('/api/finance/currencies');
|
||||
}
|
||||
|
||||
listExchangeRates(params: ListExchangeRatesParams = {}) {
|
||||
const query: Record<string, string> = {};
|
||||
if (params.fromCurrency) query.from = params.fromCurrency;
|
||||
if (params.toCurrency) query.to = params.toCurrency;
|
||||
if (params.date) query.date = params.date;
|
||||
return this.get('/api/finance/exchange-rates', query);
|
||||
}
|
||||
|
||||
listMedia(params: ListMediaParams = {}) {
|
||||
const query: Record<string, number | string> = {};
|
||||
if (typeof params.limit === 'number') query.limit = params.limit;
|
||||
if (params.fileTypes?.length) query.types = params.fileTypes.join(',');
|
||||
return this.get('/api/finance/media', query);
|
||||
}
|
||||
|
||||
listReimbursements(params: ListReimbursementsParams = {}) {
|
||||
const query: Record<string, boolean | string> = {};
|
||||
if (params.type) query.type = params.type;
|
||||
if (params.statuses?.length) query.statuses = params.statuses.join(',');
|
||||
if (params.includeDeleted !== undefined)
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
return this.get('/api/finance/reimbursements', query);
|
||||
}
|
||||
|
||||
listTelegramConfigs() {
|
||||
return this.get('/api/telegram/notifications');
|
||||
}
|
||||
|
||||
listTransactions(params: ListTransactionsParams = {}) {
|
||||
const query: Record<string, boolean | string> = {};
|
||||
if (params.type) query.type = params.type;
|
||||
if (params.statuses?.length) query.statuses = params.statuses.join(',');
|
||||
if (params.includeDeleted !== undefined)
|
||||
query.includeDeleted = params.includeDeleted;
|
||||
return this.get('/api/finance/transactions', query);
|
||||
}
|
||||
|
||||
testTelegramConfig(payload: { botToken: string; chatId: string }) {
|
||||
return this.post('/api/telegram/test', payload);
|
||||
}
|
||||
|
||||
updateBudget(id: number, payload: unknown) {
|
||||
return this.put(`/api/finance/budgets/${id}`, payload);
|
||||
}
|
||||
|
||||
updateCategory(id: number, payload: unknown) {
|
||||
return this.put(`/api/finance/categories/${id}`, payload);
|
||||
}
|
||||
|
||||
updateReimbursement(id: number, payload: unknown) {
|
||||
return this.put(`/api/finance/reimbursements/${id}`, payload);
|
||||
}
|
||||
|
||||
updateTelegramConfig(id: number, payload: TelegramConfigPayload) {
|
||||
return this.put(`/api/telegram/notifications/${id}`, payload);
|
||||
}
|
||||
|
||||
updateTransaction(id: number, payload: unknown) {
|
||||
return this.put(`/api/finance/transactions/${id}`, payload);
|
||||
}
|
||||
|
||||
private buildHeaders(json: boolean) {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (json) headers['Content-Type'] = 'application/json';
|
||||
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
|
||||
else if (this.basicAuth)
|
||||
headers.Authorization = `Basic ${createBasicToken(this.basicAuth)}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
private createUrl(path: string) {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
return new URL(normalized, this.baseUrl);
|
||||
}
|
||||
|
||||
private async delete(path: string) {
|
||||
return this.request('DELETE', path);
|
||||
}
|
||||
|
||||
private async download(path: string) {
|
||||
const url = this.createUrl(path);
|
||||
const response = await this.performFetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.buildHeaders(false),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload =
|
||||
await this.safeParseEnvelope<FinanceEnvelope<unknown>>(response);
|
||||
if (payload) {
|
||||
throw new Error(payload.message || 'Failed to download media file');
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to download media file (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
return {
|
||||
fileName: this.extractFileName(
|
||||
response.headers.get('content-disposition'),
|
||||
),
|
||||
mimeType:
|
||||
response.headers.get('content-type') ?? 'application/octet-stream',
|
||||
size: buffer.byteLength,
|
||||
base64: buffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
private extractFileName(contentDisposition: null | string) {
|
||||
if (!contentDisposition) return undefined;
|
||||
|
||||
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
|
||||
if (filenameStar?.[1]) {
|
||||
const value = filenameStar[1].replace(/^UTF-8''/, '');
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const filename = contentDisposition.match(/filename="?([^";]+)"?/i);
|
||||
return filename?.[1];
|
||||
}
|
||||
|
||||
private async get(path: string, query?: Record<string, unknown>) {
|
||||
return this.request('GET', path, { query });
|
||||
}
|
||||
|
||||
private async parseEnvelope(response: Response, path: string) {
|
||||
const payload =
|
||||
await this.safeParseEnvelope<FinanceEnvelope<unknown>>(response);
|
||||
|
||||
if (!payload) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Unexpected response from ${path}: ${text || response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload.message ||
|
||||
`Finance API request failed (HTTP ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async performFetch(url: URL, init: RequestInit) {
|
||||
const controller = this.timeoutMs ? new AbortController() : undefined;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
if (controller) {
|
||||
init.signal = controller.signal;
|
||||
timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error: unknown) {
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
throw new Error(`Request to ${url.pathname} timed out`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async post(path: string, body?: unknown) {
|
||||
return this.request('POST', path, { body });
|
||||
}
|
||||
|
||||
private async put(path: string, body?: unknown) {
|
||||
return this.request('PUT', path, { body });
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: 'DELETE' | 'GET' | 'POST' | 'PUT',
|
||||
path: string,
|
||||
options: {
|
||||
body?: unknown;
|
||||
query?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const url = this.createUrl(path);
|
||||
|
||||
if (options.query) {
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) url.searchParams.set(key, value.join(','));
|
||||
} else if (typeof value === 'boolean') {
|
||||
url.searchParams.set(key, value ? 'true' : 'false');
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.performFetch(url, {
|
||||
method,
|
||||
headers: this.buildHeaders(method !== 'GET' && method !== 'DELETE'),
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
const payload = await this.parseEnvelope(response, path);
|
||||
if (payload.code !== 0) {
|
||||
throw new Error(payload.message || 'Finance API returned an error');
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
private async safeParseEnvelope<T>(response: Response) {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) return null;
|
||||
|
||||
try {
|
||||
return (await response.clone().json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BasicAuthLike = Partial<
|
||||
BasicCredentials & {
|
||||
login: string;
|
||||
pass: string;
|
||||
user: string;
|
||||
}
|
||||
>;
|
||||
|
||||
const validateBasicAuth = (
|
||||
credentials?: BasicAuthLike | null,
|
||||
): BasicCredentials | undefined => {
|
||||
if (!credentials) return undefined;
|
||||
const username =
|
||||
credentials.username ?? credentials.user ?? credentials.login;
|
||||
const password = credentials.password ?? credentials.pass;
|
||||
|
||||
if (!username && !password) return undefined;
|
||||
if (!username || !password) {
|
||||
throw new Error(
|
||||
'FinanceClient basicAuth requires both username and password',
|
||||
);
|
||||
}
|
||||
|
||||
return { username: String(username), password: String(password) };
|
||||
};
|
||||
|
||||
const createBasicToken = ({ username, password }: BasicCredentials) =>
|
||||
Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
|
||||
Reference in New Issue
Block a user