Compare commits

...

7 Commits

18 changed files with 297 additions and 92 deletions

View File

@@ -1,5 +1,49 @@
# Changelog
## 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)
Complete stateless architecture - consumers now have full control over session persistence

128
example.pagination.ts Normal file
View 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
View File

@@ -1,12 +1,12 @@
{
"name": "@apiclient.xyz/bunq",
"version": "3.0.9",
"version": "4.1.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@apiclient.xyz/bunq",
"version": "3.0.9",
"version": "4.1.2",
"license": "MIT",
"dependencies": {
"@bunq-community/bunq-js-client": "^1.1.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@apiclient.xyz/bunq",
"version": "4.0.0",
"version": "4.2.1",
"private": false,
"description": "A full-featured TypeScript/JavaScript client for the bunq API",
"type": "module",
@@ -33,7 +33,6 @@
"@push.rocks/smartfile": "^11.2.5",
"@push.rocks/smartpath": "^5.0.18",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.0.21",
"@push.rocks/smarttime": "^4.0.54"
},
"files": [

3
pnpm-lock.yaml generated
View File

@@ -20,9 +20,6 @@ importers:
'@push.rocks/smartpromise':
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartrequest':
specifier: ^2.0.21
version: 2.1.0
'@push.rocks/smarttime':
specifier: ^4.0.54
version: 4.1.1

View File

@@ -25,7 +25,7 @@ tap.test('should setup advanced test environment', async () => {
await testBunqAccount.init();
// Get primary account
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
primaryAccount = accounts[0];
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 () => {
// Clean up any created resources
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
// Close any test accounts created (except primary)
for (const account of accounts) {

View File

@@ -25,7 +25,7 @@ tap.test('should setup error test environment', async () => {
await testBunqAccount.init();
// Get primary account
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
primaryAccount = accounts[0];
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);
@@ -283,7 +283,7 @@ tap.test('should test error recovery strategies', async () => {
}
const accounts = await retryableOperation();
expect(accounts).toBeArray();
expect(accounts.accounts).toBeArray();
console.log('Error recovery with retry successful');
// 2. Recover from expired session

View File

@@ -26,7 +26,7 @@ tap.test('should setup payment test environment', async () => {
await testBunqAccount.init();
// Get primary account
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
primaryAccount = accounts[0];
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);

View File

@@ -27,7 +27,7 @@ tap.test('should create test setup with multiple accounts', async () => {
await testBunqAccount.init();
// Get accounts
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
primaryAccount = accounts[0];
// Create a second account for testing transfers
@@ -40,7 +40,7 @@ tap.test('should create test setup with multiple accounts', async () => {
});
// Refresh accounts list
const updatedAccounts = await testBunqAccount.getAccounts();
const { accounts: updatedAccounts } = await testBunqAccount.getAccounts();
secondaryAccount = updatedAccounts.find(acc => acc.id === newAccount.id) || primaryAccount;
} catch (error) {
console.log('Could not create secondary account, using primary for tests');

View File

@@ -84,7 +84,7 @@ tap.test('should test concurrent session usage', async () => {
// Execute all operations concurrently
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[2]).toBeArray(); // Notification filters
@@ -172,7 +172,7 @@ tap.test('should test session token rotation', async () => {
// Make multiple requests to test token handling
for (let i = 0; i < 3; i++) {
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
expect(accounts).toBeArray();
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
const accounts = await tempAccount.getAccounts();
const { accounts } = await tempAccount.getAccounts();
expect(accounts).toBeArray();
console.log('Session still functional after error');

View File

@@ -41,7 +41,7 @@ tap.test('should init the client', async () => {
});
tap.test('should get accounts', async () => {
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
expect(accounts).toBeArray();
expect(accounts.length).toBeGreaterThan(0);
@@ -56,7 +56,7 @@ tap.test('should get accounts', async () => {
});
tap.test('should get transactions', async () => {
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
const account = accounts[0];
const transactions = await account.getTransactions();
@@ -74,7 +74,7 @@ tap.test('should get transactions', async () => {
});
tap.test('should test payment builder', async () => {
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
const account = accounts[0];
// Test payment builder without actually creating the payment

View File

@@ -27,7 +27,7 @@ tap.test('should setup webhook test environment', async () => {
await testBunqAccount.init();
// Get primary account
const accounts = await testBunqAccount.getAccounts();
const { accounts } = await testBunqAccount.getAccounts();
primaryAccount = accounts[0];
expect(primaryAccount).toBeInstanceOf(bunq.BunqMonetaryAccount);

View File

@@ -161,7 +161,7 @@ export class BunqAccount {
}
// 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',
{
method: 'POST',
@@ -170,12 +170,18 @@ export class BunqAccount {
'User-Agent': 'bunq-api-client/1.0.0',
'Cache-Control': 'no-cache'
},
requestBody: '{}'
body: '{}'
}
);
if (response.body.Response && response.body.Response[0] && response.body.Response[0].ApiKey) {
return response.body.Response[0].ApiKey.api_key;
if (!response.ok) {
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');

View File

@@ -47,17 +47,19 @@ export class BunqAttachment {
'X-Bunq-Client-Authentication': this.bunqAccount.apiContext.getSession().getContext().sessionToken
};
const requestOptions = {
method: 'PUT' as const,
headers: headers,
requestBody: options.body
};
await plugins.smartrequest.request(
const response = await fetch(
`${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;
}
@@ -67,7 +69,7 @@ export class BunqAttachment {
public async getContent(attachmentUuid: string): Promise<Buffer> {
await this.bunqAccount.apiContext.ensureValidSession();
const response = await plugins.smartrequest.request(
const response = await fetch(
`${this.bunqAccount.apiContext.getBaseUrl()}/v1/attachment-public/${attachmentUuid}/content`,
{
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);
}
/**

View File

@@ -111,27 +111,22 @@ export class BunqExport {
throw new Error('Export ID not set');
}
// First get the export details to find the attachment
const exportDetails = await this.get();
if (!exportDetails.attachment || exportDetails.attachment.length === 0) {
throw new Error('Export has no attachment');
// 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 response = await fetch(downloadUrl, {
method: 'GET',
headers: {
'X-Bunq-Client-Authentication': this.bunqAccount.apiContext.getSession().getContext().sessionToken
}
});
if (!response.ok) {
throw new Error(`Failed to download export: HTTP ${response.status}`);
}
const attachmentUuid = exportDetails.attachment[0].attachment_public_uuid;
// 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 arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**

View File

@@ -27,7 +27,7 @@ export class BunqHttpClient {
* Make an API request to bunq
*/
public async request<T = any>(options: IBunqRequestOptions): Promise<T> {
const url = `${this.context.baseUrl}${options.endpoint}`;
let url = `${this.context.baseUrl}${options.endpoint}`;
// Prepare headers
const headers = this.prepareHeaders(options);
@@ -45,47 +45,45 @@ export class BunqHttpClient {
);
}
// Make the request
const requestOptions: any = {
method: options.method === 'LIST' ? 'GET' : options.method,
headers: headers,
requestBody: body
};
// Handle query parameters
if (options.params) {
const params = new URLSearchParams();
const queryParams = new URLSearchParams();
Object.entries(options.params).forEach(([key, value]) => {
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 {
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
if (this.context.serverPublicKey) {
// Convert headers to string-only format
const stringHeaders: { [key: string]: string } = {};
for (const [key, value] of Object.entries(response.headers)) {
if (typeof value === 'string') {
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);
response.headers.forEach((value, key) => {
stringHeaders[key] = value;
});
const isValid = this.crypto.verifyResponseSignature(
response.statusCode,
response.status,
stringHeaders,
bodyString,
responseText,
this.context.serverPublicKey
);
@@ -99,17 +97,21 @@ export class BunqHttpClient {
}
}
// Parse response - smartrequest may already parse JSON automatically
// Parse response
let responseData;
if (typeof response.body === 'string') {
if (responseText) {
try {
responseData = JSON.parse(response.body);
responseData = JSON.parse(responseText);
} 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}`);
}
} else {
// Response is already parsed
responseData = response.body;
// Empty response body
responseData = {};
}
// Check for errors
@@ -117,6 +119,11 @@ export class BunqHttpClient {
throw new BunqApiError(responseData.Error);
}
// Check HTTP status
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return responseData;
} catch (error) {
if (error instanceof BunqApiError) {

View File

@@ -111,18 +111,41 @@ export class BunqMonetaryAccount {
/**
* 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[]> {
const paginationOptions: IBunqPaginationOptions = {
count: 200,
newer_id: startingIdArg,
public async getTransactions(options?: IBunqPaginationOptions | number | false): Promise<BunqTransaction[]> {
let paginationOptions: IBunqPaginationOptions = {};
// 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();
const response = await this.bunqAccountRef.getHttpClient().list(
`/v1/user/${this.bunqAccountRef.userId}/monetary-account/${this.id}/payment`,
paginationOptions
cleanPaginationOptions
);
const transactionsArray: BunqTransaction[] = [];

View File

@@ -9,7 +9,6 @@ import * as smartcrypto from '@push.rocks/smartcrypto';
import * as smartfile from '@push.rocks/smartfile';
import * as smartpath from '@push.rocks/smartpath';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smarttime from '@push.rocks/smarttime';
export { smartcrypto, smartfile, smartpath, smartpromise, smartrequest, smarttime };
export { smartcrypto, smartfile, smartpath, smartpromise, smarttime };