@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 +53 -72
- package/bin/bunk.js +2 -11
- package/lib/auth.js +34 -121
- package/lib/fetch.js +221 -78
- package/package.json +2 -1
- package/lib/index.js +0 -116
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
51
|
+
6. Create a session and save `sessionToken` + `userId` to keychain
|
|
54
52
|
|
|
55
|
-
All credentials are persisted to
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
Payments are stored in the `payment` table with a normalized schema:
|
|
77
74
|
|
|
78
75
|
```sql
|
|
79
|
-
CREATE TABLE
|
|
80
|
-
id
|
|
81
|
-
|
|
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
|
|
119
|
-
SELECT
|
|
120
|
-
FROM
|
|
121
|
-
ORDER BY
|
|
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 (
|
|
125
|
-
SELECT counterparty_name, ROUND(SUM(amount_value), 2) AS total
|
|
126
|
-
FROM
|
|
127
|
-
WHERE amount_value < 0
|
|
128
|
-
GROUP BY
|
|
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
|
-
--
|
|
133
|
-
SELECT
|
|
134
|
-
FROM
|
|
135
|
-
WHERE
|
|
136
|
-
|
|
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
|
|
152
|
-
*/15 * * * * bunk fetch
|
|
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
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
188
|
-
let
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
|
322
|
+
console.log(`\nSaved tokens + session to keychain`);
|
|
323
|
+
process.exit(0)
|
|
411
324
|
}
|
|
412
325
|
|
|
413
|
-
module.exports = { auth,
|
|
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.
|
|
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
|
|
72
|
+
CREATE TABLE IF NOT EXISTS payment (
|
|
66
73
|
id INTEGER PRIMARY KEY,
|
|
67
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
const
|
|
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
|
|
216
|
+
throw new Error(`Missing sessionToken in keychain. Run: bunk auth`);
|
|
129
217
|
}
|
|
130
218
|
if (!userId) {
|
|
131
|
-
throw new Error(`Missing userId in
|
|
219
|
+
throw new Error(`Missing userId in keychain. Run: bunk auth`);
|
|
132
220
|
}
|
|
133
221
|
|
|
134
|
-
const dbPath = resolveDbPath(opts
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 };
|