@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 +153 -0
- package/bin/bunk.js +45 -0
- package/lib/auth.js +413 -0
- package/lib/fetch.js +232 -0
- package/lib/index.js +116 -0
- package/package.json +25 -0
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
|
+
}
|