@iovdin/bunk 1.0.2 → 1.0.5

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 CHANGED
@@ -1,11 +1,11 @@
1
1
  # bunk — bunq CLI
2
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.
3
+ A command-line tool to authenticate with [bunq](https://www.bunq.com) via OAuth2 and index Payments locally into SQLite for fast offline querying.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install -g bunk
8
+ npm install -g @iovdin/bunk
9
9
  ```
10
10
 
11
11
  Or for local dev:
@@ -19,20 +19,18 @@ npm link # makes `bunk` available on your PATH
19
19
 
20
20
  ## Configuration
21
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
- ```
22
+ Credentials are stored securely in the system keychain (macOS Keychain, GNOME Keyring, KWallet, Windows Credential Manager) and are managed automatically by `bunk auth`. The following items are stored under the service name `bunk`:
23
+
24
+ | Item | Description |
25
+ |------|-------------|
26
+ | `BUNQ_CLIENT_ID` | OAuth2 client ID from bunq Developer settings |
27
+ | `BUNQ_CLIENT_SECRET` | OAuth2 client secret |
28
+ | `BUNQ_ACCESS_TOKEN` | OAuth2 access token |
29
+ | `BUNQ_INSTALLATION_TOKEN` | bunq installation token |
30
+ | `BUNQ_SESSION_TOKEN` | bunq session token |
31
+ | `BUNQ_USER_ID` | Your bunq user ID |
32
+ | `BUNQ_PUBLIC_KEY` | RSA public key for bunq API |
33
+ | `BUNQ_PRIVATE_KEY` | RSA private key for bunq API |
36
34
 
37
35
  ## Usage
38
36
 
@@ -50,13 +48,13 @@ This will:
50
48
  3. Start a local HTTP server to capture the OAuth2 callback
51
49
  4. Exchange the code for an `access_token`
52
50
  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
51
+ 6. Create a session and save `sessionToken` + `userId` to keychain
54
52
 
55
- All credentials are persisted to `~/.config/bunk/config.json` (mode `0600`) so subsequent runs skip steps already completed.
53
+ All credentials are persisted to your system keychain so subsequent runs skip steps already completed.
56
54
 
57
55
  ---
58
56
 
59
- ### 2. Fetch events
57
+ ### 2. Fetch payments
60
58
 
61
59
  ```bash
62
60
  bunk fetch
@@ -68,46 +66,31 @@ bunk fetch --verbose
68
66
  bunk fetch --clean
69
67
  ```
70
68
 
71
- Downloads all bunq events (payments, card actions, etc.) from the API and stores the raw JSON in a local SQLite database.
69
+ Downloads Payments for each monetary account and stores a normalized row per payment in a local SQLite database. The tool only fetches the `payment` collection (no other event types).
72
70
 
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.
71
+ On the **first run** (empty database) it performs a full backfill, paginating back in time until there are no more payments. On **subsequent runs** it only fetches payments newer than the highest `id` already stored.
75
72
 
76
- Events are stored in the `events` table:
73
+ Payments are stored in the `payment` table with a normalized schema:
77
74
 
78
75
  ```sql
79
- CREATE TABLE events (
80
- id INTEGER PRIMARY KEY,
81
- content TEXT NOT NULL -- raw bunq event JSON
76
+ CREATE TABLE payment (
77
+ id INTEGER PRIMARY KEY,
78
+ monetary_account_id INTEGER NOT NULL,
79
+ created TEXT,
80
+ amount_value TEXT,
81
+ amount_currency TEXT,
82
+ alias_iban TEXT,
83
+ alias_name TEXT,
84
+ counterparty_alias_iban TEXT,
85
+ counterparty_alias_name TEXT,
86
+ description TEXT,
87
+ type TEXT,
88
+ sub_type TEXT,
89
+ balance_value TEXT,
90
+ balance_currency TEXT
82
91
  );
83
- ```
84
92
 
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
- );
93
+ CREATE INDEX IF NOT EXISTS idx_payment_account ON payment (monetary_account_id);
111
94
  ```
112
95
 
113
96
  ---
@@ -115,39 +98,37 @@ CREATE TABLE payments (
115
98
  ## Example queries
116
99
 
117
100
  ```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
101
+ -- Last 20 payments
102
+ SELECT id, created, amount_value, amount_currency, counterparty_alias_name AS counterparty_name, description
103
+ FROM payment
104
+ ORDER BY created DESC
122
105
  LIMIT 20;
123
106
 
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
107
+ -- Total spent per counterparty (outgoing payments have negative amounts relative to the account)
108
+ SELECT counterparty_alias_name AS counterparty_name, ROUND(SUM(CAST(amount_value AS REAL)), 2) AS total
109
+ FROM payment
110
+ WHERE CAST(amount_value AS REAL) < 0
111
+ GROUP BY counterparty_alias_name
129
112
  ORDER BY total ASC
130
113
  LIMIT 20;
131
114
 
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;
115
+ -- Payments this month
116
+ SELECT created, amount_value, description, counterparty_alias_name
117
+ FROM payment
118
+ WHERE created >= date('now', 'start of month')
119
+ ORDER BY created DESC;
138
120
  ```
139
121
 
140
122
  ---
141
123
 
142
124
  ## crontab
143
125
 
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):
126
+ To keep your local database up to date automatically, add the following to your crontab (`crontab -e`). Adjust the Node.js path to match your environment (`node --version` to check):
146
127
 
147
128
  ```crontab
148
129
  HOME=/Users/your_username
149
130
  PATH=/Users/your_username/.nvm/versions/node/v22.20.0/bin:/usr/local/bin:/usr/bin:/bin
150
131
 
151
- # Fetch new bunq events and index payments every 15 minutes
152
- */15 * * * * bunk fetch && bunk index >> $HOME/bunk.log 2>&1
132
+ # Fetch new bunq payments every 15 minutes
133
+ */15 * * * * bunk fetch >> $HOME/bunk.log 2>&1
153
134
  ```
package/bin/bunk.js CHANGED
@@ -3,7 +3,6 @@
3
3
  const { Command } = require('commander');
4
4
  const { auth } = require('../lib/auth');
5
5
  const { fetchEvents } = require('../lib/fetch');
6
- const { indexPayments } = require('../lib/index');
7
6
 
8
7
  const program = new Command();
9
8
 
@@ -14,7 +13,7 @@ program
14
13
 
15
14
  program
16
15
  .command('auth')
17
- .description('Authenticate with bunq using OAuth2 and store tokens in ~/.config/bunk/config.txt')
16
+ .description('Authenticate with bunq using OAuth2 and store tokens in keyring')
18
17
  .option('-p, --port <port>', 'Local callback server port', (v) => parseInt(v, 10), 4589)
19
18
  .option('--host <host>', 'Local callback server host', '127.0.0.1')
20
19
  .action(async (opts) => {
@@ -31,15 +30,7 @@ program
31
30
  await fetchEvents(opts);
32
31
  });
33
32
 
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
33
  program.parseAsync(process.argv).catch((err) => {
43
34
  console.error(err?.stack || String(err));
44
35
  process.exit(1);
45
- });
36
+ });
package/lib/auth.js CHANGED
@@ -4,8 +4,10 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const crypto = require("crypto");
7
+ const { Entry } = require("@napi-rs/keyring")
7
8
 
8
9
  const environment = 'production'
10
+ // const environment = 'sandbox'
9
11
 
10
12
  const domains = {
11
13
  sandbox: {
@@ -21,38 +23,20 @@ const domains = {
21
23
  }
22
24
 
23
25
 
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() {
26
+ function getSecret(name) {
39
27
  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 {};
28
+ return new Entry(name, 'personal').getPassword() || null;
29
+ } catch (e) {
30
+ console.error(e)
31
+ return null;
47
32
  }
48
33
  }
49
34
 
50
- function writeConfig(obj) {
51
- ensureDir(configDir());
52
- const text = JSON.stringify(obj || {}, null, 2) + '\n';
53
- fs.writeFileSync(configPath(), text, { mode: 0o600 });
35
+ function setSecret(name, value) {
36
+ new Entry(name, 'personal').setPassword(value);
54
37
  }
55
38
 
39
+
56
40
  function question(prompt) {
57
41
  return new Promise((resolve) => {
58
42
  process.stdout.write(prompt);
@@ -121,89 +105,30 @@ function signBody(body, privateKey) {
121
105
  return signer.sign(privateKey, "base64");
122
106
  }
123
107
 
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
108
  async function auth({ host = '127.0.0.1', port = 4589 }) {
185
109
  const redirectUri = `http://${host}:${port}/callback`;
186
110
 
187
- const existing = readExistingConfig();
188
- let {
189
- clientId, clientSecret,
190
- accessToken,
191
- publicKey, privateKey,
192
- installationToken,
193
- sessionToken,
194
- userId
195
- } = existing;
111
+ let userId = getSecret('BUNQ_USER_ID');
112
+ let clientId = getSecret('BUNQ_CLIENT_ID');
113
+ let clientSecret = getSecret('BUNQ_CLIENT_SECRET');
114
+ let accessToken = getSecret('BUNQ_ACCESS_TOKEN');
115
+ let publicKey = getSecret('BUNQ_PUBLIC_KEY');
116
+ let privateKey = getSecret('BUNQ_PRIVATE_KEY');
117
+ let installationToken = getSecret('BUNQ_INSTALLATION_TOKEN');
118
+ let sessionToken = getSecret('BUNQ_SESSION_TOKEN');
196
119
 
197
120
  if (!publicKey || !privateKey) {
198
- const res = generateBunqKeyPair()
199
- writeConfig({ ...existing, ...res });
200
-
121
+ const res = generateBunqKeyPair();
122
+ publicKey = res.publicKey;
123
+ privateKey = res.privateKey;
124
+ setSecret('BUNQ_PUBLIC_KEY', publicKey);
125
+ setSecret('BUNQ_PRIVATE_KEY', privateKey);
201
126
  }
202
127
  if (!clientId || !clientSecret) {
203
128
  console.log(`\nThis command will start a local HTTP server to capture bunq OAuth redirect.`);
204
129
  console.log(`1) In bunq Developer settings create an OAuth app (or edit existing).`);
205
130
  console.log(`2) Set Redirect URL to: ${redirectUri}`);
206
- console.log(`\nThen paste the OAuth credentials here (they will be stored in ${configPath()}):\n`);
131
+ console.log(`\nThen paste the OAuth credentials here (they will be stored in your keychain):\n`);
207
132
 
208
133
  clientId = clientId || (await question(`client_id: `));
209
134
  clientSecret = clientSecret || (await question(`client_secret: `));
@@ -212,8 +137,8 @@ async function auth({ host = '127.0.0.1', port = 4589 }) {
212
137
  throw new Error('client_id and client_secret are required');
213
138
  }
214
139
 
215
- // Save immediately
216
- writeConfig({ ...readExistingConfig(), clientId, clientSecret, redirectUri });
140
+ setSecret('BUNQ_CLIENT_ID', clientId);
141
+ setSecret('BUNQ_CLIENT_SECRET', clientSecret);
217
142
  }
218
143
 
219
144
  if (!accessToken) {
@@ -222,8 +147,6 @@ async function auth({ host = '127.0.0.1', port = 4589 }) {
222
147
  authUrl.searchParams.set('response_type', 'code');
223
148
  authUrl.searchParams.set('client_id', clientId);
224
149
  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
150
 
228
151
  const finalAuthUrl = authUrl.toString();
229
152
  console.log(`\nAuthorization URL (open in your browser):\n${finalAuthUrl}\n`);
@@ -298,11 +221,8 @@ async function auth({ host = '127.0.0.1', port = 4589 }) {
298
221
  throw new Error(`Token response missing access_token:\n${JSON.stringify(tokenRes)}`);
299
222
  }
300
223
 
301
- accessToken = access_token
302
- writeConfig({
303
- ...readExistingConfig(),
304
- accessToken: access_token
305
- });
224
+ accessToken = access_token;
225
+ setSecret('BUNQ_ACCESS_TOKEN', accessToken);
306
226
  }
307
227
 
308
228
  if (!installationToken) {
@@ -328,14 +248,10 @@ async function auth({ host = '127.0.0.1', port = 4589 }) {
328
248
  throw new Error(`bunq installation token error (${res.status}): ${text}`);
329
249
  }
330
250
  res = await res.json();
331
- console.log(JSON.stringify(res, null, " "))
332
251
  installationToken = res.Response[1].Token.token;
333
252
 
334
253
 
335
- writeConfig({
336
- ...readExistingConfig(),
337
- installationToken
338
- });
254
+ setSecret('BUNQ_INSTALLATION_TOKEN', installationToken);
339
255
  console.log('Registering bunq device');
340
256
 
341
257
  const body = JSON.stringify({
@@ -386,7 +302,6 @@ async function auth({ host = '127.0.0.1', port = 4589 }) {
386
302
  throw new Error(`bunq device registration error (${res.status}): ${text}`);
387
303
  }
388
304
  res = await res.json()
389
- console.log(JSON.stringify(res, null, " "))
390
305
 
391
306
  const response = res?.Response ?? [];
392
307
 
@@ -398,16 +313,14 @@ async function auth({ host = '127.0.0.1', port = 4589 }) {
398
313
  response.find(x => x?.UserApiKey?.id != null)?.UserApiKey?.id ??
399
314
  null;
400
315
 
401
- writeConfig({
402
- ...readExistingConfig(),
403
- sessionToken,
404
- userId
405
- });
316
+ setSecret('BUNQ_SESSION_TOKEN', sessionToken);
317
+ setSecret('BUNQ_USER_ID', String(userId));
406
318
 
407
319
  }
408
320
 
409
321
 
410
- console.log(`\nSaved tokens + session to ${configPath()}`);
322
+ console.log(`\nSaved tokens + session to keychain`);
323
+ process.exit(0)
411
324
  }
412
325
 
413
- module.exports = { auth, configPath };
326
+ module.exports = { auth, getSecret, setSecret };
package/lib/fetch.js CHANGED
@@ -5,13 +5,13 @@ const path = require('path');
5
5
  // Node.js v25+ built-in SQLite
6
6
  const sqlite = require('node:sqlite');
7
7
 
8
- const { configPath } = require('./auth');
8
+ const { getSecret, configPath } = require('./auth');
9
9
 
10
10
  const environment = 'production';
11
11
  const domains = {
12
12
  sandbox: {
13
13
  oauthApi: 'api-oauth.sandbox.bunq.com',
14
- oauth: 'oauth.sandbox.bunq.com',
14
+ oauth: 'oauth.sanbox.bunq.com',
15
15
  api: 'public-api.sandbox.bunq.com',
16
16
  },
17
17
  production: {
@@ -21,6 +21,11 @@ const domains = {
21
21
  },
22
22
  };
23
23
 
24
+ // Only fetch payments
25
+ const COLLECTIONS = [
26
+ { name: 'payment', endpoint: 'payment' },
27
+ ];
28
+
24
29
  function expandHome(p) {
25
30
  if (!p) return p;
26
31
  if (p === '~') return os.homedir();
@@ -61,16 +66,35 @@ function getDb(dbPath) {
61
66
  }
62
67
 
63
68
  function ensureSchema(db) {
69
+ // Create a normalized table for payments. If the table already exists with other
70
+ // columns, attempt to add missing columns. This keeps compatibility with existing DBs.
64
71
  db.exec(`
65
- CREATE TABLE IF NOT EXISTS events (
72
+ CREATE TABLE IF NOT EXISTS payment (
66
73
  id INTEGER PRIMARY KEY,
67
- content TEXT NOT NULL
74
+ monetary_account_id INTEGER NOT NULL,
75
+ created TEXT,
76
+ amount_value TEXT,
77
+ amount_currency TEXT,
78
+ alias_iban TEXT,
79
+ alias_name TEXT,
80
+ counterparty_alias_iban TEXT,
81
+ counterparty_alias_name TEXT,
82
+ description TEXT,
83
+ type TEXT,
84
+ sub_type TEXT,
85
+ balance_value TEXT,
86
+ balance_currency TEXT
68
87
  );
69
88
  `);
89
+
90
+ // Index for faster lookups by account
91
+ db.exec(`
92
+ CREATE INDEX IF NOT EXISTS idx_payment_account ON payment (monetary_account_id);
93
+ `);
70
94
  }
71
95
 
72
- function getMaxId(db) {
73
- const row = db.prepare('SELECT MAX(id) AS max_id FROM events').get();
96
+ function getMaxId(db, tableName) {
97
+ const row = db.prepare(`SELECT MAX(id) AS max_id FROM ${tableName}`).get();
74
98
  return row && row.max_id ? Number(row.max_id) : 0;
75
99
  }
76
100
 
@@ -106,32 +130,96 @@ async function bunqGetJson(urlPath, { sessionToken } = {}) {
106
130
  return res.json();
107
131
  }
108
132
 
109
- function extractEvents(payload) {
133
+ // Extract items from response based on collection type
134
+ // bunq wraps items like { Response: [ { Payment: {...} }, ... ] }
135
+ function extractItems(payload, collectionName) {
110
136
  const out = [];
111
137
  const arr = Array.isArray(payload?.Response) ? payload.Response : [];
112
138
  for (const item of arr) {
113
- const ev = item?.Event;
114
- if (ev && ev.id != null) out.push(ev);
139
+ // The key is the singular/capitalized version of the collection name
140
+ // e.g., 'payment' -> 'Payment'
141
+ const key = Object.keys(item || {}).find(k => k !== 'Pagination');
142
+ if (key && item[key] && item[key].id != null) {
143
+ out.push(item[key]);
144
+ }
115
145
  }
116
146
  return out;
117
147
  }
118
148
 
119
- async function fetchEvents(opts = {}) {
149
+ function fetchMonetaryAccounts(userId, sessionToken) {
150
+ // Keep as async wrapper that calls bunqGetJson to match original signature
151
+ return bunqGetJson(`/v1/user/${userId}/monetary-account`, { sessionToken })
152
+ .then(json => {
153
+ const accounts = [];
154
+ const arr = Array.isArray(json?.Response) ? json.Response : [];
155
+ for (const item of arr) {
156
+ const key = Object.keys(item || {}).find(k => k.startsWith('MonetaryAccount'));
157
+ if (key && item[key] && item[key].id != null) {
158
+ accounts.push({
159
+ id: item[key].id,
160
+ type: key,
161
+ currency: item[key].currency,
162
+ balance: item[key].balance,
163
+ });
164
+ }
165
+ }
166
+ return accounts;
167
+ });
168
+ }
169
+
170
+ function normalizePayment(item, accountId) {
171
+ const created = item.created || null;
172
+ const amount_value = item.amount?.value ?? null;
173
+ const amount_currency = item.amount?.currency ?? null;
174
+
175
+ // alias: prefer top-level display_name, fallback to label_user.display_name
176
+ const alias_iban = item.alias?.iban ?? null;
177
+ const alias_name = item.alias?.display_name ?? item.alias?.label_user?.display_name ?? null;
178
+
179
+ const counterparty_alias_iban = item.counterparty_alias?.iban ?? null;
180
+ const counterparty_alias_name = item.counterparty_alias?.display_name ?? item.counterparty_alias?.label_user?.display_name ?? null;
181
+
182
+ const description = item.description ?? null;
183
+ const type = item.type ?? null;
184
+ const sub_type = item.sub_type ?? null;
185
+
186
+ const balance_value = item.balance_after_mutation?.value ?? null;
187
+ const balance_currency = item.balance_after_mutation?.currency ?? null;
188
+
189
+ return {
190
+ id: Number(item.id),
191
+ monetary_account_id: Number(accountId),
192
+ created,
193
+ amount_value,
194
+ amount_currency,
195
+ alias_iban,
196
+ alias_name,
197
+ counterparty_alias_iban,
198
+ counterparty_alias_name,
199
+ description,
200
+ type,
201
+ sub_type,
202
+ balance_value,
203
+ balance_currency,
204
+ };
205
+ }
206
+
207
+ // Fetch items for a specific collection from a monetary account
208
+ async function fetchCollection(opts = {}) {
120
209
  const verbose = !!opts.verbose;
121
210
  const clean = !!opts.clean;
122
211
 
123
- const cfg = readExistingConfig();
124
- const sessionToken = cfg.sessionToken;
125
- const userId = cfg.userId;
212
+ const sessionToken = getSecret('BUNQ_SESSION_TOKEN');
213
+ const userId = getSecret('BUNQ_USER_ID');
126
214
 
127
215
  if (!sessionToken) {
128
- throw new Error(`Missing sessionToken in ${configPath()}. Run: bunk auth`);
216
+ throw new Error(`Missing sessionToken in keychain. Run: bunk auth`);
129
217
  }
130
218
  if (!userId) {
131
- throw new Error(`Missing userId in ${configPath()}. Run: bunk auth`);
219
+ throw new Error(`Missing userId in keychain. Run: bunk auth`);
132
220
  }
133
221
 
134
- const dbPath = resolveDbPath(opts, cfg);
222
+ const dbPath = resolveDbPath(opts);
135
223
 
136
224
  if (clean) {
137
225
  if (verbose) console.log(`--clean: removing ${dbPath} (and -wal/-shm)`);
@@ -142,19 +230,61 @@ async function fetchEvents(opts = {}) {
142
230
  try {
143
231
  ensureSchema(db);
144
232
 
145
- const maxId = getMaxId(db);
146
- const isEmpty = maxId === 0;
233
+ // Get monetary accounts
234
+ if (verbose) console.log('Fetching monetary accounts...');
235
+ const accounts = await fetchMonetaryAccounts(userId, sessionToken);
236
+ console.log(`Found ${accounts.length} monetary account(s): ${accounts.map(a => a.id).join(', ')}`);
147
237
 
148
- // per your comment: if collection is empty behave like --all
149
- const modeAll = isEmpty;
238
+ const count = 200;
239
+ let totalInserted = 0;
240
+ let totalPages = 0;
241
+
242
+ // Prepare insert statements for payment only
243
+ const inserters = {};
244
+ for (const col of COLLECTIONS) {
245
+ if (col.name === 'payment') {
246
+ inserters[col.name] = db.prepare(
247
+ `INSERT OR REPLACE INTO payment (
248
+ id, monetary_account_id, created,
249
+ amount_value, amount_currency,
250
+ alias_iban, alias_name,
251
+ counterparty_alias_iban, counterparty_alias_name,
252
+ description, type, sub_type,
253
+ balance_value, balance_currency
254
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
255
+ );
256
+ } else {
257
+ inserters[col.name] = db.prepare(
258
+ `INSERT OR REPLACE INTO ${col.name} (id, monetary_account_id, content) VALUES (?, ?, ?)`
259
+ );
260
+ }
261
+ }
150
262
 
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) => {
263
+ const insertMany = (tableName, items, accountId) => {
154
264
  db.exec('BEGIN');
155
265
  try {
156
- for (const ev of events) {
157
- ins.run(Number(ev.id), JSON.stringify(ev));
266
+ for (const item of items) {
267
+ if (tableName === 'payment') {
268
+ const p = normalizePayment(item, accountId);
269
+ inserters.payment.run(
270
+ p.id,
271
+ p.monetary_account_id,
272
+ p.created,
273
+ p.amount_value,
274
+ p.amount_currency,
275
+ p.alias_iban,
276
+ p.alias_name,
277
+ p.counterparty_alias_iban,
278
+ p.counterparty_alias_name,
279
+ p.description,
280
+ p.type,
281
+ p.sub_type,
282
+ p.balance_value,
283
+ p.balance_currency
284
+ );
285
+ } else {
286
+ inserters[tableName].run(Number(item.id), Number(accountId), JSON.stringify(item));
287
+ }
158
288
  }
159
289
  db.exec('COMMIT');
160
290
  } catch (e) {
@@ -165,68 +295,81 @@ async function fetchEvents(opts = {}) {
165
295
  }
166
296
  };
167
297
 
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 });
298
+ // Process each monetary account
299
+ for (const account of accounts) {
300
+ console.log(`\nProcessing monetary account ${account.id} (${account.type})...`);
301
+
302
+ // Process each collection for this account (only payment)
303
+ for (const col of COLLECTIONS) {
304
+ const isEmpty = getMaxId(db, col.name) === 0;
305
+ const modeAll = isEmpty;
306
+
307
+ let nextPath;
308
+ if (modeAll) {
309
+ nextPath = `/v1/user/${userId}/monetary-account/${account.id}/${col.endpoint}?count=${count}`;
310
+ } else {
311
+ const maxId = getMaxId(db, col.name);
312
+ nextPath = `/v1/user/${userId}/monetary-account/${account.id}/${col.endpoint}?count=${count}&newer_id=${maxId}`;
313
+ }
197
314
 
198
- const events = extractEvents(json);
199
- if (events.length) {
200
- insertMany(events);
201
- inserted += events.length;
202
- }
315
+ if (verbose) {
316
+ console.log(` [${col.name}] ${modeAll ? 'full backfill' : 'incremental (since id=' + getMaxId(db, col.name) + ')'} `);
317
+ }
203
318
 
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;
319
+ let pages = 0;
320
+ let inserted = 0;
321
+ let lastPath = null;
322
+
323
+ while (nextPath) {
324
+ if (nextPath === lastPath) {
325
+ if (verbose) console.warn(` Pagination did not advance. Stopping.`);
326
+ break;
327
+ }
328
+ lastPath = nextPath;
329
+
330
+ pages++;
331
+ if (verbose) console.log(` GET ${nextPath}`);
332
+ const json = await bunqGetJson(nextPath, { sessionToken });
333
+
334
+ const items = extractItems(json, col.name);
335
+ if (items.length) {
336
+ insertMany(col.name, items, account.id);
337
+ inserted += items.length;
338
+ }
339
+
340
+ const pag = json?.Pagination || {};
341
+ if (modeAll) {
342
+ // Walk back in time until older_url is null
343
+ nextPath = pag.older_url || null;
344
+ } else {
345
+ // Incremental: use future_url for newer items
346
+ nextPath = pag.future_url || null;
347
+ if (!nextPath) break;
348
+ }
349
+
350
+ if (!verbose) {
351
+ process.stdout.write(`\r [${col.name}] pages=${pages} inserted=${inserted} `);
352
+ }
353
+
354
+ if (pages > 100_000) throw new Error(`Too many pages for ${col.name}; aborting`);
214
355
  }
215
- }
216
356
 
217
- if (!verbose) {
218
- process.stdout.write(`\rpages=${pages} inserted=${inserted}`);
357
+ if (!verbose) process.stdout.write('\n');
358
+ if (verbose || inserted > 0) {
359
+ console.log(` [${col.name}] Done: ${inserted} items in ${pages} pages`);
360
+ }
361
+ totalInserted += inserted;
362
+ totalPages += pages;
219
363
  }
220
-
221
- // Safety: prevent infinite loops if API misbehaves
222
- if (pages > 1_000_000) throw new Error('too many pages; aborting');
223
364
  }
224
365
 
225
- if (!verbose) process.stdout.write('\n');
226
- console.log(`Done. pages=${pages}, inserted=${inserted}, maxId(now)=${getMaxId(db)}`);
366
+ console.log(`\nAll done. Total inserted: ${totalInserted} items across ${accounts.length} account(s)`);
227
367
  } finally {
228
368
  db.close();
229
369
  }
230
370
  }
231
371
 
232
- module.exports = { fetchEvents };
372
+ // For backwards compatibility, alias fetchEvents to fetchCollection
373
+ const fetchEvents = fetchCollection;
374
+
375
+ module.exports = { fetchEvents, fetchCollection, COLLECTIONS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iovdin/bunk",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
4
4
  "homepage": "https://github.com/iovdin/bunk#readme",
5
5
  "description": "bunq CLI download and index transactions into sqlite database",
6
6
  "type": "commonjs",
@@ -18,6 +18,7 @@
18
18
  "start": "node bin/bunk.js"
19
19
  },
20
20
  "dependencies": {
21
+ "@napi-rs/keyring": "^1.1.0",
21
22
  "commander": "^14.0.3"
22
23
  },
23
24
  "author": "Ilya Ovdin <iovdin@gmail.com>",
package/lib/index.js DELETED
@@ -1,116 +0,0 @@
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 };