UNPKG

das-budget-sdk

Version:

An UNOFFICIAL TypeScript SDK for interacting with the DAS Budget API

461 lines (410 loc) 13.3 kB
import axios from "axios"; import crypto from "crypto"; import { DasBudgetConfig, TokenResponse, Account, Transaction, Bucket, RefreshesResponse, Budget, BudgetsResponse, FREE_TO_SPEND, TransactionsOptions, AssignTransactionOptions, ApiOptions, PaginatedResponse, TransactionsResponse, AccountsResponse, RefreshOptions, AccountItem, ItemsResponse, } from "./types"; // eslint-disable-next-line @typescript-eslint/no-var-requires const { version } = require("../package.json"); export default class DasBudget { private refreshToken: string; private apiKey: string; private debug: boolean; private accessToken: string | null = null; private tokenExpiry: number | null = null; private userId: string | null = null; private budgetId: string | null = null; private readonly baseUrl = "https://api.dasbudget.com"; constructor(config: DasBudgetConfig) { this.refreshToken = config.refreshToken; this.apiKey = config.apiKey; this.debug = config.debug || false; } private log(message: string) { if (this.debug) { console.log(`[DasBudget SDK] ${message}`); } } private async refreshAccessToken(): Promise<void> { try { this.log("Refreshing access token..."); const params = new URLSearchParams({ grant_type: "refresh_token", refresh_token: this.refreshToken, }); const response = await axios.post<TokenResponse>( `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; } } private async ensureValidToken(): Promise<void> { 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 */ public setBudgetId(budgetId: string | null): void { this.budgetId = budgetId; this.log(`Set budget ID to: ${budgetId ?? "null"}`); } private getHeaders(options?: ApiOptions): Record<string, string> { 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}`, }; } public async initialize(): Promise<void> { this.log("Initializing SDK..."); await this.refreshAccessToken(); this.log("SDK initialized successfully"); } public async transactions( options?: TransactionsOptions, ): Promise<Transaction[]> { 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: Transaction[] = []; 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.get<TransactionsResponse>( `${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; } private async getBucketsByKind( kind: "expense" | "goal" | "vault", options?: ApiOptions, ): Promise<Bucket[]> { await this.ensureValidToken(); this.log(`Fetching ${kind}s...`); try { const response = await axios.get<PaginatedResponse<Bucket>>( `${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; } } public async expenses(options?: ApiOptions): Promise<Bucket[]> { return this.getBucketsByKind("expense", options); } public async goals(options?: ApiOptions): Promise<Bucket[]> { return this.getBucketsByKind("goal", options); } public async vaults(options?: ApiOptions): Promise<Bucket[]> { return this.getBucketsByKind("vault", options); } public async accounts(options?: ApiOptions): Promise<Account[]> { await this.ensureValidToken(); this.log("Fetching accounts..."); try { const response = await axios.get<AccountsResponse>( `${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; } } public async assignTransactionToBucket( options: AssignTransactionOptions, ): Promise<Transaction> { await this.ensureValidToken(); this.log( `Assigning transaction ${options.transactionId} to bucket ${options.bucketId}...`, ); try { const actualBucketId = options.bucketId === FREE_TO_SPEND ? "fts" : options.bucketId; const response = await axios.post<Transaction>( `${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; } } public async refreshes(options?: ApiOptions): Promise<RefreshesResponse> { await this.ensureValidToken(); this.log("Fetching refresh information..."); try { const response = await axios.get<RefreshesResponse>( `${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" * }); * ``` */ public async refresh(options: RefreshOptions): Promise<void> { 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.post( `${this.baseUrl}/api/item/${options.itemId}/refresh`, { use_premium: options.usePremium ?? false, idempotency_key: crypto.randomUUID(), user_initiated: true, }, { headers: this.getHeaders(), }, ); } catch (error) { this.log("Error refreshing account"); throw error; } } public async budgets(): Promise<Budget[]> { await this.ensureValidToken(); this.log("Fetching budgets..."); try { const response = await axios.get<BudgetsResponse>( `${this.baseUrl}/api/context`, { headers: this.getHeaders(), }, ); return response.data.items; } catch (error) { this.log("Error fetching budgets"); throw error; } } public async items(options?: ApiOptions): Promise<AccountItem[]> { await this.ensureValidToken(); this.log("Fetching items..."); try { const response = await axios.get<ItemsResponse>( `${this.baseUrl}/api/item`, { headers: this.getHeaders(options), }, ); return response.data.items; } catch (error) { this.log("Error fetching items"); throw error; } } }