Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
4c0ad95eb1 | |||
3144c9edbf | |||
b9317484bf | |||
9dd55543e9 | |||
dfbf66e339 | |||
cb6e79ba50 | |||
c9fab7def2 | |||
fb30c6f4e3 | |||
0e403e1584 | |||
16135cae02 | |||
1190500221 |
55
changelog.md
55
changelog.md
@@ -1,5 +1,60 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-07-29 - 4.3.0 - feat(http)
|
||||||
|
Enhanced HTTP client with automatic rate limit handling
|
||||||
|
|
||||||
|
- Added @push.rocks/smartrequest dependency for robust HTTP handling
|
||||||
|
- Implemented automatic retry with exponential backoff for rate-limited requests
|
||||||
|
- Built-in handling of HTTP 429 responses with intelligent waiting
|
||||||
|
- Respects Retry-After headers when provided by the server
|
||||||
|
- Maximum of 3 retry attempts with configurable backoff (1s, 2s, 4s)
|
||||||
|
- Improved error handling and network resilience
|
||||||
|
- Updated readme documentation with automatic rate limit handling examples
|
||||||
|
|
||||||
|
## 2025-07-27 - 4.2.1 - fix(tests)
|
||||||
|
Fix test compatibility with breaking changes from v4.0.0
|
||||||
|
|
||||||
|
- Updated all tests to handle new API structure where methods return objects
|
||||||
|
- Fixed destructuring for getAccounts() which now returns { accounts, sessionData? }
|
||||||
|
- Ensures all 83 tests pass successfully with the stateless architecture
|
||||||
|
|
||||||
|
## 2025-07-27 - 4.2.0 - feat(core)
|
||||||
|
Switch to native fetch API for all HTTP requests
|
||||||
|
|
||||||
|
- Replaced @push.rocks/smartrequest with native fetch API throughout the codebase
|
||||||
|
- Updated HTTP client to use fetch with proper error handling
|
||||||
|
- Updated attachment upload/download methods to use fetch
|
||||||
|
- Updated export download method to use fetch
|
||||||
|
- Updated sandbox user creation to use fetch
|
||||||
|
- Removed smartrequest dependency from package.json and plugins
|
||||||
|
- Improved error messages with HTTP status codes
|
||||||
|
|
||||||
|
## 2025-07-26 - 4.1.3 - fix(export)
|
||||||
|
Fix PDF statement download to use direct content endpoint
|
||||||
|
|
||||||
|
- Changed `downloadContent()` method to use the `/content` endpoint directly for PDF statements
|
||||||
|
- Removed unnecessary attachment lookup step that was causing issues
|
||||||
|
- Simplified the download process for customer statement exports
|
||||||
|
|
||||||
|
## 2025-07-25 - 4.1.1 - fix(httpclient)
|
||||||
|
Fix query parameter handling for smartrequest compatibility
|
||||||
|
|
||||||
|
- Changed query parameter handling to pass objects directly instead of URLSearchParams
|
||||||
|
- Removed URLSearchParams usage and string conversion
|
||||||
|
- Now passes queryParams as an object to smartrequest which handles URL encoding internally
|
||||||
|
|
||||||
|
## 2025-07-25 - 4.1.0 - feat(transactions)
|
||||||
|
Enhanced transaction pagination support with full control over historical data retrieval
|
||||||
|
|
||||||
|
- Added full `IBunqPaginationOptions` support to `getTransactions()` method
|
||||||
|
- Now supports `older_id` for paginating backwards through historical transactions
|
||||||
|
- Supports custom `count` parameter (defaults to 200)
|
||||||
|
- Maintains backward compatibility - passing a number is still treated as `newer_id`
|
||||||
|
- Only includes pagination parameters that are explicitly set (not false/undefined)
|
||||||
|
- Added `example.pagination.ts` demonstrating various pagination patterns
|
||||||
|
|
||||||
|
This enhancement allows banking applications to properly fetch and paginate through historical transaction data using both `newer_id` and `older_id` parameters.
|
||||||
|
|
||||||
## 2025-07-25 - 4.0.0 - BREAKING CHANGE(core)
|
## 2025-07-25 - 4.0.0 - BREAKING CHANGE(core)
|
||||||
Complete stateless architecture - consumers now have full control over session persistence
|
Complete stateless architecture - consumers now have full control over session persistence
|
||||||
|
|
||||||
|
128
example.pagination.ts
Normal file
128
example.pagination.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { BunqAccount, IBunqPaginationOptions } from './ts/index.js';
|
||||||
|
|
||||||
|
// Example demonstrating the enhanced pagination support in getTransactions
|
||||||
|
|
||||||
|
async function demonstratePagination() {
|
||||||
|
const bunq = new BunqAccount({
|
||||||
|
apiKey: 'your-api-key',
|
||||||
|
deviceName: 'Pagination Demo',
|
||||||
|
environment: 'PRODUCTION',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize and get session
|
||||||
|
const sessionData = await bunq.init();
|
||||||
|
|
||||||
|
// Get accounts
|
||||||
|
const { accounts } = await bunq.getAccounts();
|
||||||
|
const account = accounts[0];
|
||||||
|
|
||||||
|
// Example 1: Get most recent transactions (default behavior)
|
||||||
|
const recentTransactions = await account.getTransactions();
|
||||||
|
console.log(`Got ${recentTransactions.length} recent transactions`);
|
||||||
|
|
||||||
|
// Example 2: Get transactions with custom count
|
||||||
|
const smallBatch = await account.getTransactions({ count: 10 });
|
||||||
|
console.log(`Got ${smallBatch.length} transactions with custom count`);
|
||||||
|
|
||||||
|
// Example 3: Get older transactions using older_id
|
||||||
|
if (recentTransactions.length > 0) {
|
||||||
|
const oldestTransaction = recentTransactions[recentTransactions.length - 1];
|
||||||
|
const olderTransactions = await account.getTransactions({
|
||||||
|
count: 50,
|
||||||
|
older_id: oldestTransaction.id
|
||||||
|
});
|
||||||
|
console.log(`Got ${olderTransactions.length} older transactions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 4: Get newer transactions using newer_id
|
||||||
|
if (recentTransactions.length > 0) {
|
||||||
|
const newestTransaction = recentTransactions[0];
|
||||||
|
const newerTransactions = await account.getTransactions({
|
||||||
|
count: 20,
|
||||||
|
newer_id: newestTransaction.id
|
||||||
|
});
|
||||||
|
console.log(`Got ${newerTransactions.length} newer transactions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 5: Backward compatibility - using number as newer_id
|
||||||
|
const backwardCompatible = await account.getTransactions(12345);
|
||||||
|
console.log(`Backward compatible call returned ${backwardCompatible.length} transactions`);
|
||||||
|
|
||||||
|
// Example 6: Paginating through all historical transactions
|
||||||
|
async function getAllTransactions(account: any): Promise<any[]> {
|
||||||
|
const allTransactions: any[] = [];
|
||||||
|
let lastTransactionId: number | false = false;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const options: IBunqPaginationOptions = {
|
||||||
|
count: 200,
|
||||||
|
older_id: lastTransactionId
|
||||||
|
};
|
||||||
|
|
||||||
|
const batch = await account.getTransactions(options);
|
||||||
|
|
||||||
|
if (batch.length === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
allTransactions.push(...batch);
|
||||||
|
lastTransactionId = batch[batch.length - 1].id;
|
||||||
|
console.log(`Fetched ${batch.length} transactions, total: ${allTransactions.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example 7: Getting transactions between two dates
|
||||||
|
async function getTransactionsBetweenDates(
|
||||||
|
account: any,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<any[]> {
|
||||||
|
const transactions: any[] = [];
|
||||||
|
let olderId: number | false = false;
|
||||||
|
let keepFetching = true;
|
||||||
|
|
||||||
|
while (keepFetching) {
|
||||||
|
const batch = await account.getTransactions({
|
||||||
|
count: 200,
|
||||||
|
older_id: olderId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batch.length === 0) {
|
||||||
|
keepFetching = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transaction of batch) {
|
||||||
|
const transactionDate = new Date(transaction.created);
|
||||||
|
|
||||||
|
if (transactionDate >= startDate && transactionDate <= endDate) {
|
||||||
|
transactions.push(transaction);
|
||||||
|
} else if (transactionDate < startDate) {
|
||||||
|
// We've gone past our date range
|
||||||
|
keepFetching = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
olderId = batch[batch.length - 1].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const lastMonth = new Date();
|
||||||
|
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||||
|
const transactionsLastMonth = await getTransactionsBetweenDates(
|
||||||
|
account,
|
||||||
|
lastMonth,
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
console.log(`Found ${transactionsLastMonth.length} transactions in the last month`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the demo
|
||||||
|
demonstratePagination().catch(console.error);
|
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@apiclient.xyz/bunq",
|
"name": "@apiclient.xyz/bunq",
|
||||||
"version": "4.0.1",
|
"version": "4.1.2",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@apiclient.xyz/bunq",
|
"name": "@apiclient.xyz/bunq",
|
||||||
"version": "4.0.1",
|
"version": "4.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bunq-community/bunq-js-client": "^1.1.2",
|
"@bunq-community/bunq-js-client": "^1.1.2",
|
||||||
|
21
package.json
21
package.json
@@ -1,39 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@apiclient.xyz/bunq",
|
"name": "@apiclient.xyz/bunq",
|
||||||
"version": "4.0.1",
|
"version": "4.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A full-featured TypeScript/JavaScript client for the bunq API",
|
"description": "A full-featured TypeScript/JavaScript client for the bunq API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist_ts/index.js"
|
".": "./dist_ts/index.js"
|
||||||
},
|
},
|
||||||
"author": "Lossless GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --verbose --logfile)",
|
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||||
"test:basic": "(tstest test/test.ts --verbose)",
|
|
||||||
"test:payments": "(tstest test/test.payments.simple.ts --verbose)",
|
|
||||||
"test:webhooks": "(tstest test/test.webhooks.ts --verbose)",
|
|
||||||
"test:session": "(tstest test/test.session.ts --verbose)",
|
|
||||||
"test:errors": "(tstest test/test.errors.ts --verbose)",
|
|
||||||
"test:advanced": "(tstest test/test.advanced.ts --verbose)",
|
|
||||||
"test:oauth": "(tstest test/test.oauth.ts --verbose)",
|
|
||||||
"build": "(tsbuild --web)"
|
"build": "(tsbuild --web)"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.4",
|
"@git.zone/tsbuild": "^2.6.4",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tstest": "^2.3.1",
|
"@git.zone/tstest": "^2.3.2",
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
"@push.rocks/qenv": "^6.1.0",
|
||||||
"@push.rocks/tapbundle": "^6.0.3",
|
"@push.rocks/tapbundle": "^6.0.3",
|
||||||
"@types/node": "^24.0.14"
|
"@types/node": "^22"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartcrypto": "^2.0.4",
|
"@push.rocks/smartcrypto": "^2.0.4",
|
||||||
"@push.rocks/smartfile": "^11.2.5",
|
"@push.rocks/smartfile": "^11.2.5",
|
||||||
"@push.rocks/smartpath": "^5.0.18",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartrequest": "^2.0.21",
|
"@push.rocks/smartrequest": "^4.2.1",
|
||||||
"@push.rocks/smarttime": "^4.0.54"
|
"@push.rocks/smarttime": "^4.0.54"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
801
pnpm-lock.yaml
generated
801
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ tap.test('should setup advanced test environment', async () => {
|
|||||||
await testBunqAccount.init();
|
await testBunqAccount.init();
|
||||||
|
|
||||||
// Get primary account
|
// Get primary account
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
primaryAccount = accounts[0];
|
primaryAccount = accounts[0];
|
||||||
|
|
||||||
console.log('Advanced test environment setup complete');
|
console.log('Advanced test environment setup complete');
|
||||||
@@ -389,7 +389,7 @@ tap.test('should test travel mode', async () => {
|
|||||||
|
|
||||||
tap.test('should cleanup advanced test resources', async () => {
|
tap.test('should cleanup advanced test resources', async () => {
|
||||||
// Clean up any created resources
|
// Clean up any created resources
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
|
|
||||||
// Close any test accounts created (except primary)
|
// Close any test accounts created (except primary)
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
|
@@ -25,7 +25,7 @@ tap.test('should setup error test environment', async () => {
|
|||||||
await testBunqAccount.init();
|
await testBunqAccount.init();
|
||||||
|
|
||||||
// Get primary account
|
// Get primary account
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
primaryAccount = accounts[0];
|
primaryAccount = accounts[0];
|
||||||
|
|
||||||
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);
|
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);
|
||||||
@@ -283,7 +283,7 @@ tap.test('should test error recovery strategies', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await retryableOperation();
|
const accounts = await retryableOperation();
|
||||||
expect(accounts).toBeArray();
|
expect(accounts.accounts).toBeArray();
|
||||||
console.log('Error recovery with retry successful');
|
console.log('Error recovery with retry successful');
|
||||||
|
|
||||||
// 2. Recover from expired session
|
// 2. Recover from expired session
|
||||||
|
@@ -26,7 +26,7 @@ tap.test('should setup payment test environment', async () => {
|
|||||||
await testBunqAccount.init();
|
await testBunqAccount.init();
|
||||||
|
|
||||||
// Get primary account
|
// Get primary account
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
primaryAccount = accounts[0];
|
primaryAccount = accounts[0];
|
||||||
|
|
||||||
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);
|
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);
|
||||||
|
@@ -27,7 +27,7 @@ tap.test('should create test setup with multiple accounts', async () => {
|
|||||||
await testBunqAccount.init();
|
await testBunqAccount.init();
|
||||||
|
|
||||||
// Get accounts
|
// Get accounts
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
primaryAccount = accounts[0];
|
primaryAccount = accounts[0];
|
||||||
|
|
||||||
// Create a second account for testing transfers
|
// Create a second account for testing transfers
|
||||||
@@ -40,7 +40,7 @@ tap.test('should create test setup with multiple accounts', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Refresh accounts list
|
// Refresh accounts list
|
||||||
const updatedAccounts = await testBunqAccount.getAccounts();
|
const { accounts: updatedAccounts } = await testBunqAccount.getAccounts();
|
||||||
secondaryAccount = updatedAccounts.find(acc => acc.id === newAccount.id) || primaryAccount;
|
secondaryAccount = updatedAccounts.find(acc => acc.id === newAccount.id) || primaryAccount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Could not create secondary account, using primary for tests');
|
console.log('Could not create secondary account, using primary for tests');
|
||||||
|
@@ -84,7 +84,7 @@ tap.test('should test concurrent session usage', async () => {
|
|||||||
// Execute all operations concurrently
|
// Execute all operations concurrently
|
||||||
const results = await Promise.all(operations);
|
const results = await Promise.all(operations);
|
||||||
|
|
||||||
expect(results[0]).toBeArray(); // Accounts
|
expect(results[0].accounts).toBeArray(); // Accounts
|
||||||
expect(results[1]).toBeDefined(); // User info
|
expect(results[1]).toBeDefined(); // User info
|
||||||
expect(results[2]).toBeArray(); // Notification filters
|
expect(results[2]).toBeArray(); // Notification filters
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ tap.test('should test session token rotation', async () => {
|
|||||||
|
|
||||||
// Make multiple requests to test token handling
|
// Make multiple requests to test token handling
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
expect(accounts).toBeArray();
|
expect(accounts).toBeArray();
|
||||||
console.log(`Request ${i + 1} completed successfully`);
|
console.log(`Request ${i + 1} completed successfully`);
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ tap.test('should test session cleanup on error', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we can still use the session
|
// Ensure we can still use the session
|
||||||
const accounts = await tempAccount.getAccounts();
|
const { accounts } = await tempAccount.getAccounts();
|
||||||
expect(accounts).toBeArray();
|
expect(accounts).toBeArray();
|
||||||
console.log('Session still functional after error');
|
console.log('Session still functional after error');
|
||||||
|
|
||||||
|
277
test/test.statements.ts
Normal file
277
test/test.statements.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/bunq.plugins.js';
|
||||||
|
import * as bunq from '../ts/index.js';
|
||||||
|
|
||||||
|
let testBunqAccount: bunq.BunqAccount;
|
||||||
|
let sandboxApiKey: string;
|
||||||
|
let primaryAccount: bunq.BunqMonetaryAccount;
|
||||||
|
|
||||||
|
tap.test('should setup statement test environment', async () => {
|
||||||
|
// Create sandbox user
|
||||||
|
const tempAccount = new bunq.BunqAccount({
|
||||||
|
apiKey: '',
|
||||||
|
deviceName: 'bunq-statement-test',
|
||||||
|
environment: 'SANDBOX',
|
||||||
|
});
|
||||||
|
|
||||||
|
sandboxApiKey = await tempAccount.createSandboxUser();
|
||||||
|
|
||||||
|
// Initialize bunq account
|
||||||
|
testBunqAccount = new bunq.BunqAccount({
|
||||||
|
apiKey: sandboxApiKey,
|
||||||
|
deviceName: 'bunq-statement-test',
|
||||||
|
environment: 'SANDBOX',
|
||||||
|
});
|
||||||
|
|
||||||
|
await testBunqAccount.init();
|
||||||
|
|
||||||
|
// Get primary account
|
||||||
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
|
primaryAccount = accounts[0];
|
||||||
|
|
||||||
|
console.log('Statement test environment setup complete');
|
||||||
|
console.log(`Using account: ${primaryAccount.description}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create export builder with specific date range', async () => {
|
||||||
|
const fromDate = new Date('2024-01-01');
|
||||||
|
const toDate = new Date('2024-01-31');
|
||||||
|
|
||||||
|
const exportBuilder = primaryAccount.getAccountStatement({
|
||||||
|
from: fromDate,
|
||||||
|
to: toDate,
|
||||||
|
includeTransactionAttachments: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportBuilder).toBeInstanceOf(bunq.ExportBuilder);
|
||||||
|
|
||||||
|
// The export builder should be properly configured
|
||||||
|
const privateOptions = (exportBuilder as any).options;
|
||||||
|
expect(privateOptions.dateStart).toEqual('01-01-2024');
|
||||||
|
expect(privateOptions.dateEnd).toEqual('31-01-2024');
|
||||||
|
expect(privateOptions.includeAttachment).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create export builder with monthly index from 0', async () => {
|
||||||
|
// Test with 0-indexed month (0 = current month, 1 = last month, etc.)
|
||||||
|
const exportBuilder = primaryAccount.getAccountStatement({
|
||||||
|
monthlyIndexedFrom0: 2, // Two months ago
|
||||||
|
includeTransactionAttachments: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportBuilder).toBeInstanceOf(bunq.ExportBuilder);
|
||||||
|
|
||||||
|
// The export builder should have dates for two months ago
|
||||||
|
const privateOptions = (exportBuilder as any).options;
|
||||||
|
const now = new Date();
|
||||||
|
const twoMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 2, 1);
|
||||||
|
const expectedStart = `01-${String(twoMonthsAgo.getMonth() + 1).padStart(2, '0')}-${twoMonthsAgo.getFullYear()}`;
|
||||||
|
|
||||||
|
expect(privateOptions.dateStart).toEqual(expectedStart);
|
||||||
|
expect(privateOptions.includeAttachment).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create export builder with monthly index from 1', async () => {
|
||||||
|
// Test with 1-indexed month (1 = last month, 2 = two months ago, etc.)
|
||||||
|
const exportBuilder = primaryAccount.getAccountStatement({
|
||||||
|
monthlyIndexedFrom1: 1, // Last month
|
||||||
|
includeTransactionAttachments: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportBuilder).toBeInstanceOf(bunq.ExportBuilder);
|
||||||
|
|
||||||
|
// The export builder should have dates for last month
|
||||||
|
const privateOptions = (exportBuilder as any).options;
|
||||||
|
const now = new Date();
|
||||||
|
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const expectedStart = `01-${String(lastMonth.getMonth() + 1).padStart(2, '0')}-${lastMonth.getFullYear()}`;
|
||||||
|
|
||||||
|
expect(privateOptions.dateStart).toEqual(expectedStart);
|
||||||
|
expect(privateOptions.includeAttachment).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should default to last month when no date options provided', async () => {
|
||||||
|
const exportBuilder = primaryAccount.getAccountStatement({
|
||||||
|
includeTransactionAttachments: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportBuilder).toBeInstanceOf(bunq.ExportBuilder);
|
||||||
|
|
||||||
|
// Should default to last month
|
||||||
|
const privateOptions = (exportBuilder as any).options;
|
||||||
|
const now = new Date();
|
||||||
|
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const expectedStart = `01-${String(lastMonth.getMonth() + 1).padStart(2, '0')}-${lastMonth.getFullYear()}`;
|
||||||
|
|
||||||
|
expect(privateOptions.dateStart).toEqual(expectedStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create and download PDF statement', async () => {
|
||||||
|
console.log('Creating PDF statement export...');
|
||||||
|
|
||||||
|
const exportBuilder = primaryAccount.getAccountStatement({
|
||||||
|
monthlyIndexedFrom1: 1,
|
||||||
|
includeTransactionAttachments: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure as PDF
|
||||||
|
exportBuilder.asPdf();
|
||||||
|
|
||||||
|
// Create the export
|
||||||
|
const bunqExport = await exportBuilder.create();
|
||||||
|
expect(bunqExport).toBeInstanceOf(bunq.BunqExport);
|
||||||
|
expect(bunqExport.id).toBeTypeofNumber();
|
||||||
|
console.log('Created PDF export with ID:', bunqExport.id);
|
||||||
|
|
||||||
|
// Wait for completion with status updates
|
||||||
|
console.log('Waiting for PDF export to complete...');
|
||||||
|
const maxWaitTime = 180000; // 3 minutes
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const status = await bunqExport.get();
|
||||||
|
console.log(`Export status: ${status.status}`);
|
||||||
|
|
||||||
|
if (status.status === 'COMPLETED') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'FAILED') {
|
||||||
|
throw new Error('Export failed: ' + JSON.stringify(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - startTime > maxWaitTime) {
|
||||||
|
throw new Error(`Export timed out after ${maxWaitTime/1000} seconds. Last status: ${status.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download to test directory
|
||||||
|
console.log('Downloading PDF statement...');
|
||||||
|
const testFilePath = '.nogit/teststatements/test-statement.pdf';
|
||||||
|
await bunqExport.saveToFile(testFilePath);
|
||||||
|
|
||||||
|
// Verify file exists and has content
|
||||||
|
const fileExists = await plugins.smartfile.fs.fileExists(testFilePath);
|
||||||
|
expect(fileExists).toBeTrue();
|
||||||
|
|
||||||
|
const fileStats = await plugins.smartfile.fs.stat(testFilePath);
|
||||||
|
console.log(`PDF Statement downloaded to: ${testFilePath} (${fileStats.size} bytes)`);
|
||||||
|
expect(fileStats.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create CSV statement with custom date range', async () => {
|
||||||
|
console.log('Creating CSV statement export...');
|
||||||
|
|
||||||
|
// Use last month's date range to ensure it's in the past
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||||
|
|
||||||
|
const exportBuilder = primaryAccount.getAccountStatement({
|
||||||
|
from: startOfMonth,
|
||||||
|
to: endOfMonth,
|
||||||
|
includeTransactionAttachments: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure as CSV
|
||||||
|
const csvExport = await exportBuilder.asCsv().create();
|
||||||
|
console.log('Created CSV export with ID:', csvExport.id);
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
console.log('Waiting for CSV export to complete...');
|
||||||
|
await csvExport.waitForCompletion(60000);
|
||||||
|
|
||||||
|
// Download to test directory
|
||||||
|
console.log('Downloading CSV statement...');
|
||||||
|
const testFilePath = '.nogit/teststatements/test-statement.csv';
|
||||||
|
await csvExport.saveToFile(testFilePath);
|
||||||
|
|
||||||
|
// Verify file exists and has content
|
||||||
|
const fileExists = await plugins.smartfile.fs.fileExists(testFilePath);
|
||||||
|
expect(fileExists).toBeTrue();
|
||||||
|
|
||||||
|
const fileStats = await plugins.smartfile.fs.stat(testFilePath);
|
||||||
|
console.log(`CSV statement downloaded to: ${testFilePath} (${fileStats.size} bytes)`);
|
||||||
|
expect(fileStats.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should create MT940 statement', async () => {
|
||||||
|
console.log('Creating MT940 statement export...');
|
||||||
|
|
||||||
|
const exportBuilder = primaryAccount.getAccountStatement({
|
||||||
|
monthlyIndexedFrom0: 1, // Last month
|
||||||
|
includeTransactionAttachments: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure as MT940
|
||||||
|
const mt940Export = await exportBuilder.asMt940().create();
|
||||||
|
console.log('Created MT940 export with ID:', mt940Export.id);
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
console.log('Waiting for MT940 export to complete...');
|
||||||
|
await mt940Export.waitForCompletion(60000);
|
||||||
|
|
||||||
|
// Download to test directory
|
||||||
|
console.log('Downloading MT940 statement...');
|
||||||
|
const testFilePath = '.nogit/teststatements/test-statement.txt';
|
||||||
|
await mt940Export.saveToFile(testFilePath);
|
||||||
|
|
||||||
|
// Verify file exists and has content
|
||||||
|
const fileExists = await plugins.smartfile.fs.fileExists(testFilePath);
|
||||||
|
expect(fileExists).toBeTrue();
|
||||||
|
|
||||||
|
const fileStats = await plugins.smartfile.fs.stat(testFilePath);
|
||||||
|
console.log(`MT940 statement downloaded to: ${testFilePath} (${fileStats.size} bytes)`);
|
||||||
|
expect(fileStats.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle edge cases for date calculations', async () => {
|
||||||
|
// Mock the getAccountStatement method to test with a specific date
|
||||||
|
const originalMethod = primaryAccount.getAccountStatement;
|
||||||
|
|
||||||
|
// Override the method temporarily for this test
|
||||||
|
primaryAccount.getAccountStatement = function(optionsArg) {
|
||||||
|
const exportBuilder = new bunq.ExportBuilder(this.bunqAccountRef, this);
|
||||||
|
|
||||||
|
// Simulate January 2024 as "now"
|
||||||
|
const mockNow = new Date(2024, 0, 15); // January 15, 2024
|
||||||
|
const targetDate = new Date(mockNow.getFullYear(), mockNow.getMonth() - 1, 1);
|
||||||
|
const startDate = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1);
|
||||||
|
const endDate = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${day}-${month}-${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
exportBuilder.dateRange(formatDate(startDate), formatDate(endDate));
|
||||||
|
exportBuilder.includeAttachments(optionsArg.includeTransactionAttachments);
|
||||||
|
|
||||||
|
return exportBuilder;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exportBuilder = primaryAccount.getAccountStatement({
|
||||||
|
monthlyIndexedFrom1: 1, // Last month (December 2023)
|
||||||
|
includeTransactionAttachments: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const privateOptions = (exportBuilder as any).options;
|
||||||
|
expect(privateOptions.dateStart).toEqual('01-12-2023');
|
||||||
|
expect(privateOptions.dateEnd).toEqual('31-12-2023');
|
||||||
|
} finally {
|
||||||
|
// Restore original method
|
||||||
|
primaryAccount.getAccountStatement = originalMethod;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should cleanup test environment', async () => {
|
||||||
|
await testBunqAccount.stop();
|
||||||
|
console.log('Test environment cleaned up');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
@@ -41,7 +41,7 @@ tap.test('should init the client', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should get accounts', async () => {
|
tap.test('should get accounts', async () => {
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
expect(accounts).toBeArray();
|
expect(accounts).toBeArray();
|
||||||
expect(accounts.length).toBeGreaterThan(0);
|
expect(accounts.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ tap.test('should get accounts', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should get transactions', async () => {
|
tap.test('should get transactions', async () => {
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
const account = accounts[0];
|
const account = accounts[0];
|
||||||
|
|
||||||
const transactions = await account.getTransactions();
|
const transactions = await account.getTransactions();
|
||||||
@@ -74,7 +74,7 @@ tap.test('should get transactions', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should test payment builder', async () => {
|
tap.test('should test payment builder', async () => {
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
const account = accounts[0];
|
const account = accounts[0];
|
||||||
|
|
||||||
// Test payment builder without actually creating the payment
|
// Test payment builder without actually creating the payment
|
||||||
|
@@ -27,7 +27,7 @@ tap.test('should setup webhook test environment', async () => {
|
|||||||
await testBunqAccount.init();
|
await testBunqAccount.init();
|
||||||
|
|
||||||
// Get primary account
|
// Get primary account
|
||||||
const accounts = await testBunqAccount.getAccounts();
|
const { accounts } = await testBunqAccount.getAccounts();
|
||||||
primaryAccount = accounts[0];
|
primaryAccount = accounts[0];
|
||||||
|
|
||||||
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);
|
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);
|
||||||
|
@@ -11,6 +11,7 @@ export interface IBunqConstructorOptions {
|
|||||||
environment: 'SANDBOX' | 'PRODUCTION';
|
environment: 'SANDBOX' | 'PRODUCTION';
|
||||||
permittedIps?: string[];
|
permittedIps?: string[];
|
||||||
isOAuthToken?: boolean; // Set to true when using OAuth access token instead of API key
|
isOAuthToken?: boolean; // Set to true when using OAuth access token instead of API key
|
||||||
|
dangerousOperations?: boolean; // Set to true to enable dangerous operations like closing accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +162,7 @@ export class BunqAccount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sandbox user creation doesn't require authentication
|
// Sandbox user creation doesn't require authentication
|
||||||
const response = await plugins.smartrequest.request(
|
const response = await fetch(
|
||||||
'https://public-api.sandbox.bunq.com/v1/sandbox-user-person',
|
'https://public-api.sandbox.bunq.com/v1/sandbox-user-person',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -170,12 +171,18 @@ export class BunqAccount {
|
|||||||
'User-Agent': 'bunq-api-client/1.0.0',
|
'User-Agent': 'bunq-api-client/1.0.0',
|
||||||
'Cache-Control': 'no-cache'
|
'Cache-Control': 'no-cache'
|
||||||
},
|
},
|
||||||
requestBody: '{}'
|
body: '{}'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.body.Response && response.body.Response[0] && response.body.Response[0].ApiKey) {
|
if (!response.ok) {
|
||||||
return response.body.Response[0].ApiKey.api_key;
|
throw new Error(`Failed to create sandbox user: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
if (responseData.Response && responseData.Response[0] && responseData.Response[0].ApiKey) {
|
||||||
|
return responseData.Response[0].ApiKey.api_key;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Failed to create sandbox user');
|
throw new Error('Failed to create sandbox user');
|
||||||
|
@@ -47,17 +47,19 @@ export class BunqAttachment {
|
|||||||
'X-Bunq-Client-Authentication': this.bunqAccount.apiContext.getSession().getContext().sessionToken
|
'X-Bunq-Client-Authentication': this.bunqAccount.apiContext.getSession().getContext().sessionToken
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestOptions = {
|
const response = await fetch(
|
||||||
method: 'PUT' as const,
|
|
||||||
headers: headers,
|
|
||||||
requestBody: options.body
|
|
||||||
};
|
|
||||||
|
|
||||||
await plugins.smartrequest.request(
|
|
||||||
`${this.bunqAccount.apiContext.getBaseUrl()}${uploadUrl}`,
|
`${this.bunqAccount.apiContext.getBaseUrl()}${uploadUrl}`,
|
||||||
requestOptions
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers,
|
||||||
|
body: options.body
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to upload attachment: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
return attachmentUuid;
|
return attachmentUuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ export class BunqAttachment {
|
|||||||
public async getContent(attachmentUuid: string): Promise<Buffer> {
|
public async getContent(attachmentUuid: string): Promise<Buffer> {
|
||||||
await this.bunqAccount.apiContext.ensureValidSession();
|
await this.bunqAccount.apiContext.ensureValidSession();
|
||||||
|
|
||||||
const response = await plugins.smartrequest.request(
|
const response = await fetch(
|
||||||
`${this.bunqAccount.apiContext.getBaseUrl()}/v1/attachment-public/${attachmentUuid}/content`,
|
`${this.bunqAccount.apiContext.getBaseUrl()}/v1/attachment-public/${attachmentUuid}/content`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -77,7 +79,12 @@ export class BunqAttachment {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return Buffer.from(response.body);
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get attachment: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -97,6 +97,12 @@ export class BunqCard {
|
|||||||
* Update card settings
|
* Update card settings
|
||||||
*/
|
*/
|
||||||
public async update(updates: any): Promise<void> {
|
public async update(updates: any): Promise<void> {
|
||||||
|
// Check if this is a dangerous operation
|
||||||
|
if ((updates.status === 'CANCELLED' || updates.status === 'BLOCKED') &&
|
||||||
|
!this.bunqAccount.options.dangerousOperations) {
|
||||||
|
throw new Error('Dangerous operations are not enabled. Initialize the BunqAccount with dangerousOperations: true to allow cancelling or blocking cards.');
|
||||||
|
}
|
||||||
|
|
||||||
await this.bunqAccount.apiContext.ensureValidSession();
|
await this.bunqAccount.apiContext.ensureValidSession();
|
||||||
|
|
||||||
const cardType = this.type === 'MASTERCARD' ? 'CardCredit' : 'CardDebit';
|
const cardType = this.type === 'MASTERCARD' ? 'CardCredit' : 'CardDebit';
|
||||||
|
@@ -111,27 +111,31 @@ export class BunqExport {
|
|||||||
throw new Error('Export ID not set');
|
throw new Error('Export ID not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
// First get the export details to find the attachment
|
// Ensure the export is complete before downloading
|
||||||
const exportDetails = await this.get();
|
const status = await this.get();
|
||||||
|
if (status.status !== 'COMPLETED') {
|
||||||
if (!exportDetails.attachment || exportDetails.attachment.length === 0) {
|
throw new Error(`Export is not ready for download. Status: ${status.status}`);
|
||||||
throw new Error('Export has no attachment');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentUuid = exportDetails.attachment[0].attachment_public_uuid;
|
// For PDF statements, use the /content endpoint directly
|
||||||
|
const downloadUrl = `${this.bunqAccount.apiContext.getBaseUrl()}/v1/user/${this.bunqAccount.userId}/monetary-account/${this.monetaryAccount.id}/customer-statement/${this.id}/content`;
|
||||||
// Download the attachment content
|
|
||||||
const response = await plugins.smartrequest.request(
|
|
||||||
`${this.bunqAccount.apiContext.getBaseUrl()}/v1/attachment-public/${attachmentUuid}/content`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'X-Bunq-Client-Authentication': this.bunqAccount.apiContext.getSession().getContext().sessionToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return Buffer.from(response.body);
|
const response = await fetch(downloadUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Bunq-Client-Authentication': this.bunqAccount.apiContext.getSession().getContext().sessionToken,
|
||||||
|
'User-Agent': 'bunq-api-client/1.0.0',
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseText = await response.text();
|
||||||
|
throw new Error(`Failed to download export: HTTP ${response.status} - ${responseText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,7 +155,7 @@ export class BunqExport {
|
|||||||
while (true) {
|
while (true) {
|
||||||
const details = await this.get();
|
const details = await this.get();
|
||||||
|
|
||||||
if (details.status === 'COMPLETE') {
|
if (details.status === 'COMPLETED') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,8 +260,16 @@ export class ExportBuilder {
|
|||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(startDate.getDate() - days);
|
startDate.setDate(startDate.getDate() - days);
|
||||||
|
|
||||||
this.options.dateStart = startDate.toISOString().split('T')[0];
|
// Format as DD-MM-YYYY for bunq API
|
||||||
this.options.dateEnd = endDate.toISOString().split('T')[0];
|
const formatDate = (date: Date): string => {
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
return `${day}-${month}-${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.options.dateStart = formatDate(startDate);
|
||||||
|
this.options.dateEnd = formatDate(endDate);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,8 +281,16 @@ export class ExportBuilder {
|
|||||||
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth(), 0);
|
const endDate = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||||
|
|
||||||
this.options.dateStart = startDate.toISOString().split('T')[0];
|
// Format as DD-MM-YYYY for bunq API
|
||||||
this.options.dateEnd = endDate.toISOString().split('T')[0];
|
const formatDate = (date: Date): string => {
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
return `${day}-${month}-${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.options.dateStart = formatDate(startDate);
|
||||||
|
this.options.dateEnd = formatDate(endDate);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -24,10 +24,47 @@ export class BunqHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an API request to bunq
|
* Make an API request to bunq with automatic retry on rate limit
|
||||||
*/
|
*/
|
||||||
public async request<T = any>(options: IBunqRequestOptions): Promise<T> {
|
public async request<T = any>(options: IBunqRequestOptions): Promise<T> {
|
||||||
const url = `${this.context.baseUrl}${options.endpoint}`;
|
const maxRetries = 3;
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await this.makeRequest<T>(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!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to make the actual request
|
||||||
|
*/
|
||||||
|
private async makeRequest<T = any>(options: IBunqRequestOptions): Promise<T> {
|
||||||
|
let url = `${this.context.baseUrl}${options.endpoint}`;
|
||||||
|
|
||||||
// Prepare headers
|
// Prepare headers
|
||||||
const headers = this.prepareHeaders(options);
|
const headers = this.prepareHeaders(options);
|
||||||
@@ -45,47 +82,45 @@ export class BunqHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make the request
|
// Handle query parameters
|
||||||
const requestOptions: any = {
|
|
||||||
method: options.method === 'LIST' ? 'GET' : options.method,
|
|
||||||
headers: headers,
|
|
||||||
requestBody: body
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.params) {
|
if (options.params) {
|
||||||
const params = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
Object.entries(options.params).forEach(([key, value]) => {
|
Object.entries(options.params).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
params.append(key, String(value));
|
queryParams.append(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
requestOptions.queryParams = params.toString();
|
const queryString = queryParams.toString();
|
||||||
|
if (queryString) {
|
||||||
|
url += '?' + queryString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make the request using native fetch
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: options.method === 'LIST' ? 'GET' : options.method,
|
||||||
|
headers: headers,
|
||||||
|
body: body
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await plugins.smartrequest.request(url, requestOptions);
|
const response = await fetch(url, fetchOptions);
|
||||||
|
|
||||||
|
// Get response body as text
|
||||||
|
const responseText = await response.text();
|
||||||
|
|
||||||
// Verify response signature if we have server public key
|
// Verify response signature if we have server public key
|
||||||
if (this.context.serverPublicKey) {
|
if (this.context.serverPublicKey) {
|
||||||
// Convert headers to string-only format
|
// Convert headers to string-only format
|
||||||
const stringHeaders: { [key: string]: string } = {};
|
const stringHeaders: { [key: string]: string } = {};
|
||||||
for (const [key, value] of Object.entries(response.headers)) {
|
response.headers.forEach((value, key) => {
|
||||||
if (typeof value === 'string') {
|
stringHeaders[key] = value;
|
||||||
stringHeaders[key] = value;
|
});
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
stringHeaders[key] = value.join(', ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert body to string if needed for signature verification
|
|
||||||
const bodyString = typeof response.body === 'string'
|
|
||||||
? response.body
|
|
||||||
: JSON.stringify(response.body);
|
|
||||||
|
|
||||||
const isValid = this.crypto.verifyResponseSignature(
|
const isValid = this.crypto.verifyResponseSignature(
|
||||||
response.statusCode,
|
response.status,
|
||||||
stringHeaders,
|
stringHeaders,
|
||||||
bodyString,
|
responseText,
|
||||||
this.context.serverPublicKey
|
this.context.serverPublicKey
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,17 +134,21 @@ export class BunqHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response - smartrequest may already parse JSON automatically
|
// Parse response
|
||||||
let responseData;
|
let responseData;
|
||||||
if (typeof response.body === 'string') {
|
if (responseText) {
|
||||||
try {
|
try {
|
||||||
responseData = JSON.parse(response.body);
|
responseData = JSON.parse(responseText);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
// If parsing fails and it's not a 2xx response, throw an HTTP error
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
throw new Error(`Failed to parse JSON response: ${parseError.message}`);
|
throw new Error(`Failed to parse JSON response: ${parseError.message}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Response is already parsed
|
// Empty response body
|
||||||
responseData = response.body;
|
responseData = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for errors
|
// Check for errors
|
||||||
@@ -117,6 +156,11 @@ export class BunqHttpClient {
|
|||||||
throw new BunqApiError(responseData.Error);
|
throw new BunqApiError(responseData.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check HTTP status
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BunqApiError) {
|
if (error instanceof BunqApiError) {
|
||||||
|
@@ -2,6 +2,7 @@ import * as plugins from './bunq.plugins.js';
|
|||||||
import { BunqAccount } from './bunq.classes.account.js';
|
import { BunqAccount } from './bunq.classes.account.js';
|
||||||
import { BunqTransaction } from './bunq.classes.transaction.js';
|
import { BunqTransaction } from './bunq.classes.transaction.js';
|
||||||
import { BunqPayment } from './bunq.classes.payment.js';
|
import { BunqPayment } from './bunq.classes.payment.js';
|
||||||
|
import { ExportBuilder } from './bunq.classes.export.js';
|
||||||
import type { IBunqPaginationOptions, IBunqMonetaryAccountBank } from './bunq.interfaces.js';
|
import type { IBunqPaginationOptions, IBunqMonetaryAccountBank } from './bunq.interfaces.js';
|
||||||
|
|
||||||
export type TAccountType = 'bank' | 'joint' | 'savings' | 'external' | 'light' | 'card' | 'external_savings' | 'savings_external';
|
export type TAccountType = 'bank' | 'joint' | 'savings' | 'external' | 'light' | 'card' | 'external_savings' | 'savings_external';
|
||||||
@@ -111,18 +112,41 @@ export class BunqMonetaryAccount {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* gets all transactions on this account
|
* gets all transactions on this account
|
||||||
|
* @param options - Pagination options or a number for backward compatibility (treated as newer_id)
|
||||||
*/
|
*/
|
||||||
public async getTransactions(startingIdArg: number | false = false): Promise<BunqTransaction[]> {
|
public async getTransactions(options?: IBunqPaginationOptions | number | false): Promise<BunqTransaction[]> {
|
||||||
const paginationOptions: IBunqPaginationOptions = {
|
let paginationOptions: IBunqPaginationOptions = {};
|
||||||
count: 200,
|
|
||||||
newer_id: startingIdArg,
|
// Backward compatibility: if a number or false is passed, treat it as newer_id
|
||||||
|
if (typeof options === 'number' || options === false) {
|
||||||
|
paginationOptions.newer_id = options;
|
||||||
|
} else if (options) {
|
||||||
|
paginationOptions = { ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default count if not specified
|
||||||
|
if (!paginationOptions.count) {
|
||||||
|
paginationOptions.count = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build clean pagination object - only include properties that are not false/undefined
|
||||||
|
const cleanPaginationOptions: IBunqPaginationOptions = {
|
||||||
|
count: paginationOptions.count,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (paginationOptions.newer_id !== undefined && paginationOptions.newer_id !== false) {
|
||||||
|
cleanPaginationOptions.newer_id = paginationOptions.newer_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paginationOptions.older_id !== undefined && paginationOptions.older_id !== false) {
|
||||||
|
cleanPaginationOptions.older_id = paginationOptions.older_id;
|
||||||
|
}
|
||||||
|
|
||||||
await this.bunqAccountRef.apiContext.ensureValidSession();
|
await this.bunqAccountRef.apiContext.ensureValidSession();
|
||||||
|
|
||||||
const response = await this.bunqAccountRef.getHttpClient().list(
|
const response = await this.bunqAccountRef.getHttpClient().list(
|
||||||
`/v1/user/${this.bunqAccountRef.userId}/monetary-account/${this.id}/payment`,
|
`/v1/user/${this.bunqAccountRef.userId}/monetary-account/${this.id}/payment`,
|
||||||
paginationOptions
|
cleanPaginationOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
const transactionsArray: BunqTransaction[] = [];
|
const transactionsArray: BunqTransaction[] = [];
|
||||||
@@ -147,6 +171,11 @@ export class BunqMonetaryAccount {
|
|||||||
* Update account settings
|
* Update account settings
|
||||||
*/
|
*/
|
||||||
public async update(updates: any): Promise<void> {
|
public async update(updates: any): Promise<void> {
|
||||||
|
// Check if this is a dangerous operation
|
||||||
|
if (updates.status === 'CANCELLED' && !this.bunqAccountRef.options.dangerousOperations) {
|
||||||
|
throw new Error('Dangerous operations are not enabled. Initialize the BunqAccount with dangerousOperations: true to allow cancelling accounts.');
|
||||||
|
}
|
||||||
|
|
||||||
await this.bunqAccountRef.apiContext.ensureValidSession();
|
await this.bunqAccountRef.apiContext.ensureValidSession();
|
||||||
|
|
||||||
const endpoint = `/v1/user/${this.bunqAccountRef.userId}/monetary-account/${this.id}`;
|
const endpoint = `/v1/user/${this.bunqAccountRef.userId}/monetary-account/${this.id}`;
|
||||||
@@ -212,6 +241,10 @@ export class BunqMonetaryAccount {
|
|||||||
* Close this monetary account
|
* Close this monetary account
|
||||||
*/
|
*/
|
||||||
public async close(reason: string): Promise<void> {
|
public async close(reason: string): Promise<void> {
|
||||||
|
if (!this.bunqAccountRef.options.dangerousOperations) {
|
||||||
|
throw new Error('Dangerous operations are not enabled. Initialize the BunqAccount with dangerousOperations: true to allow closing accounts.');
|
||||||
|
}
|
||||||
|
|
||||||
await this.update({
|
await this.update({
|
||||||
status: 'CANCELLED',
|
status: 'CANCELLED',
|
||||||
sub_status: 'REDEMPTION_VOLUNTARY',
|
sub_status: 'REDEMPTION_VOLUNTARY',
|
||||||
@@ -219,4 +252,60 @@ export class BunqMonetaryAccount {
|
|||||||
reason_description: reason
|
reason_description: reason
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account statement with flexible date options
|
||||||
|
* @param optionsArg - Options for statement generation
|
||||||
|
* @returns ExportBuilder instance for creating the statement
|
||||||
|
*/
|
||||||
|
public getAccountStatement(optionsArg: {
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
monthlyIndexedFrom0?: number;
|
||||||
|
monthlyIndexedFrom1?: number;
|
||||||
|
includeTransactionAttachments: boolean;
|
||||||
|
}): ExportBuilder {
|
||||||
|
const exportBuilder = new ExportBuilder(this.bunqAccountRef, this);
|
||||||
|
|
||||||
|
// Determine date range based on provided options
|
||||||
|
let startDate: Date;
|
||||||
|
let endDate: Date;
|
||||||
|
|
||||||
|
if (optionsArg.from && optionsArg.to) {
|
||||||
|
// Use provided date range
|
||||||
|
startDate = optionsArg.from;
|
||||||
|
endDate = optionsArg.to;
|
||||||
|
} else if (optionsArg.monthlyIndexedFrom0 !== undefined) {
|
||||||
|
// Calculate date range for 0-indexed month
|
||||||
|
const now = new Date();
|
||||||
|
const targetDate = new Date(now.getFullYear(), now.getMonth() - optionsArg.monthlyIndexedFrom0, 1);
|
||||||
|
startDate = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1);
|
||||||
|
endDate = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0);
|
||||||
|
} else if (optionsArg.monthlyIndexedFrom1 !== undefined) {
|
||||||
|
// Calculate date range for 1-indexed month (1 = last month, 2 = two months ago, etc.)
|
||||||
|
const now = new Date();
|
||||||
|
const targetDate = new Date(now.getFullYear(), now.getMonth() - optionsArg.monthlyIndexedFrom1, 1);
|
||||||
|
startDate = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1);
|
||||||
|
endDate = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0);
|
||||||
|
} else {
|
||||||
|
// Default to last month if no date options provided
|
||||||
|
const now = new Date();
|
||||||
|
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
endDate = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format dates as DD-MM-YYYY (bunq API format)
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${day}-${month}-${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure the export builder
|
||||||
|
exportBuilder.dateRange(formatDate(startDate), formatDate(endDate));
|
||||||
|
exportBuilder.includeAttachments(optionsArg.includeTransactionAttachments);
|
||||||
|
|
||||||
|
return exportBuilder;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,6 @@ import * as smartcrypto from '@push.rocks/smartcrypto';
|
|||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
|
||||||
import * as smarttime from '@push.rocks/smarttime';
|
import * as smarttime from '@push.rocks/smarttime';
|
||||||
|
|
||||||
export { smartcrypto, smartfile, smartpath, smartpromise, smartrequest, smarttime };
|
export { smartcrypto, smartfile, smartpath, smartpromise, smarttime };
|
||||||
|
Reference in New Issue
Block a user