diff --git a/changelog.md b/changelog.md index 957c979..f1704c9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-07-29 - 4.4.0 - feat(export) +Added buffer download methods to ExportBuilder for in-memory statement handling + +- Added `download()` method to get statements as Buffer without saving to disk +- Added `downloadAsArrayBuffer()` method for web API compatibility +- Enhanced documentation for account's `getAccountStatement()` method with month-based selection +- Updated README with comprehensive examples for all statement export options + ## 2025-07-29 - 4.3.0 - feat(http) Enhanced HTTP client with automatic rate limit handling diff --git a/package.json b/package.json index 31b622f..0fc175f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apiclient.xyz/bunq", - "version": "4.3.0", + "version": "4.4.0", "private": false, "description": "A full-featured TypeScript/JavaScript client for the bunq API", "type": "module", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f19a7ea..d7905af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@push.rocks/smartpromise': specifier: ^4.2.3 version: 4.2.3 + '@push.rocks/smartrequest': + specifier: ^4.2.1 + version: 4.2.1 '@push.rocks/smarttime': specifier: ^4.0.54 version: 4.1.1 @@ -902,6 +905,9 @@ packages: '@push.rocks/smartrequest@2.1.0': resolution: {integrity: sha512-3eHLTRInHA+u+W98TqJwgTES7rRimBAsJC4JxVNQC3UUezmblAhM5/TIQsEBQTsbjAY8SeQKy6NHzW6iTiaD8w==} + '@push.rocks/smartrequest@4.2.1': + resolution: {integrity: sha512-33sxhXMOwDx2tv98LlyxDxI/UTjw16BOSWbnqrdUdNby/sSP3ahW3NF4JMOU5xKNQUz7TjLgREj9dPuumEgQ/g==} + '@push.rocks/smartrouter@1.3.3': resolution: {integrity: sha512-1+xZEnWlhzqLWAaJ1zFNhQ0zgbfCWQl1DBT72LygLxTs+P0K8AwJKgqo/IX6CT55kGCFnPAZIYSbVJlGsgrB0w==} @@ -6043,6 +6049,15 @@ snapshots: agentkeepalive: 4.6.0 form-data: 4.0.4 + '@push.rocks/smartrequest@4.2.1': + dependencies: + '@push.rocks/smartenv': 5.0.13 + '@push.rocks/smartpath': 6.0.0 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smarturl': 3.1.0 + agentkeepalive: 4.6.0 + form-data: 4.0.4 + '@push.rocks/smartrouter@1.3.3': dependencies: '@push.rocks/lik': 6.2.2 diff --git a/readme.md b/readme.md index 21577dc..235fd2d 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,34 @@ # @apiclient.xyz/bunq + +[![npm version](https://img.shields.io/npm/v/@apiclient.xyz/bunq.svg)](https://www.npmjs.com/package/@apiclient.xyz/bunq) +[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + A powerful, type-safe TypeScript/JavaScript client for the bunq API with full feature coverage +## Table of Contents +- [Features](#features) +- [Stateless Architecture](#stateless-architecture-v400) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Core Examples](#core-examples) + - [Account Management](#account-management) + - [Making Payments](#making-payments) + - [Payment Requests](#payment-requests) + - [Draft Payments](#draft-payments-requires-approval) + - [Card Management](#card-management) + - [Webhooks](#webhooks) + - [File Attachments](#file-attachments) + - [Export Statements](#export-statements) + - [Session Management](#stateless-session-management) + - [User Management](#user-management) +- [Advanced Usage](#advanced-usage) +- [Security Best Practices](#security-best-practices) +- [Migration Guides](#migration-guide) +- [Testing](#testing) +- [Requirements](#requirements) +- [License](#license-and-legal-information) + ## Features ### Core Banking Operations @@ -26,6 +54,7 @@ A powerful, type-safe TypeScript/JavaScript client for the bunq API with full fe - ⚡ **Promise-based** - Modern async/await support throughout - 🛡️ **Type Safety** - Compile-time type checking for all operations - 📚 **Comprehensive Documentation** - Detailed examples for every feature +- 🔄 **Robust HTTP Client** - Built-in retry logic and rate limit handling ## Stateless Architecture (v4.0.0+) @@ -405,6 +434,44 @@ await new ExportBuilder(bunq, account) .lastDays(30) .includeAttachments(true) .downloadTo('/path/to/statement-with-attachments.pdf'); + +// Get statement as Buffer (no file saving) +const buffer = await new ExportBuilder(bunq, account) + .asPdf() + .lastMonth() + .download(); +// Use the buffer directly, e.g., send as email attachment +await emailService.sendWithAttachment(buffer, 'statement.pdf'); + +// Get statement as ArrayBuffer for web APIs +const arrayBuffer = await new ExportBuilder(bunq, account) + .asCsv() + .lastDays(30) + .downloadAsArrayBuffer(); +// Use with web APIs like Blob +const blob = new Blob([arrayBuffer], { type: 'text/csv' }); + +// Using account's getAccountStatement method for easy month selection +const statement1 = account.getAccountStatement({ + monthlyIndexedFrom1: 1, // Last month (1 = last month, 2 = two months ago, etc.) + includeTransactionAttachments: true +}); +await statement1.asPdf().downloadTo('/path/to/last-month.pdf'); + +// Or using 0-based indexing +const statement2 = account.getAccountStatement({ + monthlyIndexedFrom0: 0, // Current month (0 = current, 1 = last month, etc.) + includeTransactionAttachments: false +}); +await statement2.asCsv().downloadTo('/path/to/current-month.csv'); + +// Or specify exact date range +const statement3 = account.getAccountStatement({ + from: new Date('2024-01-01'), + to: new Date('2024-03-31'), + includeTransactionAttachments: true +}); +await statement3.asMt940().downloadTo('/path/to/q1-statement.sta'); ``` ### Stateless Session Management @@ -552,10 +619,6 @@ try { error.errors.forEach(e => { console.error(`- ${e.error_description}`); }); - } else if (error.response?.status === 429) { - // Handle rate limiting - console.error('Rate limited. Please retry after a few seconds.'); - await new Promise(resolve => setTimeout(resolve, 5000)); } else if (error.response?.status === 401) { // Handle authentication errors console.error('Authentication failed:', error.message); @@ -567,6 +630,26 @@ try { } ``` +### Automatic Rate Limit Handling + +The library automatically handles rate limiting (HTTP 429 responses) with intelligent exponential backoff: + +```typescript +// No special handling needed - rate limits are handled automatically +const payments = await Promise.all([ + payment1.create(), + payment2.create(), + payment3.create(), + // ... many more payments +]); + +// The HTTP client will automatically: +// 1. Detect 429 responses +// 2. Wait for the time specified in Retry-After header +// 3. Use exponential backoff if no Retry-After is provided +// 4. Retry the request automatically (up to 3 times) +``` + ### Pagination ```typescript diff --git a/readme.plan.md b/readme.plan.md index 3848408..78551f7 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -1,71 +1,48 @@ -# bunq API Client Implementation Plan +# Migration Plan: @push.rocks/smartrequest -cat /home/philkunz/.claude/CLAUDE.md +To re-read CLAUDE.md: `cat ~/.claude/CLAUDE.md` -## Phase 1: Remove External Dependencies & Setup Core Infrastructure +## Objective +Migrate the Bunq HTTP client from native `fetch` to `@push.rocks/smartrequest` to leverage built-in rate limiting, better error handling, and improved maintainability. -- [x] Remove @bunq-community/bunq-js-client dependency from package.json -- [x] Remove JSONFileStore and bunqCommunityClient from bunq.plugins.ts -- [x] Create bunq.classes.apicontext.ts for API context management -- [x] Create bunq.classes.httpclient.ts for HTTP request handling -- [x] Create bunq.classes.crypto.ts for cryptographic operations -- [x] Create bunq.classes.session.ts for session management -- [x] Create bunq.interfaces.ts for shared interfaces and types +## Tasks -## Phase 2: Implement Core Authentication Flow +### 1. Setup +- [x] Install @push.rocks/smartrequest dependency using pnpm +- [x] Update ts/bunq.plugins.ts to import smartrequest -- [x] Implement RSA key pair generation in crypto class -- [x] Implement installation endpoint (`POST /v1/installation`) -- [x] Implement device registration (`POST /v1/device-server`) -- [x] Implement session creation (`POST /v1/session-server`) -- [x] Implement request signing mechanism -- [x] Implement response verification -- [x] Add session token refresh logic +### 2. Refactor BunqHttpClient +- [x] Replace fetch-based makeRequest method with SmartRequest implementation +- [x] Preserve all custom headers (X-Bunq-*) +- [x] Maintain request signing functionality +- [x] Keep response signature verification +- [x] Map LIST method to GET (SmartRequest doesn't have LIST) +- [x] Replace manual retry logic with built-in handle429Backoff() -## Phase 3: Update Existing Classes +### 3. Error Handling +- [x] Ensure BunqApiError is still thrown for API errors +- [x] Map SmartRequest errors to appropriate error messages +- [x] Preserve error message format for backward compatibility -- [x] Refactor BunqAccount class to use new HTTP client -- [x] Update BunqMonetaryAccount to work with new infrastructure -- [x] Update BunqTransaction to work with new infrastructure -- [x] Add proper TypeScript interfaces for all API responses -- [x] Implement error handling with bunq-specific error types +### 4. Testing +- [x] Run existing tests to ensure no regression (tests passing) +- [x] Verify rate limiting behavior works correctly +- [x] Test signature creation and verification +- [x] Ensure all HTTP methods (GET, POST, PUT, DELETE, LIST) work -## Phase 4: Implement Additional API Resources +### 5. Cleanup +- [x] Remove unused code from the old implementation (manual retry logic removed) +- [x] Update any relevant documentation or comments -- [x] Create bunq.classes.user.ts for user management -- [x] Create bunq.classes.payment.ts for payment operations -- [x] Create bunq.classes.card.ts for card management -- [x] Create bunq.classes.request.ts for payment requests -- [x] Create bunq.classes.schedule.ts for scheduled payments -- [x] Create bunq.classes.draft.ts for draft payments -- [x] Create bunq.classes.attachment.ts for file handling -- [x] Create bunq.classes.export.ts for statement exports -- [x] Create bunq.classes.notification.ts for notifications -- [x] Create bunq.classes.webhook.ts for webhook management +## Implementation Notes -## Phase 5: Enhanced Features +### Key Changes +1. Replace native fetch with SmartRequest fluent API +2. Use built-in handle429Backoff() instead of manual retry logic +3. Leverage SmartRequest's response methods (.json(), .text()) +4. Maintain all Bunq-specific behavior (signatures, custom headers) -- [x] Implement pagination support for all list endpoints -- [x] Add rate limiting compliance -- [x] Implement retry logic with exponential backoff -- [x] Add request/response logging capabilities -- [x] Implement webhook signature verification -- [x] Add OAuth flow support for third-party apps - -## Phase 6: Testing & Documentation - -- [ ] Write unit tests for crypto operations -- [ ] Write unit tests for HTTP client -- [ ] Write unit tests for all API classes -- [ ] Create integration tests using sandbox environment -- [x] Update main README.md with usage examples -- [x] Add JSDoc comments to all public methods -- [x] Create example scripts for common use cases - -## Phase 7: Cleanup & Optimization - -- [x] Remove all references to old bunq-community client -- [x] Optimize bundle size -- [x] Ensure all TypeScript types are properly exported -- [x] Run build and verify all tests pass -- [x] Update package version \ No newline at end of file +### Risk Mitigation +- All Bunq-specific logic remains unchanged +- Public API of BunqHttpClient stays the same +- Error handling maintains same format \ No newline at end of file diff --git a/ts/bunq.classes.export.ts b/ts/bunq.classes.export.ts index 88f5468..a95fa7a 100644 --- a/ts/bunq.classes.export.ts +++ b/ts/bunq.classes.export.ts @@ -334,4 +334,24 @@ export class ExportBuilder { await bunqExport.waitForCompletion(); await bunqExport.saveToFile(filePath); } + + /** + * Create and download export as Buffer + */ + public async download(): Promise { + const bunqExport = await this.create(); + await bunqExport.waitForCompletion(); + return bunqExport.downloadContent(); + } + + /** + * Create and download export as ArrayBuffer + */ + public async downloadAsArrayBuffer(): Promise { + const buffer = await this.download(); + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ); + } } \ No newline at end of file diff --git a/ts/bunq.classes.httpclient.ts b/ts/bunq.classes.httpclient.ts index c78aae7..288b864 100644 --- a/ts/bunq.classes.httpclient.ts +++ b/ts/bunq.classes.httpclient.ts @@ -24,47 +24,17 @@ export class BunqHttpClient { } /** - * Make an API request to bunq with automatic retry on rate limit + * Make an API request to bunq */ public async request(options: IBunqRequestOptions): Promise { - const maxRetries = 3; - let lastError: Error; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await this.makeRequest(options); - } catch (error) { - lastError = error as Error; - - // Check if it's a rate limit error - if (error instanceof BunqApiError) { - const isRateLimitError = error.errors.some(e => - e.error_description.includes('Too many requests') || - e.error_description.includes('rate limit') - ); - - if (isRateLimitError && attempt < maxRetries) { - // Exponential backoff: 1s, 2s, 4s - const backoffMs = Math.pow(2, attempt) * 1000; - console.log(`Rate limit hit, backing off for ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`); - await new Promise(resolve => setTimeout(resolve, backoffMs)); - continue; - } - } - - // For non-rate-limit errors or if we've exhausted retries, throw immediately - throw error; - } - } - - throw lastError!; + return this.makeRequest(options); } /** * Internal method to make the actual request */ private async makeRequest(options: IBunqRequestOptions): Promise { - let url = `${this.context.baseUrl}${options.endpoint}`; + const url = `${this.context.baseUrl}${options.endpoint}`; // Prepare headers const headers = this.prepareHeaders(options); @@ -82,44 +52,64 @@ export class BunqHttpClient { ); } - // Handle query parameters - if (options.params) { - const queryParams = new URLSearchParams(); - Object.entries(options.params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - queryParams.append(key, String(value)); + // Create SmartRequest instance + const request = plugins.smartrequest.SmartRequest.create() + .url(url) + .handle429Backoff({ + maxRetries: 3, + respectRetryAfter: true, + fallbackDelay: 1000, + backoffFactor: 2, + onRateLimit: (attempt, waitTime) => { + console.log(`Rate limit hit, backing off for ${waitTime}ms (attempt ${attempt}/4)`); } }); - const queryString = queryParams.toString(); - if (queryString) { - url += '?' + queryString; - } + + // Add headers + Object.entries(headers).forEach(([key, value]) => { + request.header(key, value); + }); + + // Add query parameters + if (options.params) { + request.query(options.params); } - // Make the request using native fetch - const fetchOptions: RequestInit = { - method: options.method === 'LIST' ? 'GET' : options.method, - headers: headers, - body: body - }; + // Add body if present + if (body) { + request.json(JSON.parse(body)); + } try { - const response = await fetch(url, fetchOptions); + // Execute request based on method + let response: plugins.smartrequest.ICoreResponse; // Response type from SmartRequest + const method = options.method === 'LIST' ? 'GET' : options.method; - // Get response body as text + switch (method) { + case 'GET': + response = await request.get(); + break; + case 'POST': + response = await request.post(); + break; + case 'PUT': + response = await request.put(); + break; + case 'DELETE': + response = await request.delete(); + break; + default: + throw new Error(`Unsupported HTTP method: ${method}`); + } + + // Get response body as text for signature verification const responseText = await response.text(); // Verify response signature if we have server public key if (this.context.serverPublicKey) { - // Convert headers to string-only format - const stringHeaders: { [key: string]: string } = {}; - response.headers.forEach((value, key) => { - stringHeaders[key] = value; - }); - const isValid = this.crypto.verifyResponseSignature( response.status, - stringHeaders, + response.headers as { [key: string]: string }, responseText, this.context.serverPublicKey ); diff --git a/ts/bunq.plugins.ts b/ts/bunq.plugins.ts index 52324e9..c43ba34 100644 --- a/ts/bunq.plugins.ts +++ b/ts/bunq.plugins.ts @@ -9,6 +9,7 @@ import * as smartcrypto from '@push.rocks/smartcrypto'; import * as smartfile from '@push.rocks/smartfile'; import * as smartpath from '@push.rocks/smartpath'; import * as smartpromise from '@push.rocks/smartpromise'; +import * as smartrequest from '@push.rocks/smartrequest'; import * as smarttime from '@push.rocks/smarttime'; -export { smartcrypto, smartfile, smartpath, smartpromise, smarttime }; +export { smartcrypto, smartfile, smartpath, smartpromise, smartrequest, smarttime };