Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
4c0ad95eb1 | |||
3144c9edbf | |||
b9317484bf | |||
9dd55543e9 | |||
dfbf66e339 | |||
cb6e79ba50 |
18
changelog.md
18
changelog.md
@@ -1,5 +1,23 @@
|
|||||||
# 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)
|
## 2025-07-27 - 4.2.0 - feat(core)
|
||||||
Switch to native fetch API for all HTTP requests
|
Switch to native fetch API for all HTTP requests
|
||||||
|
|
||||||
|
20
package.json
20
package.json
@@ -1,38 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@apiclient.xyz/bunq",
|
"name": "@apiclient.xyz/bunq",
|
||||||
"version": "4.2.0",
|
"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": "^4.2.1",
|
||||||
"@push.rocks/smarttime": "^4.0.54"
|
"@push.rocks/smarttime": "^4.0.54"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
798
pnpm-lock.yaml
generated
798
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -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,18 +111,27 @@ export class BunqExport {
|
|||||||
throw new Error('Export ID not set');
|
throw new Error('Export ID not set');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the export is complete before downloading
|
||||||
|
const status = await this.get();
|
||||||
|
if (status.status !== 'COMPLETED') {
|
||||||
|
throw new Error(`Export is not ready for download. Status: ${status.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
// For PDF statements, use the /content endpoint directly
|
// 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`;
|
const downloadUrl = `${this.bunqAccount.apiContext.getBaseUrl()}/v1/user/${this.bunqAccount.userId}/monetary-account/${this.monetaryAccount.id}/customer-statement/${this.id}/content`;
|
||||||
|
|
||||||
const response = await fetch(downloadUrl, {
|
const response = await fetch(downloadUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'X-Bunq-Client-Authentication': this.bunqAccount.apiContext.getSession().getContext().sessionToken
|
'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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to download export: HTTP ${response.status}`);
|
const responseText = await response.text();
|
||||||
|
throw new Error(`Failed to download export: HTTP ${response.status} - ${responseText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
@@ -146,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,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,9 +24,46 @@ 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 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}`;
|
let url = `${this.context.baseUrl}${options.endpoint}`;
|
||||||
|
|
||||||
// Prepare headers
|
// Prepare headers
|
||||||
|
@@ -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';
|
||||||
@@ -170,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}`;
|
||||||
@@ -235,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',
|
||||||
@@ -242,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user