UNPKG

das-budget-sdk

Version:

An UNOFFICIAL TypeScript SDK for interacting with the DAS Budget API

328 lines (327 loc) 13.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const axios_1 = __importDefault(require("axios")); const crypto_1 = __importDefault(require("crypto")); const types_1 = require("./types"); // eslint-disable-next-line @typescript-eslint/no-var-requires const { version } = require("../package.json"); class DasBudget { constructor(config) { this.accessToken = null; this.tokenExpiry = null; this.userId = null; this.budgetId = null; this.baseUrl = "https://api.dasbudget.com"; this.refreshToken = config.refreshToken; this.apiKey = config.apiKey; this.debug = config.debug || false; } log(message) { if (this.debug) { console.log(`[DasBudget SDK] ${message}`); } } async refreshAccessToken() { try { this.log("Refreshing access token..."); const params = new URLSearchParams({ grant_type: "refresh_token", refresh_token: this.refreshToken, }); const response = await axios_1.default.post(`https://securetoken.googleapis.com/v1/token?key=${this.apiKey}`, params.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "*/*", }, }); const token = response.data.id_token ?? response.data.access_token; const expiresInSeconds = Number(response.data.expires_in); if (!token) { throw new Error("Token refresh response did not include id_token or access_token"); } if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) { throw new Error(`Token refresh response has invalid expires_in: ${response.data.expires_in}`); } this.accessToken = token; this.tokenExpiry = Date.now() + expiresInSeconds * 1000; this.userId = response.data.user_id ?? this.userId; this.refreshToken = response.data.refresh_token ?? this.refreshToken; this.log("Access token refreshed successfully"); } catch (error) { this.log("Error refreshing access token"); throw error; } } async ensureValidToken() { const FIVE_MINUTES = 300000; // Get new token if within 5 minutes of expiry if (!this.accessToken || !this.tokenExpiry || Date.now() >= this.tokenExpiry - FIVE_MINUTES) { await this.refreshAccessToken(); } } /** * Sets the budget ID to use for all future API calls. * If not set, the oldest budget will be used by default. * @param budgetId The ID of the budget to use */ setBudgetId(budgetId) { this.budgetId = budgetId; this.log(`Set budget ID to: ${budgetId ?? "null"}`); } getHeaders(options) { return { Authorization: `Bearer ${this.accessToken}`, Accept: "*/*", "Cache-Control": "no-cache", Pragma: "no-cache", Origin: "https://app.dasbudget.com", Referer: "https://app.dasbudget.com/", "X-Das-Context-Id": options?.budgetId ?? this.budgetId ?? "null", "X-Das-Platform": "web", "X-Das-Build": "216", "X-Das-Version": "0.12.0", "User-Agent": `klinquist/das-budget-sdk/${version}`, }; } async initialize() { this.log("Initializing SDK..."); await this.refreshAccessToken(); this.log("SDK initialized successfully"); } async transactions(options) { await this.ensureValidToken(); this.log("Fetching transactions..."); const since = options?.since; if (since !== undefined) { // Validate the since parameter if (typeof since !== "number" || isNaN(since)) { throw new Error("since parameter must be a valid number (seconds since epoch)"); } // Log the since value in different formats for debugging this.log(`since parameter value: ${since}`); this.log(`since as milliseconds: ${since * 1000}`); this.log(`since as date: ${new Date(since * 1000).toISOString()}`); } const allTransactions = []; let currentPage = 1; const limit = 40; // Using the API's default limit let hasMorePages = true; let totalFetched = 0; while (hasMorePages) { try { this.log(`Fetching page ${currentPage} with limit ${limit}...`); const response = await axios_1.default.get(`${this.baseUrl}/api/transaction`, { params: { page: currentPage, limit, types: "checking,credit card", }, headers: this.getHeaders({ budgetId: options?.budgetId, }), }); const { transactions, total } = response.data; this.log(`API Response - Page: ${currentPage}, Total: ${total}, Fetched: ${transactions.length}`); this.log(`Fetched ${transactions.length} transactions from page ${currentPage}`); // If we have a since parameter, filter transactions if (since !== undefined) { const filteredTransactions = transactions.filter((tx) => { const createdAt = new Date(tx.created_at).getTime() / 1000; this.log(`Transaction ${tx.id} created at: ${new Date(tx.created_at).toISOString()} (${createdAt})`); this.log(`Comparing: ${createdAt} >= ${since} = ${createdAt >= since}`); return createdAt >= since; }); this.log(`Found ${filteredTransactions.length} transactions after ${new Date(since * 1000).toISOString()}`); allTransactions.push(...filteredTransactions); totalFetched += filteredTransactions.length; // Only stop pagination if we got no transactions in this page // or if we got fewer transactions than the limit and none of them match our filter if (transactions.length === 0 || (transactions.length < limit && filteredTransactions.length === 0)) { this.log("Reached end of transactions - no more matching transactions found"); hasMorePages = false; } else { currentPage++; this.log(`Moving to page ${currentPage} - found ${filteredTransactions.length} matching transactions`); } } else { // If no since parameter, just return the first page this.log("No since parameter provided, returning first page only"); return transactions; } } catch (error) { this.log("Error fetching transactions"); throw error; } } this.log(`Returning ${allTransactions.length} total transactions (${totalFetched} fetched across ${currentPage - 1} pages)`); return allTransactions; } async getBucketsByKind(kind, options) { await this.ensureValidToken(); this.log(`Fetching ${kind}s...`); try { const response = await axios_1.default.get(`${this.baseUrl}/api/bucket`, { params: { page: 1, limit: 1000, kind, sort: "schedule_date,name_clean", }, headers: this.getHeaders(options), }); return response.data.items; } catch (error) { this.log(`Error fetching ${kind}s`); throw error; } } async expenses(options) { return this.getBucketsByKind("expense", options); } async goals(options) { return this.getBucketsByKind("goal", options); } async vaults(options) { return this.getBucketsByKind("vault", options); } async accounts(options) { await this.ensureValidToken(); this.log("Fetching accounts..."); try { const response = await axios_1.default.get(`${this.baseUrl}/api/item/account`, { params: { types: "checking,credit card", }, headers: this.getHeaders(options), }); return response.data.items; } catch (error) { this.log("Error fetching accounts"); throw error; } } async assignTransactionToBucket(options) { await this.ensureValidToken(); this.log(`Assigning transaction ${options.transactionId} to bucket ${options.bucketId}...`); try { const actualBucketId = options.bucketId === types_1.FREE_TO_SPEND ? "fts" : options.bucketId; const response = await axios_1.default.post(`${this.baseUrl}/api/item/swap/${options.transactionId}/${actualBucketId}`, {}, { headers: this.getHeaders({ budgetId: options.budgetId }), }); return response.data; } catch (error) { this.log("Error assigning transaction to bucket"); throw error; } } async refreshes(options) { await this.ensureValidToken(); this.log("Fetching refresh information..."); try { const response = await axios_1.default.get(`${this.baseUrl}/api/item/refreshes`, { headers: this.getHeaders(options), }); return response.data; } catch (error) { this.log("Error fetching refresh information"); throw error; } } /** * Refreshes the data for a specific account. * * @param options - The refresh options * @param options.itemId - The ID of the item to refresh (required) * @param options.usePremium - Whether to use premium refresh credits (optional, defaults to false) * @param options.budgetId - The ID of the budget to use (optional, defaults to the currently set budget) * * @throws {Error} If accountId is not provided * @throws {Error} If the account refresh fails * * @example * ```typescript * // Basic usage * await dasBudget.refresh({ itemId: "account-123" }); * * // Using premium refresh * await dasBudget.refresh({ * itemId: "item-123", * usePremium: true, * budgetId: "budget-456" * }); * ``` */ async refresh(options) { if (!options?.itemId) { throw new Error("itemId is required for refresh"); } if (typeof options.itemId !== "string" || options.itemId.trim() === "") { throw new Error("itemId must be a non-empty string"); } if (options.usePremium !== undefined && typeof options.usePremium !== "boolean") { throw new Error("usePremium must be a boolean if provided"); } if (options.budgetId !== undefined && (typeof options.budgetId !== "string" || options.budgetId.trim() === "")) { throw new Error("budgetId must be a non-empty string if provided"); } await this.ensureValidToken(); this.log(`Refreshing item ${options.itemId}...`); try { await axios_1.default.post(`${this.baseUrl}/api/item/${options.itemId}/refresh`, { use_premium: options.usePremium ?? false, idempotency_key: crypto_1.default.randomUUID(), user_initiated: true, }, { headers: this.getHeaders(), }); } catch (error) { this.log("Error refreshing account"); throw error; } } async budgets() { await this.ensureValidToken(); this.log("Fetching budgets..."); try { const response = await axios_1.default.get(`${this.baseUrl}/api/context`, { headers: this.getHeaders(), }); return response.data.items; } catch (error) { this.log("Error fetching budgets"); throw error; } } async items(options) { await this.ensureValidToken(); this.log("Fetching items..."); try { const response = await axios_1.default.get(`${this.baseUrl}/api/item`, { headers: this.getHeaders(options), }); return response.data.items; } catch (error) { this.log("Error fetching items"); throw error; } } } exports.default = DasBudget;