@iovdin/bunk 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # bunk — bunq CLI
2
+
3
+ A command-line tool to authenticate with [bunq](https://www.bunq.com) via OAuth2, download all your events and index payments locally into SQLite for fast offline querying.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g bunk
9
+ ```
10
+
11
+ Or for local dev:
12
+
13
+ ```bash
14
+ npm i
15
+ npm link # makes `bunk` available on your PATH
16
+ ```
17
+
18
+ > **Requires Node.js >= 22** — uses the built-in `node:sqlite` module.
19
+
20
+ ## Configuration
21
+
22
+ Config is stored at `~/.config/bunk/config.json` and is managed automatically by `bunk auth`.
23
+
24
+ ```json
25
+ {
26
+ "clientId": "...",
27
+ "clientSecret": "...",
28
+ "accessToken": "...",
29
+ "installationToken": "...",
30
+ "sessionToken": "...",
31
+ "userId": 12345678,
32
+ "publicKey": "-----BEGIN PUBLIC KEY-----\n...",
33
+ "privateKey": "-----BEGIN PRIVATE KEY-----\n..."
34
+ }
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### 1. Authenticate
40
+
41
+ ```bash
42
+ bunk auth
43
+ # or specify a custom callback port/host
44
+ bunk auth --port 4589 --host 127.0.0.1
45
+ ```
46
+
47
+ This will:
48
+ 1. Prompt you for your **OAuth2 `client_id` and `client_secret`** (from [bunq Developer settings](https://www.bunq.com/developer))
49
+ 2. Open the bunq authorization URL in your browser
50
+ 3. Start a local HTTP server to capture the OAuth2 callback
51
+ 4. Exchange the code for an `access_token`
52
+ 5. Register an RSA key pair, an installation and a device-server with the bunq API
53
+ 6. Create a session and save `sessionToken` + `userId` to config
54
+
55
+ All credentials are persisted to `~/.config/bunk/config.json` (mode `0600`) so subsequent runs skip steps already completed.
56
+
57
+ ---
58
+
59
+ ### 2. Fetch events
60
+
61
+ ```bash
62
+ bunk fetch
63
+ # or specify a custom database path
64
+ bunk fetch --output ~/bunq/index.sqlite
65
+ # verbose output
66
+ bunk fetch --verbose
67
+ # wipe database and re-fetch everything
68
+ bunk fetch --clean
69
+ ```
70
+
71
+ Downloads all bunq events (payments, card actions, etc.) from the API and stores the raw JSON in a local SQLite database.
72
+
73
+ - On the **first run** (empty database) it performs a full backfill, paginating back in time until there are no more events.
74
+ - On **subsequent runs** it only fetches events newer than the highest `id` already stored.
75
+
76
+ Events are stored in the `events` table:
77
+
78
+ ```sql
79
+ CREATE TABLE events (
80
+ id INTEGER PRIMARY KEY,
81
+ content TEXT NOT NULL -- raw bunq event JSON
82
+ );
83
+ ```
84
+
85
+ ---
86
+
87
+ ### 3. Index payments
88
+
89
+ ```bash
90
+ bunk index
91
+ # or specify a custom database path
92
+ bunk index --output ~/bunq/index.sqlite
93
+ ```
94
+
95
+ Extracts structured payment data from the raw `events` table into a `payments` table for easy querying. Supports both **Payment** and **MasterCardAction** event types.
96
+
97
+ ```sql
98
+ CREATE TABLE payments (
99
+ event_id INTEGER PRIMARY KEY,
100
+ created_at TEXT,
101
+ account_id TEXT,
102
+ amount_value REAL, -- negative for card payments / debits
103
+ amount_currency TEXT,
104
+ status TEXT,
105
+ description TEXT,
106
+ counterparty_name TEXT,
107
+ counterparty_iban TEXT,
108
+ my_name TEXT,
109
+ my_iban TEXT
110
+ );
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Example queries
116
+
117
+ ```sql
118
+ -- Last 20 transactions
119
+ SELECT event_id, created_at, amount_value, amount_currency, counterparty_name, description
120
+ FROM payments
121
+ ORDER BY created_at DESC
122
+ LIMIT 20;
123
+
124
+ -- Total spent per counterparty (debits only)
125
+ SELECT counterparty_name, ROUND(SUM(amount_value), 2) AS total
126
+ FROM payments
127
+ WHERE amount_value < 0
128
+ GROUP BY counterparty_name
129
+ ORDER BY total ASC
130
+ LIMIT 20;
131
+
132
+ -- All card payments this month
133
+ SELECT created_at, amount_value, description, counterparty_name
134
+ FROM payments
135
+ WHERE status != 'COMPLETED' -- MasterCardAction statuses differ
136
+ AND created_at >= date('now', 'start of month')
137
+ ORDER BY created_at DESC;
138
+ ```
139
+
140
+ ---
141
+
142
+ ## crontab
143
+
144
+ To keep your local database up to date automatically, add the following to your crontab (`crontab -e`).
145
+ Adjust the Node.js path to match your environment (`node --version` to check):
146
+
147
+ ```crontab
148
+ HOME=/Users/your_username
149
+ PATH=/Users/your_username/.nvm/versions/node/v22.20.0/bin:/usr/local/bin:/usr/bin:/bin
150
+
151
+ # Fetch new bunq events and index payments every 15 minutes
152
+ */15 * * * * bunk fetch && bunk index >> $HOME/bunk.log 2>&1
153
+ ```
package/bin/bunk.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const { auth } = require('../lib/auth');
5
+ const { fetchEvents } = require('../lib/fetch');
6
+ const { indexPayments } = require('../lib/index');
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name('bunk')
12
+ .description('bunq OAuth2 helper CLI')
13
+ .version('0.0.1');
14
+
15
+ program
16
+ .command('auth')
17
+ .description('Authenticate with bunq using OAuth2 and store tokens in ~/.config/bunk/config.txt')
18
+ .option('-p, --port <port>', 'Local callback server port', (v) => parseInt(v, 10), 4589)
19
+ .option('--host <host>', 'Local callback server host', '127.0.0.1')
20
+ .action(async (opts) => {
21
+ await auth(opts);
22
+ });
23
+
24
+ program
25
+ .command('fetch')
26
+ .description('Download all bunq events and store in SQLite database')
27
+ .option('-o, --output <path>', 'Output SQLite database path', '~/bunq/index.sqlite')
28
+ .option('-v, --verbose', 'Show detailed progress', false)
29
+ .option('--clean', 'Remove existing database and re-fetch everything', false)
30
+ .action(async (opts) => {
31
+ await fetchEvents(opts);
32
+ });
33
+
34
+ program
35
+ .command('index')
36
+ .description('Index latest events into payments table')
37
+ .option('-o, --output <path>', 'Output SQLite database path', '~/bunq/index.sqlite')
38
+ .action(async (opts) => {
39
+ await indexPayments(opts);
40
+ });
41
+
42
+ program.parseAsync(process.argv).catch((err) => {
43
+ console.error(err?.stack || String(err));
44
+ process.exit(1);
45
+ });
package/lib/auth.js ADDED
@@ -0,0 +1,413 @@
1
+ const http = require('http');
2
+ const { URL } = require('url');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require("crypto");
7
+
8
+ const environment = 'production'
9
+
10
+ const domains = {
11
+ sandbox: {
12
+ oauthApi: "api-oauth.sandbox.bunq.com",
13
+ oauth: "oauth.sandbox.bunq.com",
14
+ api: "public-api.sandbox.bunq.com"
15
+ },
16
+ production: {
17
+ oauthApi: "api.oauth.bunq.com",
18
+ oauth: "oauth.bunq.com",
19
+ api: "api.bunq.com"
20
+ }
21
+ }
22
+
23
+
24
+ function configDir() {
25
+ return path.join(os.homedir(), '.config', 'bunk');
26
+ }
27
+
28
+ function configPath() {
29
+ return path.join(configDir(), 'config.json');
30
+ }
31
+
32
+ function ensureDir(p) {
33
+ fs.mkdirSync(p, { recursive: true });
34
+ }
35
+
36
+ // config is stored as JSON; parsing/serialization is done inline in readExistingConfig/writeConfig
37
+
38
+ function readExistingConfig() {
39
+ try {
40
+ const text = fs.readFileSync(configPath(), 'utf8');
41
+ const trimmed = String(text || '').trim();
42
+ if (!trimmed) return {};
43
+ const obj = JSON.parse(trimmed);
44
+ return obj && typeof obj === 'object' ? obj : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function writeConfig(obj) {
51
+ ensureDir(configDir());
52
+ const text = JSON.stringify(obj || {}, null, 2) + '\n';
53
+ fs.writeFileSync(configPath(), text, { mode: 0o600 });
54
+ }
55
+
56
+ function question(prompt) {
57
+ return new Promise((resolve) => {
58
+ process.stdout.write(prompt);
59
+ let buf = '';
60
+ const onData = (chunk) => {
61
+ buf += chunk.toString('utf8');
62
+ const idx = buf.indexOf('\n');
63
+ if (idx >= 0) {
64
+ process.stdin.off('data', onData);
65
+ resolve(buf.slice(0, idx).trim());
66
+ }
67
+ };
68
+ process.stdin.on('data', onData);
69
+ });
70
+ }
71
+
72
+ async function exchangeToken({
73
+ grantType,
74
+ code,
75
+ redirectUri,
76
+ clientId,
77
+ clientSecret,
78
+ }) {
79
+ const u = new URL(`https://${domains[environment].oauthApi}/v1/token`);
80
+
81
+ u.searchParams.set('grant_type', grantType);
82
+ u.searchParams.set('redirect_uri', redirectUri);
83
+ u.searchParams.set('client_id', clientId);
84
+ u.searchParams.set('client_secret', clientSecret);
85
+ if (code) u.searchParams.set('code', code);
86
+
87
+ // bunq's OAuth token endpoint uses query parameters
88
+ const res = await fetch(u.toString(), { method: 'POST' });
89
+
90
+ if (!res.ok) {
91
+ const text = await res.text();
92
+ throw new Error(`bunq token endpoint error (${res.status}): ${text}`);
93
+ }
94
+ return res.json();
95
+ }
96
+
97
+
98
+ function generateBunqKeyPair() {
99
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
100
+ modulusLength: 2048,
101
+ publicKeyEncoding: {
102
+ type: "spki", // ✅ required for bunq
103
+ format: "pem"
104
+ },
105
+ privateKeyEncoding: {
106
+ type: "pkcs8", // ✅ required for bunq
107
+ format: "pem"
108
+ }
109
+ });
110
+
111
+ return {
112
+ publicKey,
113
+ privateKey
114
+ };
115
+ }
116
+
117
+ function signBody(body, privateKey) {
118
+ const signer = crypto.createSign("RSA-SHA256");
119
+ signer.update(body);
120
+ signer.end();
121
+ return signer.sign(privateKey, "base64");
122
+ }
123
+
124
+ /*
125
+ async function createSession({ access_token }) {
126
+ const res = await fetch(`https://${domains[environment].api}/v1/session-server`, {
127
+ method: 'POST',
128
+ headers: {
129
+ 'Content-Type': 'application/json',
130
+ 'X-Bunq-Client-Authentication': access_token,
131
+ 'X-Bunq-Language': 'en_US',
132
+ 'X-Bunq-Region': 'nl_NL',
133
+ 'X-Bunq-Client-Request-Id': Date.now().toString(),
134
+ 'Cache-Control': 'no-cache'
135
+ // Authorization: `Bearer ${access_token}`,
136
+ // Accept: 'application/json',
137
+ // 'Content-Type': 'application/json',
138
+ },
139
+ body: JSON.stringify({}),
140
+ });
141
+
142
+ const text = await res.text();
143
+ let json;
144
+ try {
145
+ json = text ? JSON.parse(text) : {};
146
+ } catch {
147
+ json = { raw: text };
148
+ }
149
+
150
+ if (!res.ok) {
151
+ throw new Error(
152
+ `bunq session-server error (${res.status}): ${typeof text === 'string' ? text : JSON.stringify(json)}`,
153
+ );
154
+ }
155
+
156
+ const sessionToken = res.headers.get('x-bunq-client-authentication');
157
+ if (!sessionToken) {
158
+ throw new Error(
159
+ `Missing x-bunq-client-authentication header from session-server response. Body: ${text}`,
160
+ );
161
+ }
162
+
163
+ // Try to find user id in response payload.
164
+ // bunq responses are typically arrays of objects like {UserPerson:{...}} wrapped in Response.
165
+ let userId;
166
+ try {
167
+ const responses = Array.isArray(json.Response) ? json.Response : [];
168
+ for (const item of responses) {
169
+ if (!item || typeof item !== 'object') continue;
170
+ const v = item.UserPerson || item.UserCompany || item.UserLight || item.UserApiKey;
171
+ if (v && v.id) {
172
+ userId = v.id;
173
+ break;
174
+ }
175
+ }
176
+ } catch {
177
+ // ignore
178
+ }
179
+
180
+ return { sessionToken, session: json, userId };
181
+ }
182
+ */
183
+
184
+ async function auth({ host = '127.0.0.1', port = 4589 }) {
185
+ const redirectUri = `http://${host}:${port}/callback`;
186
+
187
+ const existing = readExistingConfig();
188
+ let {
189
+ clientId, clientSecret,
190
+ accessToken,
191
+ publicKey, privateKey,
192
+ installationToken,
193
+ sessionToken,
194
+ userId
195
+ } = existing;
196
+
197
+ if (!publicKey || !privateKey) {
198
+ const res = generateBunqKeyPair()
199
+ writeConfig({ ...existing, ...res });
200
+
201
+ }
202
+ if (!clientId || !clientSecret) {
203
+ console.log(`\nThis command will start a local HTTP server to capture bunq OAuth redirect.`);
204
+ console.log(`1) In bunq Developer settings create an OAuth app (or edit existing).`);
205
+ console.log(`2) Set Redirect URL to: ${redirectUri}`);
206
+ console.log(`\nThen paste the OAuth credentials here (they will be stored in ${configPath()}):\n`);
207
+
208
+ clientId = clientId || (await question(`client_id: `));
209
+ clientSecret = clientSecret || (await question(`client_secret: `));
210
+
211
+ if (!clientId || !clientSecret) {
212
+ throw new Error('client_id and client_secret are required');
213
+ }
214
+
215
+ // Save immediately
216
+ writeConfig({ ...readExistingConfig(), clientId, clientSecret, redirectUri });
217
+ }
218
+
219
+ if (!accessToken) {
220
+ // bunq OAuth base (common)
221
+ const authUrl = new URL(`https://${domains[environment].oauth}/auth`);
222
+ authUrl.searchParams.set('response_type', 'code');
223
+ authUrl.searchParams.set('client_id', clientId);
224
+ authUrl.searchParams.set('redirect_uri', redirectUri);
225
+ // Keep scope optional; bunq may allow empty or require specific.
226
+ if (existing.scope) authUrl.searchParams.set('scope', existing.scope);
227
+
228
+ const finalAuthUrl = authUrl.toString();
229
+ console.log(`\nAuthorization URL (open in your browser):\n${finalAuthUrl}\n`);
230
+
231
+ // On macOS automatically open the browser.
232
+ if (process.platform === 'darwin') {
233
+ try {
234
+ const { spawn } = require('child_process');
235
+ spawn('open', [finalAuthUrl], { stdio: 'ignore', detached: true }).unref();
236
+ } catch {
237
+ // ignore
238
+ }
239
+ }
240
+
241
+ console.log('Waiting for you to authorize in bunq...');
242
+
243
+ const code = await new Promise((resolve, reject) => {
244
+ const server = http.createServer(async (req, res) => {
245
+ try {
246
+ const u = new URL(req.url, `http://${host}:${port}`);
247
+ if (u.pathname !== '/callback') {
248
+ res.writeHead(404, { 'content-type': 'text/plain' });
249
+ res.end('Not found');
250
+ return;
251
+ }
252
+
253
+ const err = u.searchParams.get('error');
254
+ if (err) {
255
+ const desc = u.searchParams.get('error_description') || '';
256
+ res.writeHead(400, { 'content-type': 'text/plain' });
257
+ res.end(`OAuth error: ${err} ${desc}`);
258
+ server.close();
259
+ reject(new Error(`OAuth error: ${err} ${desc}`));
260
+ return;
261
+ }
262
+
263
+ const code = u.searchParams.get('code');
264
+ if (!code) {
265
+ res.writeHead(400, { 'content-type': 'text/plain' });
266
+ res.end('Missing ?code=');
267
+ return;
268
+ }
269
+
270
+ const html = `You can return to the terminal`;
271
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
272
+ res.end(html);
273
+ server.close();
274
+ resolve(code);
275
+ } catch (e) {
276
+ reject(e);
277
+ }
278
+ });
279
+
280
+ server.on('error', reject);
281
+ server.listen(port, host, () => {
282
+ // listening
283
+ });
284
+ });
285
+
286
+ console.log('Received authorization code, exchanging for tokens...');
287
+
288
+ const tokenRes = await exchangeToken({
289
+ grantType: 'authorization_code',
290
+ code,
291
+ redirectUri,
292
+ clientId,
293
+ clientSecret,
294
+ });
295
+
296
+ const { access_token } = tokenRes;
297
+ if (!access_token) {
298
+ throw new Error(`Token response missing access_token:\n${JSON.stringify(tokenRes)}`);
299
+ }
300
+
301
+ accessToken = access_token
302
+ writeConfig({
303
+ ...readExistingConfig(),
304
+ accessToken: access_token
305
+ });
306
+ }
307
+
308
+ if (!installationToken) {
309
+ console.log('Creating bunq installation token...');
310
+
311
+ let res = await fetch(
312
+ `https://${domains[environment].api}/v1/installation`,
313
+ {
314
+ method: "POST",
315
+ headers: {
316
+ "Cache-Control": "no-cache",
317
+ "User-Agent": "bunk-app",
318
+ "Content-Type": "application/json"
319
+ },
320
+ body: JSON.stringify({
321
+ client_public_key: publicKey
322
+ })
323
+ }
324
+ );
325
+
326
+ if (!res.ok) {
327
+ const text = await res.text();
328
+ throw new Error(`bunq installation token error (${res.status}): ${text}`);
329
+ }
330
+ res = await res.json();
331
+ console.log(JSON.stringify(res, null, " "))
332
+ installationToken = res.Response[1].Token.token;
333
+
334
+
335
+ writeConfig({
336
+ ...readExistingConfig(),
337
+ installationToken
338
+ });
339
+ console.log('Registering bunq device');
340
+
341
+ const body = JSON.stringify({
342
+ description: "bunk server",
343
+ secret: accessToken
344
+ });
345
+
346
+ res = await fetch(
347
+ `https://${domains[environment].api}/v1/device-server`, {
348
+ method: "POST",
349
+ body,
350
+ headers: {
351
+ "Cache-Control": "no-cache",
352
+ "User-Agent": "bunk-app",
353
+ "Content-Type": "application/json",
354
+ "X-Bunq-Client-Authentication": installationToken,
355
+ "X-Bunq-Client-Signature": signBody(body, privateKey)
356
+ }
357
+ });
358
+
359
+ if (!res.ok) {
360
+ const text = await res.text();
361
+ throw new Error(`bunq device registration error (${res.status}): ${text}`);
362
+ }
363
+ }
364
+
365
+ if (!sessionToken || !userId) {
366
+ console.log('Creating bunq API session ...');
367
+
368
+ const body = JSON.stringify({
369
+ secret: accessToken
370
+ });
371
+ let res = await fetch(
372
+ `https://${domains[environment].api}/v1/session-server`, {
373
+ method: "POST",
374
+ body,
375
+ headers: {
376
+ "Cache-Control": "no-cache",
377
+ "User-Agent": "bunk-app",
378
+ "Content-Type": "application/json",
379
+ "X-Bunq-Client-Authentication": installationToken,
380
+ "X-Bunq-Client-Signature": signBody(body, privateKey)
381
+ }
382
+ });
383
+
384
+ if (!res.ok) {
385
+ const text = await res.text();
386
+ throw new Error(`bunq device registration error (${res.status}): ${text}`);
387
+ }
388
+ res = await res.json()
389
+ console.log(JSON.stringify(res, null, " "))
390
+
391
+ const response = res?.Response ?? [];
392
+
393
+ sessionToken =
394
+ response.find(x => x?.Token?.token != null)?.Token?.token ?? null;
395
+
396
+ userId =
397
+ response.find(x => x?.UserPerson?.id != null)?.UserPerson?.id ??
398
+ response.find(x => x?.UserApiKey?.id != null)?.UserApiKey?.id ??
399
+ null;
400
+
401
+ writeConfig({
402
+ ...readExistingConfig(),
403
+ sessionToken,
404
+ userId
405
+ });
406
+
407
+ }
408
+
409
+
410
+ console.log(`\nSaved tokens + session to ${configPath()}`);
411
+ }
412
+
413
+ module.exports = { auth, configPath };
package/lib/fetch.js ADDED
@@ -0,0 +1,232 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+
5
+ // Node.js v25+ built-in SQLite
6
+ const sqlite = require('node:sqlite');
7
+
8
+ const { configPath } = require('./auth');
9
+
10
+ const environment = 'production';
11
+ const domains = {
12
+ sandbox: {
13
+ oauthApi: 'api-oauth.sandbox.bunq.com',
14
+ oauth: 'oauth.sandbox.bunq.com',
15
+ api: 'public-api.sandbox.bunq.com',
16
+ },
17
+ production: {
18
+ oauthApi: 'api.oauth.bunq.com',
19
+ oauth: 'oauth.bunq.com',
20
+ api: 'api.bunq.com',
21
+ },
22
+ };
23
+
24
+ function expandHome(p) {
25
+ if (!p) return p;
26
+ if (p === '~') return os.homedir();
27
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
28
+ return p;
29
+ }
30
+
31
+ function readExistingConfig() {
32
+ try {
33
+ const text = fs.readFileSync(configPath(), 'utf8');
34
+ const trimmed = String(text || '').trim();
35
+ if (!trimmed) return {};
36
+ const obj = JSON.parse(trimmed);
37
+ return obj && typeof obj === 'object' ? obj : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function ensureDir(p) {
44
+ fs.mkdirSync(p, { recursive: true });
45
+ }
46
+
47
+ function resolveDbPath(opts, config) {
48
+ const fromOpts = opts && opts.output;
49
+ const fromConfig = config && config.output;
50
+ const p = expandHome(fromOpts || fromConfig || '~/bunq/index.sqlite');
51
+ return p;
52
+ }
53
+
54
+ function getDb(dbPath) {
55
+ ensureDir(path.dirname(dbPath));
56
+ const db = new sqlite.DatabaseSync(dbPath);
57
+ // Improve concurrency a bit and speed inserts
58
+ db.exec('PRAGMA journal_mode = WAL;');
59
+ db.exec('PRAGMA synchronous = NORMAL;');
60
+ return db;
61
+ }
62
+
63
+ function ensureSchema(db) {
64
+ db.exec(`
65
+ CREATE TABLE IF NOT EXISTS events (
66
+ id INTEGER PRIMARY KEY,
67
+ content TEXT NOT NULL
68
+ );
69
+ `);
70
+ }
71
+
72
+ function getMaxId(db) {
73
+ const row = db.prepare('SELECT MAX(id) AS max_id FROM events').get();
74
+ return row && row.max_id ? Number(row.max_id) : 0;
75
+ }
76
+
77
+ function deleteDbFiles(dbPath) {
78
+ for (const p of [dbPath, dbPath + '-wal', dbPath + '-shm']) {
79
+ try {
80
+ fs.unlinkSync(p);
81
+ } catch {}
82
+ }
83
+ }
84
+
85
+ async function bunqGetJson(urlPath, { sessionToken } = {}) {
86
+ const url = urlPath.startsWith('http')
87
+ ? urlPath
88
+ : `https://${domains[environment].api}${urlPath}`;
89
+
90
+ const res = await fetch(url, {
91
+ method: 'GET',
92
+ headers: {
93
+ 'X-Bunq-Client-Authentication': sessionToken,
94
+ 'Content-Type': 'application/json',
95
+ 'User-Agent': 'bunk-cli/1.0',
96
+ 'X-Bunq-Language': 'en_US',
97
+ 'X-Bunq-Region': 'en_US',
98
+ 'X-Bunq-Geolocation': '0 0 0 0 NL',
99
+ },
100
+ });
101
+
102
+ if (!res.ok) {
103
+ const text = await res.text();
104
+ throw new Error(`bunq GET ${urlPath} failed (${res.status}): ${text}`);
105
+ }
106
+ return res.json();
107
+ }
108
+
109
+ function extractEvents(payload) {
110
+ const out = [];
111
+ const arr = Array.isArray(payload?.Response) ? payload.Response : [];
112
+ for (const item of arr) {
113
+ const ev = item?.Event;
114
+ if (ev && ev.id != null) out.push(ev);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ async function fetchEvents(opts = {}) {
120
+ const verbose = !!opts.verbose;
121
+ const clean = !!opts.clean;
122
+
123
+ const cfg = readExistingConfig();
124
+ const sessionToken = cfg.sessionToken;
125
+ const userId = cfg.userId;
126
+
127
+ if (!sessionToken) {
128
+ throw new Error(`Missing sessionToken in ${configPath()}. Run: bunk auth`);
129
+ }
130
+ if (!userId) {
131
+ throw new Error(`Missing userId in ${configPath()}. Run: bunk auth`);
132
+ }
133
+
134
+ const dbPath = resolveDbPath(opts, cfg);
135
+
136
+ if (clean) {
137
+ if (verbose) console.log(`--clean: removing ${dbPath} (and -wal/-shm)`);
138
+ deleteDbFiles(dbPath);
139
+ }
140
+
141
+ const db = getDb(dbPath);
142
+ try {
143
+ ensureSchema(db);
144
+
145
+ const maxId = getMaxId(db);
146
+ const isEmpty = maxId === 0;
147
+
148
+ // per your comment: if collection is empty behave like --all
149
+ const modeAll = isEmpty;
150
+
151
+ // Insert/upsert (node:sqlite does not provide db.transaction like better-sqlite3)
152
+ const ins = db.prepare('INSERT OR REPLACE INTO events (id, content) VALUES (?, ?)');
153
+ const insertMany = (events) => {
154
+ db.exec('BEGIN');
155
+ try {
156
+ for (const ev of events) {
157
+ ins.run(Number(ev.id), JSON.stringify(ev));
158
+ }
159
+ db.exec('COMMIT');
160
+ } catch (e) {
161
+ try {
162
+ db.exec('ROLLBACK');
163
+ } catch {}
164
+ throw e;
165
+ }
166
+ };
167
+
168
+ const count = 200;
169
+
170
+ let nextPath;
171
+ if (modeAll) {
172
+ nextPath = `/v1/user/${userId}/event?count=${count}`;
173
+ console.log(`Database: ${dbPath}`);
174
+ console.log(`events table empty -> full backfill mode (count=${count})`);
175
+ } else {
176
+ // Only fetch newer than current max id
177
+ nextPath = `/v1/user/${userId}/event?count=${count}&newer_id=${encodeURIComponent(String(maxId))}`;
178
+ console.log(`Database: ${dbPath}`);
179
+ console.log(`Fetching newer events since id=${maxId} (count=${count})`);
180
+ }
181
+
182
+ let pages = 0;
183
+ let inserted = 0;
184
+
185
+ let lastPath = null;
186
+ while (nextPath) {
187
+ // Detect non-advancing pagination
188
+ if (nextPath === lastPath) {
189
+ if (verbose) console.warn(`Pagination did not advance (stuck on ${nextPath}). Stopping.`);
190
+ break;
191
+ }
192
+ lastPath = nextPath;
193
+
194
+ pages++;
195
+ if (verbose) console.log(`GET ${nextPath}`);
196
+ const json = await bunqGetJson(nextPath, { sessionToken });
197
+
198
+ const events = extractEvents(json);
199
+ if (events.length) {
200
+ insertMany(events);
201
+ inserted += events.length;
202
+ }
203
+
204
+ const pag = json?.Pagination || {};
205
+ if (modeAll) {
206
+ // walk back in time until older_url is null
207
+ nextPath = pag.older_url || null;
208
+ } else {
209
+ // When using newer_id, bunq may keep returning future_url=null.
210
+ // In that case, stop after the first page.
211
+ nextPath = pag.future_url || null;
212
+ if (!nextPath) {
213
+ break;
214
+ }
215
+ }
216
+
217
+ if (!verbose) {
218
+ process.stdout.write(`\rpages=${pages} inserted=${inserted}`);
219
+ }
220
+
221
+ // Safety: prevent infinite loops if API misbehaves
222
+ if (pages > 1_000_000) throw new Error('too many pages; aborting');
223
+ }
224
+
225
+ if (!verbose) process.stdout.write('\n');
226
+ console.log(`Done. pages=${pages}, inserted=${inserted}, maxId(now)=${getMaxId(db)}`);
227
+ } finally {
228
+ db.close();
229
+ }
230
+ }
231
+
232
+ module.exports = { fetchEvents };
package/lib/index.js ADDED
@@ -0,0 +1,116 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const sqlite = require('node:sqlite');
5
+
6
+ function expandHome(p) {
7
+ if (!p) return p;
8
+ if (p === '~') return os.homedir();
9
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
10
+ return p;
11
+ }
12
+
13
+ function ensureDir(p) {
14
+ fs.mkdirSync(p, { recursive: true });
15
+ }
16
+
17
+ function getDb(dbPath) {
18
+ ensureDir(path.dirname(dbPath));
19
+ const db = new sqlite.DatabaseSync(dbPath);
20
+ db.exec('PRAGMA journal_mode = WAL;');
21
+ db.exec('PRAGMA synchronous = NORMAL;');
22
+ return db;
23
+ }
24
+
25
+ function ensurePaymentsSchema(db) {
26
+ db.exec(`
27
+ CREATE TABLE IF NOT EXISTS payments (
28
+ event_id INTEGER PRIMARY KEY,
29
+ created_at TEXT,
30
+ account_id TEXT,
31
+ amount_value REAL,
32
+ amount_currency TEXT,
33
+ status TEXT,
34
+ description TEXT,
35
+ counterparty_name TEXT,
36
+ counterparty_iban TEXT,
37
+ my_name TEXT,
38
+ my_iban TEXT
39
+ );
40
+
41
+ CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at);
42
+ CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
43
+ CREATE INDEX IF NOT EXISTS idx_payments_counterparty_name ON payments(counterparty_name);
44
+ `);
45
+ }
46
+
47
+ function indexPayments(opts = {}) {
48
+ const dbPath = expandHome(opts.output || '~/bunq/index.sqlite');
49
+ const db = getDb(dbPath);
50
+
51
+ try {
52
+ ensurePaymentsSchema(db);
53
+
54
+ const row = db.prepare('SELECT COALESCE(MAX(event_id), 0) AS max_event_id FROM payments').get();
55
+ const maxEventId = Number(row?.max_event_id || 0);
56
+
57
+ const sql = `
58
+ INSERT OR IGNORE INTO payments (
59
+ event_id,
60
+ created_at,
61
+ account_id,
62
+ amount_value,
63
+ amount_currency,
64
+ status,
65
+ description,
66
+ counterparty_name,
67
+ counterparty_iban,
68
+ my_name,
69
+ my_iban
70
+ )
71
+ SELECT
72
+ json_extract(content, '$.id') AS event_id,
73
+ json_extract(content, '$.created') AS created_at,
74
+ json_extract(content, '$.monetary_account_id') AS account_id,
75
+ CAST(json_extract(content, '$.object.Payment.amount.value') AS REAL) AS amount_value,
76
+ json_extract(content, '$.object.Payment.amount.currency') AS amount_currency,
77
+ json_extract(content, '$.status') AS status,
78
+ json_extract(content, '$.object.Payment.description') AS description,
79
+ json_extract(content, '$.object.Payment.counterparty_alias.display_name') AS counterparty_name,
80
+ json_extract(content, '$.object.Payment.counterparty_alias.iban') AS counterparty_iban,
81
+ json_extract(content, '$.object.Payment.alias.display_name') AS my_name,
82
+ json_extract(content, '$.object.Payment.alias.iban') AS my_iban
83
+ FROM events
84
+ WHERE json_type(content, '$.object.Payment') = 'object'
85
+ AND CAST(json_extract(content, '$.id') AS INTEGER) > ?
86
+
87
+ UNION ALL
88
+
89
+ SELECT
90
+ json_extract(content, '$.id') AS event_id,
91
+ json_extract(content, '$.created') AS created_at,
92
+ json_extract(content, '$.monetary_account_id') AS account_id,
93
+ -ABS(CAST(json_extract(content, '$.object.MasterCardAction.amount_local.value') AS REAL)) AS amount_value,
94
+ json_extract(content, '$.object.MasterCardAction.amount_local.currency') AS amount_currency,
95
+ json_extract(content, '$.object.MasterCardAction.payment_status') AS status,
96
+ json_extract(content, '$.object.MasterCardAction.description') AS description,
97
+ json_extract(content, '$.object.MasterCardAction.counterparty_alias.display_name') AS counterparty_name,
98
+ json_extract(content, '$.object.MasterCardAction.counterparty_alias.iban') AS counterparty_iban,
99
+ json_extract(content, '$.object.MasterCardAction.alias.display_name') AS my_name,
100
+ json_extract(content, '$.object.MasterCardAction.alias.iban') AS my_iban
101
+ FROM events
102
+ WHERE json_type(content, '$.object.MasterCardAction') = 'object'
103
+ AND CAST(json_extract(content, '$.id') AS INTEGER) > ?
104
+ `;
105
+
106
+ const before = db.prepare('SELECT COUNT(*) AS n FROM payments').get().n;
107
+ db.prepare(sql).run(maxEventId, maxEventId);
108
+ const after = db.prepare('SELECT COUNT(*) AS n FROM payments').get().n;
109
+
110
+ console.log(`Indexed payments: +${after - before} (total=${after}) from events newer than event_id=${maxEventId}`);
111
+ } finally {
112
+ db.close();
113
+ }
114
+ }
115
+
116
+ module.exports = { indexPayments };
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@iovdin/bunk",
3
+ "version": "1.0.1",
4
+ "homepage": "https://github.com/iovdin/bunk#readme",
5
+ "description": "bunq CLI download and index transactions into sqlite database",
6
+ "type": "commonjs",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/iovdin/bunk.git"
10
+ },
11
+ "bin": {
12
+ "bunk": "bin/bunk.js"
13
+ },
14
+ "engines": {
15
+ "node": ">=22.0.0"
16
+ },
17
+ "scripts": {
18
+ "start": "node bin/bunk.js"
19
+ },
20
+ "dependencies": {
21
+ "commander": "^14.0.3"
22
+ },
23
+ "author": "Ilya Ovdin <iovdin@gmail.com>",
24
+ "license": "MIT"
25
+ }