sheetlink 0.1.15 → 0.2.0
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/package.json +1 -1
- package/src/adapters/csv.js +102 -9
- package/src/adapters/postgres.js +168 -44
- package/src/adapters/sqlite.js +162 -45
- package/src/commands/sync.js +31 -7
- package/src/index.js +2 -0
package/package.json
CHANGED
package/src/adapters/csv.js
CHANGED
|
@@ -4,11 +4,52 @@
|
|
|
4
4
|
* Writes a flat snapshot of transactions to a CSV file.
|
|
5
5
|
* Each run OVERWRITES the file — no append, no dedup.
|
|
6
6
|
* Users who want history should use Postgres or SQLite.
|
|
7
|
+
*
|
|
8
|
+
* Full schema matches the Google Sheets extension and Excel add-in (35 transaction columns).
|
|
9
|
+
* Use --slim to write the legacy 14-column subset instead.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import fs from 'fs';
|
|
10
13
|
|
|
11
|
-
const
|
|
14
|
+
const HEADERS_FULL = [
|
|
15
|
+
'transaction_id',
|
|
16
|
+
'account_id',
|
|
17
|
+
'persistent_account_id',
|
|
18
|
+
'account_name',
|
|
19
|
+
'account_mask',
|
|
20
|
+
'date',
|
|
21
|
+
'authorized_date',
|
|
22
|
+
'datetime',
|
|
23
|
+
'authorized_datetime',
|
|
24
|
+
'description_raw',
|
|
25
|
+
'merchant_name',
|
|
26
|
+
'merchant_entity_id',
|
|
27
|
+
'amount',
|
|
28
|
+
'iso_currency_code',
|
|
29
|
+
'unofficial_currency_code',
|
|
30
|
+
'pending',
|
|
31
|
+
'pending_transaction_id',
|
|
32
|
+
'check_number',
|
|
33
|
+
'category_primary',
|
|
34
|
+
'category_detailed',
|
|
35
|
+
'payment_channel',
|
|
36
|
+
'transaction_type',
|
|
37
|
+
'transaction_code',
|
|
38
|
+
'location_address',
|
|
39
|
+
'location_city',
|
|
40
|
+
'location_region',
|
|
41
|
+
'location_postal_code',
|
|
42
|
+
'location_country',
|
|
43
|
+
'location_lat',
|
|
44
|
+
'location_lon',
|
|
45
|
+
'website',
|
|
46
|
+
'logo_url',
|
|
47
|
+
'source_institution',
|
|
48
|
+
'category',
|
|
49
|
+
'synced_at',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const HEADERS_SLIM = [
|
|
12
53
|
'date',
|
|
13
54
|
'name',
|
|
14
55
|
'amount',
|
|
@@ -16,12 +57,12 @@ const HEADERS = [
|
|
|
16
57
|
'account_id',
|
|
17
58
|
'account_name',
|
|
18
59
|
'account_mask',
|
|
19
|
-
'
|
|
60
|
+
'source_institution',
|
|
20
61
|
'pending',
|
|
21
62
|
'payment_channel',
|
|
22
63
|
'merchant_name',
|
|
23
|
-
'
|
|
24
|
-
'
|
|
64
|
+
'category_primary',
|
|
65
|
+
'category_detailed',
|
|
25
66
|
'transaction_id',
|
|
26
67
|
];
|
|
27
68
|
|
|
@@ -34,12 +75,64 @@ function escape(val) {
|
|
|
34
75
|
return s;
|
|
35
76
|
}
|
|
36
77
|
|
|
37
|
-
function
|
|
38
|
-
|
|
78
|
+
function flattenTransaction(tx) {
|
|
79
|
+
const loc = tx.location || {};
|
|
80
|
+
const pfc = tx.personal_finance_category || {};
|
|
81
|
+
const category = Array.isArray(tx.plaid_category) ? tx.plaid_category.join(', ') : (tx.category || null);
|
|
82
|
+
return {
|
|
83
|
+
transaction_id: tx.transaction_id,
|
|
84
|
+
account_id: tx.account_id,
|
|
85
|
+
persistent_account_id: tx.persistent_account_id || null,
|
|
86
|
+
account_name: tx.account_name,
|
|
87
|
+
account_mask: tx.account_mask,
|
|
88
|
+
date: tx.date,
|
|
89
|
+
authorized_date: tx.authorized_date || null,
|
|
90
|
+
datetime: tx.datetime || null,
|
|
91
|
+
authorized_datetime: tx.authorized_datetime || null,
|
|
92
|
+
description_raw: tx.description_raw || tx.name,
|
|
93
|
+
merchant_name: tx.merchant_name || null,
|
|
94
|
+
merchant_entity_id: tx.merchant_entity_id || null,
|
|
95
|
+
amount: tx.amount,
|
|
96
|
+
iso_currency_code: tx.iso_currency_code || null,
|
|
97
|
+
unofficial_currency_code: tx.unofficial_currency_code || null,
|
|
98
|
+
pending: tx.pending,
|
|
99
|
+
pending_transaction_id: tx.pending_transaction_id || null,
|
|
100
|
+
check_number: tx.check_number || null,
|
|
101
|
+
category_primary: pfc.primary || tx.category_primary || null,
|
|
102
|
+
category_detailed: pfc.detailed || tx.category_detailed || null,
|
|
103
|
+
payment_channel: tx.payment_channel || null,
|
|
104
|
+
transaction_type: tx.transaction_type || null,
|
|
105
|
+
transaction_code: tx.transaction_code || null,
|
|
106
|
+
location_address: loc.address || null,
|
|
107
|
+
location_city: loc.city || null,
|
|
108
|
+
location_region: loc.region || null,
|
|
109
|
+
location_postal_code: loc.postal_code || null,
|
|
110
|
+
location_country: loc.country || null,
|
|
111
|
+
location_lat: loc.lat ?? null,
|
|
112
|
+
location_lon: loc.lon ?? null,
|
|
113
|
+
website: tx.website || null,
|
|
114
|
+
logo_url: tx.logo_url || null,
|
|
115
|
+
source_institution: tx.source_institution || tx.institution_name || null,
|
|
116
|
+
category,
|
|
117
|
+
synced_at: new Date().toISOString(),
|
|
118
|
+
};
|
|
39
119
|
}
|
|
40
120
|
|
|
41
|
-
export function writeCsv(transactions, filePath = './sheetlink-transactions.csv') {
|
|
42
|
-
const
|
|
43
|
-
|
|
121
|
+
export function writeCsv(transactions, filePath = './sheetlink-transactions.csv', { slim = false } = {}) {
|
|
122
|
+
const headers = slim ? HEADERS_SLIM : HEADERS_FULL;
|
|
123
|
+
const rows = transactions.map(tx => {
|
|
124
|
+
const flat = slim
|
|
125
|
+
? {
|
|
126
|
+
...tx,
|
|
127
|
+
name: tx.description_raw || tx.name,
|
|
128
|
+
source_institution: tx.source_institution || tx.institution_name || null,
|
|
129
|
+
category: Array.isArray(tx.plaid_category) ? tx.plaid_category.join(', ') : (tx.category || null),
|
|
130
|
+
category_primary: (tx.personal_finance_category?.primary) || tx.category_primary || null,
|
|
131
|
+
category_detailed: (tx.personal_finance_category?.detailed) || tx.category_detailed || null,
|
|
132
|
+
}
|
|
133
|
+
: flattenTransaction(tx);
|
|
134
|
+
return headers.map(h => escape(flat[h])).join(',');
|
|
135
|
+
});
|
|
136
|
+
fs.writeFileSync(filePath, [headers.join(','), ...rows].join('\n') + '\n', 'utf8');
|
|
44
137
|
console.log(`Wrote ${transactions.length} transactions to ${filePath}`);
|
|
45
138
|
}
|
package/src/adapters/postgres.js
CHANGED
|
@@ -3,44 +3,116 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Creates sheetlink_transactions and sheetlink_accounts tables if they don't exist.
|
|
5
5
|
* Upserts on primary key — safe to run repeatedly with no duplicates.
|
|
6
|
+
*
|
|
7
|
+
* Full schema matches the Google Sheets extension and Excel add-in (35 transaction columns).
|
|
8
|
+
* Use --slim to write the legacy 14-column subset instead.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
// Full schema — matches TRANSACTIONS_HEADERS_FULL in extension + Excel
|
|
12
|
+
const CREATE_TRANSACTIONS_FULL = `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS sheetlink_transactions (
|
|
14
|
+
transaction_id TEXT PRIMARY KEY,
|
|
15
|
+
account_id TEXT,
|
|
16
|
+
persistent_account_id TEXT,
|
|
17
|
+
account_name TEXT,
|
|
18
|
+
account_mask TEXT,
|
|
19
|
+
date DATE NOT NULL,
|
|
20
|
+
authorized_date DATE,
|
|
21
|
+
datetime TEXT,
|
|
22
|
+
authorized_datetime TEXT,
|
|
23
|
+
description_raw TEXT,
|
|
24
|
+
merchant_name TEXT,
|
|
25
|
+
merchant_entity_id TEXT,
|
|
26
|
+
amount DECIMAL(10,2),
|
|
27
|
+
iso_currency_code TEXT,
|
|
28
|
+
unofficial_currency_code TEXT,
|
|
29
|
+
pending BOOLEAN,
|
|
30
|
+
pending_transaction_id TEXT,
|
|
31
|
+
check_number TEXT,
|
|
32
|
+
category_primary TEXT,
|
|
33
|
+
category_detailed TEXT,
|
|
34
|
+
payment_channel TEXT,
|
|
35
|
+
transaction_type TEXT,
|
|
36
|
+
transaction_code TEXT,
|
|
37
|
+
location_address TEXT,
|
|
38
|
+
location_city TEXT,
|
|
39
|
+
location_region TEXT,
|
|
40
|
+
location_postal_code TEXT,
|
|
41
|
+
location_country TEXT,
|
|
42
|
+
location_lat DECIMAL(10,7),
|
|
43
|
+
location_lon DECIMAL(10,7),
|
|
44
|
+
website TEXT,
|
|
45
|
+
logo_url TEXT,
|
|
46
|
+
source_institution TEXT,
|
|
47
|
+
category TEXT,
|
|
48
|
+
synced_at TIMESTAMP DEFAULT NOW()
|
|
49
|
+
)`;
|
|
50
|
+
|
|
51
|
+
// Slim schema — legacy 14-column subset for --slim flag
|
|
52
|
+
const CREATE_TRANSACTIONS_SLIM = `
|
|
9
53
|
CREATE TABLE IF NOT EXISTS sheetlink_transactions (
|
|
10
|
-
transaction_id
|
|
11
|
-
date
|
|
12
|
-
name
|
|
13
|
-
amount
|
|
14
|
-
category
|
|
15
|
-
account_id
|
|
16
|
-
account_name
|
|
17
|
-
account_mask
|
|
18
|
-
|
|
19
|
-
pending
|
|
20
|
-
payment_channel
|
|
21
|
-
merchant_name
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
synced_at
|
|
54
|
+
transaction_id TEXT PRIMARY KEY,
|
|
55
|
+
date DATE NOT NULL,
|
|
56
|
+
name TEXT,
|
|
57
|
+
amount DECIMAL(10,2),
|
|
58
|
+
category TEXT,
|
|
59
|
+
account_id TEXT,
|
|
60
|
+
account_name TEXT,
|
|
61
|
+
account_mask TEXT,
|
|
62
|
+
source_institution TEXT,
|
|
63
|
+
pending BOOLEAN,
|
|
64
|
+
payment_channel TEXT,
|
|
65
|
+
merchant_name TEXT,
|
|
66
|
+
category_primary TEXT,
|
|
67
|
+
category_detailed TEXT,
|
|
68
|
+
synced_at TIMESTAMP DEFAULT NOW()
|
|
25
69
|
)`;
|
|
26
70
|
|
|
27
71
|
const CREATE_ACCOUNTS = `
|
|
28
72
|
CREATE TABLE IF NOT EXISTS sheetlink_accounts (
|
|
29
|
-
account_id
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
73
|
+
account_id TEXT PRIMARY KEY,
|
|
74
|
+
persistent_account_id TEXT,
|
|
75
|
+
name TEXT,
|
|
76
|
+
official_name TEXT,
|
|
77
|
+
mask TEXT,
|
|
78
|
+
type TEXT,
|
|
79
|
+
subtype TEXT,
|
|
80
|
+
current_balance DECIMAL(10,2),
|
|
81
|
+
available_balance DECIMAL(10,2),
|
|
82
|
+
iso_currency_code TEXT,
|
|
83
|
+
institution TEXT,
|
|
84
|
+
last_synced_at TIMESTAMP DEFAULT NOW()
|
|
38
85
|
)`;
|
|
39
86
|
|
|
40
|
-
const
|
|
87
|
+
const UPSERT_TRANSACTION_FULL = `
|
|
88
|
+
INSERT INTO sheetlink_transactions
|
|
89
|
+
(transaction_id, account_id, persistent_account_id, account_name, account_mask,
|
|
90
|
+
date, authorized_date, datetime, authorized_datetime,
|
|
91
|
+
description_raw, merchant_name, merchant_entity_id,
|
|
92
|
+
amount, iso_currency_code, unofficial_currency_code,
|
|
93
|
+
pending, pending_transaction_id, check_number,
|
|
94
|
+
category_primary, category_detailed, payment_channel,
|
|
95
|
+
transaction_type, transaction_code,
|
|
96
|
+
location_address, location_city, location_region, location_postal_code, location_country,
|
|
97
|
+
location_lat, location_lon,
|
|
98
|
+
website, logo_url, source_institution, category, synced_at)
|
|
99
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,NOW())
|
|
100
|
+
ON CONFLICT (transaction_id) DO UPDATE SET
|
|
101
|
+
date = EXCLUDED.date,
|
|
102
|
+
authorized_date = EXCLUDED.authorized_date,
|
|
103
|
+
description_raw = EXCLUDED.description_raw,
|
|
104
|
+
amount = EXCLUDED.amount,
|
|
105
|
+
pending = EXCLUDED.pending,
|
|
106
|
+
pending_transaction_id = EXCLUDED.pending_transaction_id,
|
|
107
|
+
category_primary = EXCLUDED.category_primary,
|
|
108
|
+
category_detailed = EXCLUDED.category_detailed,
|
|
109
|
+
category = EXCLUDED.category,
|
|
110
|
+
synced_at = NOW()`;
|
|
111
|
+
|
|
112
|
+
const UPSERT_TRANSACTION_SLIM = `
|
|
41
113
|
INSERT INTO sheetlink_transactions
|
|
42
114
|
(transaction_id, date, name, amount, category, account_id, account_name, account_mask,
|
|
43
|
-
|
|
115
|
+
source_institution, pending, payment_channel, merchant_name, category_primary, category_detailed, synced_at)
|
|
44
116
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
|
|
45
117
|
ON CONFLICT (transaction_id) DO UPDATE SET
|
|
46
118
|
date = EXCLUDED.date,
|
|
@@ -52,16 +124,16 @@ ON CONFLICT (transaction_id) DO UPDATE SET
|
|
|
52
124
|
|
|
53
125
|
const UPSERT_ACCOUNT = `
|
|
54
126
|
INSERT INTO sheetlink_accounts
|
|
55
|
-
(account_id, name, mask, type, subtype,
|
|
56
|
-
|
|
127
|
+
(account_id, persistent_account_id, name, official_name, mask, type, subtype,
|
|
128
|
+
current_balance, available_balance, iso_currency_code, institution, last_synced_at)
|
|
129
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
|
|
57
130
|
ON CONFLICT (account_id) DO UPDATE SET
|
|
58
131
|
name = EXCLUDED.name,
|
|
59
|
-
|
|
60
|
-
|
|
132
|
+
current_balance = EXCLUDED.current_balance,
|
|
133
|
+
available_balance = EXCLUDED.available_balance,
|
|
61
134
|
last_synced_at = NOW()`;
|
|
62
135
|
|
|
63
|
-
export async function writePostgres(transactions, accounts, connectionString) {
|
|
64
|
-
// Dynamic import so users without pg installed don't hit an error on other commands
|
|
136
|
+
export async function writePostgres(transactions, accounts, connectionString, { slim = false } = {}) {
|
|
65
137
|
const { default: pg } = await import('pg').then(m => ({ default: m.default || m }));
|
|
66
138
|
const { Client } = pg;
|
|
67
139
|
|
|
@@ -69,24 +141,76 @@ export async function writePostgres(transactions, accounts, connectionString) {
|
|
|
69
141
|
await client.connect();
|
|
70
142
|
|
|
71
143
|
try {
|
|
72
|
-
await client.query(
|
|
144
|
+
await client.query(slim ? CREATE_TRANSACTIONS_SLIM : CREATE_TRANSACTIONS_FULL);
|
|
73
145
|
await client.query(CREATE_ACCOUNTS);
|
|
74
146
|
|
|
75
147
|
for (const tx of transactions) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
148
|
+
const loc = tx.location || {};
|
|
149
|
+
const pfc = tx.personal_finance_category || {};
|
|
150
|
+
const category = Array.isArray(tx.plaid_category) ? tx.plaid_category.join(', ') : (tx.category || null);
|
|
151
|
+
const source_institution = tx.source_institution || tx.institution_name || null;
|
|
152
|
+
|
|
153
|
+
if (slim) {
|
|
154
|
+
await client.query(UPSERT_TRANSACTION_SLIM, [
|
|
155
|
+
tx.transaction_id, tx.date, tx.description_raw || tx.name, tx.amount,
|
|
156
|
+
category, tx.account_id, tx.account_name, tx.account_mask,
|
|
157
|
+
source_institution,
|
|
158
|
+
tx.pending, tx.payment_channel, tx.merchant_name,
|
|
159
|
+
pfc.primary || tx.category_primary || null,
|
|
160
|
+
pfc.detailed || tx.category_detailed || null,
|
|
161
|
+
]);
|
|
162
|
+
} else {
|
|
163
|
+
await client.query(UPSERT_TRANSACTION_FULL, [
|
|
164
|
+
tx.transaction_id,
|
|
165
|
+
tx.account_id,
|
|
166
|
+
tx.persistent_account_id || null,
|
|
167
|
+
tx.account_name,
|
|
168
|
+
tx.account_mask,
|
|
169
|
+
tx.date,
|
|
170
|
+
tx.authorized_date || null,
|
|
171
|
+
tx.datetime || null,
|
|
172
|
+
tx.authorized_datetime || null,
|
|
173
|
+
tx.description_raw || tx.name,
|
|
174
|
+
tx.merchant_name || null,
|
|
175
|
+
tx.merchant_entity_id || null,
|
|
176
|
+
tx.amount,
|
|
177
|
+
tx.iso_currency_code || null,
|
|
178
|
+
tx.unofficial_currency_code || null,
|
|
179
|
+
tx.pending,
|
|
180
|
+
tx.pending_transaction_id || null,
|
|
181
|
+
tx.check_number || null,
|
|
182
|
+
pfc.primary || tx.category_primary || null,
|
|
183
|
+
pfc.detailed || tx.category_detailed || null,
|
|
184
|
+
tx.payment_channel || null,
|
|
185
|
+
tx.transaction_type || null,
|
|
186
|
+
tx.transaction_code || null,
|
|
187
|
+
loc.address || null,
|
|
188
|
+
loc.city || null,
|
|
189
|
+
loc.region || null,
|
|
190
|
+
loc.postal_code || null,
|
|
191
|
+
loc.country || null,
|
|
192
|
+
loc.lat ?? null,
|
|
193
|
+
loc.lon ?? null,
|
|
194
|
+
tx.website || null,
|
|
195
|
+
tx.logo_url || null,
|
|
196
|
+
source_institution,
|
|
197
|
+
category,
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
83
200
|
}
|
|
84
201
|
|
|
85
202
|
for (const acc of accounts) {
|
|
86
203
|
await client.query(UPSERT_ACCOUNT, [
|
|
87
|
-
acc.account_id,
|
|
88
|
-
acc.
|
|
89
|
-
acc.
|
|
204
|
+
acc.account_id,
|
|
205
|
+
acc.persistent_account_id || null,
|
|
206
|
+
acc.name,
|
|
207
|
+
acc.official_name || null,
|
|
208
|
+
acc.mask,
|
|
209
|
+
acc.type,
|
|
210
|
+
acc.subtype,
|
|
211
|
+
acc.balances?.current ?? acc.current_balance ?? null,
|
|
212
|
+
acc.balances?.available ?? acc.available_balance ?? null,
|
|
213
|
+
acc.iso_currency_code || null,
|
|
90
214
|
acc.institution || acc.institution_name || null,
|
|
91
215
|
]);
|
|
92
216
|
}
|
package/src/adapters/sqlite.js
CHANGED
|
@@ -2,44 +2,109 @@
|
|
|
2
2
|
* sqlite.js - SQLite upsert adapter (MAX tier only)
|
|
3
3
|
*
|
|
4
4
|
* Same schema as Postgres. Upserts on primary key — safe to run repeatedly.
|
|
5
|
+
*
|
|
6
|
+
* Full schema matches the Google Sheets extension and Excel add-in (35 transaction columns).
|
|
7
|
+
* Use --slim to write the legacy 14-column subset instead.
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
|
-
const
|
|
10
|
+
const CREATE_TRANSACTIONS_FULL = `
|
|
8
11
|
CREATE TABLE IF NOT EXISTS sheetlink_transactions (
|
|
9
|
-
transaction_id
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
transaction_id TEXT PRIMARY KEY,
|
|
13
|
+
account_id TEXT,
|
|
14
|
+
persistent_account_id TEXT,
|
|
15
|
+
account_name TEXT,
|
|
16
|
+
account_mask TEXT,
|
|
17
|
+
date TEXT NOT NULL,
|
|
18
|
+
authorized_date TEXT,
|
|
19
|
+
datetime TEXT,
|
|
20
|
+
authorized_datetime TEXT,
|
|
21
|
+
description_raw TEXT,
|
|
22
|
+
merchant_name TEXT,
|
|
23
|
+
merchant_entity_id TEXT,
|
|
24
|
+
amount REAL,
|
|
25
|
+
iso_currency_code TEXT,
|
|
26
|
+
unofficial_currency_code TEXT,
|
|
27
|
+
pending INTEGER,
|
|
28
|
+
pending_transaction_id TEXT,
|
|
29
|
+
check_number TEXT,
|
|
30
|
+
category_primary TEXT,
|
|
31
|
+
category_detailed TEXT,
|
|
32
|
+
payment_channel TEXT,
|
|
33
|
+
transaction_type TEXT,
|
|
34
|
+
transaction_code TEXT,
|
|
35
|
+
location_address TEXT,
|
|
36
|
+
location_city TEXT,
|
|
37
|
+
location_region TEXT,
|
|
38
|
+
location_postal_code TEXT,
|
|
39
|
+
location_country TEXT,
|
|
40
|
+
location_lat REAL,
|
|
41
|
+
location_lon REAL,
|
|
42
|
+
website TEXT,
|
|
43
|
+
logo_url TEXT,
|
|
44
|
+
source_institution TEXT,
|
|
45
|
+
category TEXT,
|
|
46
|
+
synced_at TEXT DEFAULT (datetime('now'))
|
|
47
|
+
)`;
|
|
48
|
+
|
|
49
|
+
const CREATE_TRANSACTIONS_SLIM = `
|
|
50
|
+
CREATE TABLE IF NOT EXISTS sheetlink_transactions (
|
|
51
|
+
transaction_id TEXT PRIMARY KEY,
|
|
52
|
+
date TEXT NOT NULL,
|
|
53
|
+
name TEXT,
|
|
54
|
+
amount REAL,
|
|
55
|
+
category TEXT,
|
|
56
|
+
account_id TEXT,
|
|
57
|
+
account_name TEXT,
|
|
58
|
+
account_mask TEXT,
|
|
59
|
+
source_institution TEXT,
|
|
60
|
+
pending INTEGER,
|
|
61
|
+
payment_channel TEXT,
|
|
62
|
+
merchant_name TEXT,
|
|
63
|
+
category_primary TEXT,
|
|
64
|
+
category_detailed TEXT,
|
|
65
|
+
synced_at TEXT DEFAULT (datetime('now'))
|
|
24
66
|
)`;
|
|
25
67
|
|
|
26
68
|
const CREATE_ACCOUNTS = `
|
|
27
69
|
CREATE TABLE IF NOT EXISTS sheetlink_accounts (
|
|
28
|
-
account_id
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
70
|
+
account_id TEXT PRIMARY KEY,
|
|
71
|
+
persistent_account_id TEXT,
|
|
72
|
+
name TEXT,
|
|
73
|
+
official_name TEXT,
|
|
74
|
+
mask TEXT,
|
|
75
|
+
type TEXT,
|
|
76
|
+
subtype TEXT,
|
|
77
|
+
current_balance REAL,
|
|
78
|
+
available_balance REAL,
|
|
79
|
+
iso_currency_code TEXT,
|
|
80
|
+
institution TEXT,
|
|
81
|
+
last_synced_at TEXT DEFAULT (datetime('now'))
|
|
37
82
|
)`;
|
|
38
83
|
|
|
39
|
-
const
|
|
84
|
+
const UPSERT_TRANSACTION_FULL = `
|
|
85
|
+
INSERT INTO sheetlink_transactions
|
|
86
|
+
(transaction_id, account_id, persistent_account_id, account_name, account_mask,
|
|
87
|
+
date, authorized_date, datetime, authorized_datetime,
|
|
88
|
+
description_raw, merchant_name, merchant_entity_id,
|
|
89
|
+
amount, iso_currency_code, unofficial_currency_code,
|
|
90
|
+
pending, pending_transaction_id, check_number,
|
|
91
|
+
category_primary, category_detailed, payment_channel,
|
|
92
|
+
transaction_type, transaction_code,
|
|
93
|
+
location_address, location_city, location_region, location_postal_code, location_country,
|
|
94
|
+
location_lat, location_lon,
|
|
95
|
+
website, logo_url, source_institution, category, synced_at)
|
|
96
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
97
|
+
ON CONFLICT(transaction_id) DO UPDATE SET
|
|
98
|
+
date=excluded.date, authorized_date=excluded.authorized_date,
|
|
99
|
+
description_raw=excluded.description_raw, amount=excluded.amount, pending=excluded.pending,
|
|
100
|
+
pending_transaction_id=excluded.pending_transaction_id,
|
|
101
|
+
category_primary=excluded.category_primary, category_detailed=excluded.category_detailed,
|
|
102
|
+
category=excluded.category, synced_at=datetime('now')`;
|
|
103
|
+
|
|
104
|
+
const UPSERT_TRANSACTION_SLIM = `
|
|
40
105
|
INSERT INTO sheetlink_transactions
|
|
41
106
|
(transaction_id, date, name, amount, category, account_id, account_name, account_mask,
|
|
42
|
-
|
|
107
|
+
source_institution, pending, payment_channel, merchant_name, category_primary, category_detailed, synced_at)
|
|
43
108
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
44
109
|
ON CONFLICT(transaction_id) DO UPDATE SET
|
|
45
110
|
date=excluded.date, name=excluded.name, amount=excluded.amount,
|
|
@@ -47,41 +112,93 @@ ON CONFLICT(transaction_id) DO UPDATE SET
|
|
|
47
112
|
|
|
48
113
|
const UPSERT_ACCOUNT = `
|
|
49
114
|
INSERT INTO sheetlink_accounts
|
|
50
|
-
(account_id, name, mask, type, subtype,
|
|
51
|
-
|
|
115
|
+
(account_id, persistent_account_id, name, official_name, mask, type, subtype,
|
|
116
|
+
current_balance, available_balance, iso_currency_code, institution, last_synced_at)
|
|
117
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
52
118
|
ON CONFLICT(account_id) DO UPDATE SET
|
|
53
|
-
name=excluded.name,
|
|
54
|
-
|
|
119
|
+
name=excluded.name, current_balance=excluded.current_balance,
|
|
120
|
+
available_balance=excluded.available_balance, last_synced_at=datetime('now')`;
|
|
55
121
|
|
|
56
|
-
export async function writeSQLite(transactions, accounts, dbPath) {
|
|
57
|
-
// Dynamic import so users without better-sqlite3 don't hit an error on other commands
|
|
122
|
+
export async function writeSQLite(transactions, accounts, dbPath, { slim = false } = {}) {
|
|
58
123
|
const Database = (await import('better-sqlite3')).default;
|
|
59
124
|
|
|
60
125
|
const db = new Database(dbPath);
|
|
61
|
-
db.exec(
|
|
126
|
+
db.exec(slim ? CREATE_TRANSACTIONS_SLIM : CREATE_TRANSACTIONS_FULL);
|
|
62
127
|
db.exec(CREATE_ACCOUNTS);
|
|
63
128
|
|
|
64
|
-
const insertTx = db.prepare(
|
|
129
|
+
const insertTx = db.prepare(slim ? UPSERT_TRANSACTION_SLIM : UPSERT_TRANSACTION_FULL);
|
|
65
130
|
const insertAcc = db.prepare(UPSERT_ACCOUNT);
|
|
66
131
|
|
|
67
132
|
const txBatch = db.transaction((txns) => {
|
|
68
133
|
for (const tx of txns) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
134
|
+
const loc = tx.location || {};
|
|
135
|
+
const pfc = tx.personal_finance_category || {};
|
|
136
|
+
const category = Array.isArray(tx.plaid_category) ? tx.plaid_category.join(', ') : (tx.category || null);
|
|
137
|
+
const source_institution = tx.source_institution || tx.institution_name || null;
|
|
138
|
+
|
|
139
|
+
if (slim) {
|
|
140
|
+
insertTx.run(
|
|
141
|
+
tx.transaction_id, tx.date, tx.description_raw || tx.name, tx.amount,
|
|
142
|
+
category, tx.account_id, tx.account_name, tx.account_mask,
|
|
143
|
+
source_institution,
|
|
144
|
+
tx.pending ? 1 : 0, tx.payment_channel, tx.merchant_name,
|
|
145
|
+
pfc.primary || tx.category_primary || null,
|
|
146
|
+
pfc.detailed || tx.category_detailed || null,
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
insertTx.run(
|
|
150
|
+
tx.transaction_id,
|
|
151
|
+
tx.account_id,
|
|
152
|
+
tx.persistent_account_id || null,
|
|
153
|
+
tx.account_name,
|
|
154
|
+
tx.account_mask,
|
|
155
|
+
tx.date,
|
|
156
|
+
tx.authorized_date || null,
|
|
157
|
+
tx.datetime || null,
|
|
158
|
+
tx.authorized_datetime || null,
|
|
159
|
+
tx.description_raw || tx.name,
|
|
160
|
+
tx.merchant_name || null,
|
|
161
|
+
tx.merchant_entity_id || null,
|
|
162
|
+
tx.amount,
|
|
163
|
+
tx.iso_currency_code || null,
|
|
164
|
+
tx.unofficial_currency_code || null,
|
|
165
|
+
tx.pending ? 1 : 0,
|
|
166
|
+
tx.pending_transaction_id || null,
|
|
167
|
+
tx.check_number || null,
|
|
168
|
+
pfc.primary || tx.category_primary || null,
|
|
169
|
+
pfc.detailed || tx.category_detailed || null,
|
|
170
|
+
tx.payment_channel || null,
|
|
171
|
+
tx.transaction_type || null,
|
|
172
|
+
tx.transaction_code || null,
|
|
173
|
+
loc.address || null,
|
|
174
|
+
loc.city || null,
|
|
175
|
+
loc.region || null,
|
|
176
|
+
loc.postal_code || null,
|
|
177
|
+
loc.country || null,
|
|
178
|
+
loc.lat ?? null,
|
|
179
|
+
loc.lon ?? null,
|
|
180
|
+
tx.website || null,
|
|
181
|
+
tx.logo_url || null,
|
|
182
|
+
source_institution,
|
|
183
|
+
category,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
76
186
|
}
|
|
77
187
|
});
|
|
78
188
|
|
|
79
189
|
const accBatch = db.transaction((accs) => {
|
|
80
190
|
for (const acc of accs) {
|
|
81
191
|
insertAcc.run(
|
|
82
|
-
acc.account_id,
|
|
83
|
-
acc.
|
|
84
|
-
acc.
|
|
192
|
+
acc.account_id,
|
|
193
|
+
acc.persistent_account_id || null,
|
|
194
|
+
acc.name,
|
|
195
|
+
acc.official_name || null,
|
|
196
|
+
acc.mask,
|
|
197
|
+
acc.type,
|
|
198
|
+
acc.subtype,
|
|
199
|
+
acc.balances?.current ?? acc.current_balance ?? null,
|
|
200
|
+
acc.balances?.available ?? acc.available_balance ?? null,
|
|
201
|
+
acc.iso_currency_code || null,
|
|
85
202
|
acc.institution || acc.institution_name || null,
|
|
86
203
|
);
|
|
87
204
|
}
|
package/src/commands/sync.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* csv [--file path] - Snapshot CSV, overwrites each run (PRO+)
|
|
9
9
|
* postgres://... - Upsert to Postgres (MAX only)
|
|
10
10
|
* sqlite:///path/to/db - Upsert to SQLite (MAX only)
|
|
11
|
+
*
|
|
12
|
+
* Flags:
|
|
13
|
+
* --slim - Write legacy 14-column schema instead of full 34-column schema
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import { listItems, syncItem } from '../api.js';
|
|
@@ -17,9 +20,30 @@ import { writeCsv } from '../adapters/csv.js';
|
|
|
17
20
|
import { writePostgres } from '../adapters/postgres.js';
|
|
18
21
|
import { writeSQLite } from '../adapters/sqlite.js';
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Enrich transactions with account_name and account_mask from the accounts array.
|
|
25
|
+
* The API returns accounts and transactions separately; transactions only carry account_id.
|
|
26
|
+
*/
|
|
27
|
+
function enrichTransactions(transactions, accounts) {
|
|
28
|
+
const accountMap = {};
|
|
29
|
+
for (const acc of accounts) {
|
|
30
|
+
accountMap[acc.account_id] = acc;
|
|
31
|
+
}
|
|
32
|
+
return transactions.map(tx => {
|
|
33
|
+
const acc = accountMap[tx.account_id];
|
|
34
|
+
return {
|
|
35
|
+
...tx,
|
|
36
|
+
account_name: acc?.name ?? tx.account_name ?? null,
|
|
37
|
+
account_mask: acc?.mask ?? tx.account_mask ?? null,
|
|
38
|
+
persistent_account_id: acc?.persistent_account_id ?? tx.persistent_account_id ?? null,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
export async function cmdSync(options) {
|
|
21
44
|
const output = options.output || getDefaultOutput();
|
|
22
45
|
const itemId = options.item || null;
|
|
46
|
+
const slim = !!options.slim;
|
|
23
47
|
|
|
24
48
|
// Collect items to sync
|
|
25
49
|
let itemIds;
|
|
@@ -52,11 +76,12 @@ export async function cmdSync(options) {
|
|
|
52
76
|
}, 80);
|
|
53
77
|
try {
|
|
54
78
|
const result = await syncItem(id);
|
|
55
|
-
|
|
79
|
+
const enriched = enrichTransactions(result.transactions || [], result.accounts || []);
|
|
80
|
+
allTransactions.push(...enriched);
|
|
56
81
|
allAccounts.push(...(result.accounts || []));
|
|
57
|
-
results.push({ item_id: id, ...result });
|
|
82
|
+
results.push({ item_id: id, ...result, transactions: enriched });
|
|
58
83
|
clearInterval(spinner);
|
|
59
|
-
process.stderr.write(`\r✓ Synced ${id} — ${
|
|
84
|
+
process.stderr.write(`\r✓ Synced ${id} — ${enriched.length} transactions\n`);
|
|
60
85
|
} catch (e) {
|
|
61
86
|
clearInterval(spinner);
|
|
62
87
|
if (e.code === 'ITEM_LOGIN_REQUIRED') {
|
|
@@ -82,19 +107,18 @@ export async function cmdSync(options) {
|
|
|
82
107
|
}
|
|
83
108
|
|
|
84
109
|
if (output === 'csv') {
|
|
85
|
-
writeCsv(allTransactions, options.file);
|
|
110
|
+
writeCsv(allTransactions, options.file, { slim });
|
|
86
111
|
return;
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
if (output.startsWith('postgres://') || output.startsWith('postgresql://')) {
|
|
90
|
-
await writePostgres(allTransactions, allAccounts, output);
|
|
115
|
+
await writePostgres(allTransactions, allAccounts, output, { slim });
|
|
91
116
|
return;
|
|
92
117
|
}
|
|
93
118
|
|
|
94
119
|
if (output.startsWith('sqlite://')) {
|
|
95
|
-
// sqlite:///absolute/path → /absolute/path, sqlite://relative/path → relative/path
|
|
96
120
|
const dbPath = output.replace(/^sqlite:\/\//, '') || './sheetlink.db';
|
|
97
|
-
writeSQLite(allTransactions, allAccounts, dbPath);
|
|
121
|
+
writeSQLite(allTransactions, allAccounts, dbPath, { slim });
|
|
98
122
|
return;
|
|
99
123
|
}
|
|
100
124
|
|
package/src/index.js
CHANGED
|
@@ -42,6 +42,7 @@ program
|
|
|
42
42
|
.option('--output <dest>', 'Output destination: json (default), csv, postgres://..., sqlite:///path')
|
|
43
43
|
.option('--file <path>', 'File path for CSV output (default: ./sheetlink-transactions.csv)')
|
|
44
44
|
.option('--item <item_id>', 'Sync a specific item only (default: all connected banks)')
|
|
45
|
+
.option('--slim', 'Write 14-column schema instead of full 34-column schema')
|
|
45
46
|
.addHelpText('after', `
|
|
46
47
|
Examples:
|
|
47
48
|
sheetlink sync JSON to stdout (pipeable)
|
|
@@ -50,6 +51,7 @@ Examples:
|
|
|
50
51
|
sheetlink sync --output csv --file ~/finances.csv CSV to custom path
|
|
51
52
|
sheetlink sync --output postgres://localhost/mydb Upsert to Postgres (MAX only)
|
|
52
53
|
sheetlink sync --output sqlite:///~/finance.db Upsert to SQLite (MAX only)
|
|
54
|
+
sheetlink sync --output postgres://localhost/mydb --slim Legacy 14-column schema
|
|
53
55
|
sheetlink sync --item VBX93wmRY4Iy... Sync one bank only
|
|
54
56
|
`)
|
|
55
57
|
.action(cmdSync);
|