orla-il-banks 0.1.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/LICENSE +21 -0
- package/README.md +204 -0
- package/dist/args.js +44 -0
- package/dist/browser.js +10 -0
- package/dist/cli.js +360 -0
- package/dist/companies.js +64 -0
- package/dist/config.js +174 -0
- package/dist/map.js +229 -0
- package/dist/profile.js +50 -0
- package/dist/push.js +130 -0
- package/dist/scrape.js +99 -0
- package/dist/trust.js +75 -0
- package/npm-shrinkwrap.json +1197 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Digital Flow Pte. Ltd.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# orla-il-banks
|
|
2
|
+
|
|
3
|
+
Israeli bank and card transactions into your [Orla](https://orla.finance) books.
|
|
4
|
+
It runs on your machine, or in a private GitHub repository of your own. Your
|
|
5
|
+
bank passwords stay there: Orla only receives the rows.
|
|
6
|
+
|
|
7
|
+
It is a thin layer over [israeli-bank-scrapers](https://github.com/eshaham/israeli-bank-scrapers),
|
|
8
|
+
which logs into the bank's website the way you would, with a real browser. What
|
|
9
|
+
this package adds is the part that decides what the rows mean in your books,
|
|
10
|
+
and the delivery to Orla.
|
|
11
|
+
|
|
12
|
+
> **Status: early.** The mapping is covered by tests against the library's data
|
|
13
|
+
> shapes. The maintainers have not yet run it live against every bank below. If
|
|
14
|
+
> yours misbehaves, open an issue with what the run printed (it prints no
|
|
15
|
+
> passwords and no account numbers).
|
|
16
|
+
|
|
17
|
+
## Why it works this way
|
|
18
|
+
|
|
19
|
+
Orla does not connect Israeli banks itself, and will not ask for your bank
|
|
20
|
+
password: a password to online banking can do everything you can do there,
|
|
21
|
+
including sending money, and no service should hold that for thousands of
|
|
22
|
+
people. This runner keeps the password with you and sends Orla only what a
|
|
23
|
+
statement would show.
|
|
24
|
+
|
|
25
|
+
## Supported
|
|
26
|
+
|
|
27
|
+
| Id | Institution | Kind in Orla |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `hapoalim` | Bank Hapoalim | bank, **from your own computer**, after `trust` once (see below) |
|
|
30
|
+
| `leumi` | Bank Leumi | bank |
|
|
31
|
+
| `mizrahi` | Mizrahi Tefahot | bank |
|
|
32
|
+
| `discount` | Discount Bank | bank |
|
|
33
|
+
| `mercantile` | Mercantile Bank | bank |
|
|
34
|
+
| `otsarHahayal` | Bank Otsar Hahayal | bank |
|
|
35
|
+
| `union` | Union Bank | bank |
|
|
36
|
+
| `beinleumi` | First International (Beinleumi) | bank |
|
|
37
|
+
| `massad` | Bank Massad | bank |
|
|
38
|
+
| `yahav` | Bank Yahav | bank |
|
|
39
|
+
| `pagi` | Bank Pagi | bank |
|
|
40
|
+
| `oneZero` | One Zero | bank, **from a terminal only**: it asks for a code at every login |
|
|
41
|
+
| `isracard` | Isracard | card |
|
|
42
|
+
| `amex` | American Express Israel | card |
|
|
43
|
+
| `max` | Max | card |
|
|
44
|
+
| `visaCal` | Visa Cal | card |
|
|
45
|
+
| `beyahadBishvilha` | Beyahad Bishvilha | card (prepaid) |
|
|
46
|
+
|
|
47
|
+
`orla-il-banks companies` prints the fields each one needs.
|
|
48
|
+
|
|
49
|
+
### Bank Hapoalim and a new computer
|
|
50
|
+
|
|
51
|
+
Since March 2026 Bank Hapoalim sends an SMS code when a login comes from a
|
|
52
|
+
device it has not seen, and the scraping library has no way through that page
|
|
53
|
+
(upstream [#1077](https://github.com/eshaham/israeli-bank-scrapers/issues/1077)).
|
|
54
|
+
A browser started fresh is a new device every time, so the runner keeps one
|
|
55
|
+
browser profile per Hapoalim login on your computer, and you introduce it to
|
|
56
|
+
the bank once:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx orla-il-banks@0.1.0 trust hapoalim --config ~/.orla-il-banks.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A browser window opens on the bank's own login page. Log in there, with the
|
|
63
|
+
code the bank sends; the window closes by itself when the bank shows your
|
|
64
|
+
accounts. Your password and the code go into the bank's page, not into this
|
|
65
|
+
program. From then on `run` logs in from that profile.
|
|
66
|
+
|
|
67
|
+
- The profile lives in `~/.orla-il-banks/profiles` (move it with
|
|
68
|
+
`--profile-dir` or `ORLA_IL_PROFILE_DIR`), readable by you only. It holds a
|
|
69
|
+
bank session: delete the folder to make this computer a stranger again.
|
|
70
|
+
- Not in GitHub Actions and not in Docker: a runner is a new device on every
|
|
71
|
+
run, and keeping the profile there would put a bank session in a cache. The
|
|
72
|
+
runner skips Hapoalim there and says so.
|
|
73
|
+
- Whether the bank keeps recognising the profile, and for how long, is the
|
|
74
|
+
bank's call. If a run fails with a hint to trust again, do.
|
|
75
|
+
|
|
76
|
+
Not supported: **Behatsdaa**. It is the order history of a benefits shop, and
|
|
77
|
+
each order there was paid with a card; with that card connected too, every
|
|
78
|
+
order would count twice.
|
|
79
|
+
|
|
80
|
+
## Set up
|
|
81
|
+
|
|
82
|
+
1. **In Orla**, open Integrations, then **Israeli banks**, and issue a key. It
|
|
83
|
+
is shown once. It can only add transactions to this one source, and it
|
|
84
|
+
expires (you choose when, up to a year).
|
|
85
|
+
2. **Check the browser** on the machine that will run this:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx orla-il-banks@0.1.0 check-browser
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
It starts the browser on an empty page and closes it. It touches no bank.
|
|
92
|
+
|
|
93
|
+
3. Pick one of the three ways to run it below.
|
|
94
|
+
|
|
95
|
+
### On your computer
|
|
96
|
+
|
|
97
|
+
Write a config file and make it readable by you only; the runner refuses a file
|
|
98
|
+
anyone else can read.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cp examples/orla-il-banks.example.json ~/.orla-il-banks.json
|
|
102
|
+
chmod 600 ~/.orla-il-banks.json
|
|
103
|
+
# edit it: your Orla key, then one entry per bank or card
|
|
104
|
+
npx orla-il-banks@0.1.0 run --config ~/.orla-il-banks.json --dry-run
|
|
105
|
+
npx orla-il-banks@0.1.0 run --config ~/.orla-il-banks.json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`--dry-run` logs into the banks and prints what it would send, without
|
|
109
|
+
sending. Run it daily with cron or launchd. The first time, `--days 365` brings
|
|
110
|
+
a year of history.
|
|
111
|
+
|
|
112
|
+
### In a private GitHub repository
|
|
113
|
+
|
|
114
|
+
The machine does not have to be on. Create a **private** repository, copy
|
|
115
|
+
[`templates/github-actions/orla-il-banks.yml`](templates/github-actions/orla-il-banks.yml)
|
|
116
|
+
to `.github/workflows/` in it, and add two secrets:
|
|
117
|
+
|
|
118
|
+
- `ORLA_TOKEN`: the key from Orla.
|
|
119
|
+
- `ORLA_IL_ACCOUNTS`: your logins, as one JSON list:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
[{"company": "hapoalim", "userCode": "AB12345", "password": "..."},
|
|
123
|
+
{"company": "max", "username": "...", "password": "..."}]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Bank Hapoalim does not run here (see above): the runner skips it with the
|
|
127
|
+
reason. The runner checks that the repository is private and stops if it is not:
|
|
128
|
+
public repositories have public run logs. It also masks every password in the
|
|
129
|
+
log one by one, since GitHub masks the whole secret but not the values inside
|
|
130
|
+
it.
|
|
131
|
+
|
|
132
|
+
Your passwords then live in GitHub's secret store and the logins come from
|
|
133
|
+
GitHub's servers, outside Israel. We have not measured whether any bank treats
|
|
134
|
+
that differently; if yours starts asking for extra verification, run it from
|
|
135
|
+
your own computer instead.
|
|
136
|
+
|
|
137
|
+
### In Docker
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
docker build -t orla-il-banks .
|
|
141
|
+
docker run --rm orla-il-banks check-browser
|
|
142
|
+
docker run --rm --env-file ~/.orla-il-banks.env orla-il-banks run
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
with `ORLA_TOKEN=...` and `ORLA_IL_ACCOUNTS=[...]` in the env file (`chmod 600`
|
|
146
|
+
it). The image runs Debian's Chromium as a non-root user, with the browser's
|
|
147
|
+
sandbox off: containers usually lack what the sandbox needs.
|
|
148
|
+
|
|
149
|
+
## What lands in your books
|
|
150
|
+
|
|
151
|
+
- **Completed rows only.** A pending row has no stable id and comes back as a
|
|
152
|
+
completed one later; sending both would book it twice.
|
|
153
|
+
- **A purchase on the day it was made, an installment on the day it was
|
|
154
|
+
charged**, for the amount charged that month. The note says
|
|
155
|
+
`Installment 3 of 12, purchase total 1200.00 ILS`.
|
|
156
|
+
- **The amount charged**, in the currency charged. A purchase abroad keeps its
|
|
157
|
+
original amount in the note.
|
|
158
|
+
- **Israeli days.** A purchase at 00:30 in Tel Aviv books on that day, wherever
|
|
159
|
+
the runner runs.
|
|
160
|
+
- **One money-in row per billing cycle on each credit card.** Your bank pays
|
|
161
|
+
the card's month in one line, and the card lists the same purchases one by
|
|
162
|
+
one. Without a counterpart both would count as spending. So on each billing
|
|
163
|
+
day the card account gets `Billing cycle payment`, equal to what the card
|
|
164
|
+
charged. Orla then suggests linking it with the bank's line for that day as
|
|
165
|
+
a transfer: one click per card per month. If the two amounts differ (a fee,
|
|
166
|
+
a card billed in two currencies), there is no suggestion and you link them
|
|
167
|
+
by hand. A cycle row is sent only once its billing day has come.
|
|
168
|
+
- **Account numbers do not travel.** Orla gets a hash to recognise the account
|
|
169
|
+
and a name like `Bank Hapoalim ••8901`.
|
|
170
|
+
- **One delivery per login.** Orla links a move between two accounts of the same
|
|
171
|
+
login as a transfer by itself, the way it does for a bank it connects. Across
|
|
172
|
+
banks and cards it only suggests: a card refund and an unrelated bank payment
|
|
173
|
+
of the same amount on the same day must never be glued into a transfer. It
|
|
174
|
+
also means Orla's Connections page lists each bank's run on its own, and you
|
|
175
|
+
can undo one without the others.
|
|
176
|
+
|
|
177
|
+
Every row has its own id, so running twice, or re-running after a failure,
|
|
178
|
+
files nothing twice. For the same reason **undoing a delivery in Orla is final
|
|
179
|
+
for its rows**: later runs recognise them and do not file them again.
|
|
180
|
+
|
|
181
|
+
## When something fails
|
|
182
|
+
|
|
183
|
+
| It says | Do |
|
|
184
|
+
|---|---|
|
|
185
|
+
| `the browser did not start` | Run `check-browser`. On Ubuntu 24.04, see the `sysctl` line in the template. |
|
|
186
|
+
| `Bank Hapoalim: ... this computer is new to it` | Run `orla-il-banks trust hapoalim` once and log in on the bank's page. |
|
|
187
|
+
| `Orla refused the key` | The key expired or was revoked. Issue a new one on the Israeli banks card. |
|
|
188
|
+
| `<bank>: failed. invalidPassword` | Log in on the bank's site by hand once; banks lock after a few failures. |
|
|
189
|
+
| `<bank>: failed. changePassword` | The bank wants a new password. Change it on the site, then in your config. |
|
|
190
|
+
| `N refused` | Orla named the field for each refused row. Open an issue with the message. |
|
|
191
|
+
|
|
192
|
+
Exit status: `0` everything went through, `1` a bank or the delivery failed,
|
|
193
|
+
`2` the command line or the config is wrong.
|
|
194
|
+
|
|
195
|
+
## Updating
|
|
196
|
+
|
|
197
|
+
Banks change their sites, and israeli-bank-scrapers follows. Each release of
|
|
198
|
+
this package pins one version of it, and one exact set of everything beneath
|
|
199
|
+
it. Raise the version in your workflow on purpose, after reading the release
|
|
200
|
+
notes, not by pointing it at `latest`.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT. See [SECURITY.md](SECURITY.md) to report a vulnerability.
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grammar, on its own so a test can read it back. Two commands and a
|
|
3
|
+
* handful of flags; what breaks here is the quiet case, a flag that takes a
|
|
4
|
+
* value swallowing the next flag as that value.
|
|
5
|
+
*/
|
|
6
|
+
export function parse(argv) {
|
|
7
|
+
const words = [];
|
|
8
|
+
const flags = {};
|
|
9
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
10
|
+
const arg = argv[i];
|
|
11
|
+
if (!arg.startsWith("--")) {
|
|
12
|
+
words.push(arg);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const name = arg.slice(2);
|
|
16
|
+
const next = argv[i + 1];
|
|
17
|
+
if (next === undefined || next.startsWith("--")) {
|
|
18
|
+
flags[name] = true;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
flags[name] = next;
|
|
22
|
+
i += 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { words, flags };
|
|
26
|
+
}
|
|
27
|
+
export function optional(flags, name) {
|
|
28
|
+
const value = flags[name];
|
|
29
|
+
return typeof value === "string" ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
//: Every flag `run` understands. Anything else is a typo, and a typo in
|
|
32
|
+
//: `--dry-run` would otherwise send the rows the person meant to preview.
|
|
33
|
+
export const RUN_FLAGS = new Set([
|
|
34
|
+
"config",
|
|
35
|
+
"days",
|
|
36
|
+
"dry-run",
|
|
37
|
+
"only",
|
|
38
|
+
"from-json",
|
|
39
|
+
"save-json",
|
|
40
|
+
"show-browser",
|
|
41
|
+
"profile-dir",
|
|
42
|
+
]);
|
|
43
|
+
//: What `trust` understands. It reads the config only to name the profile.
|
|
44
|
+
export const TRUST_FLAGS = new Set(["config", "profile-dir"]);
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser flags shared by the scrape and the trust flow, on their own so that
|
|
3
|
+
* neither the command line nor the trust flow loads the scraping library to get
|
|
4
|
+
* them.
|
|
5
|
+
*/
|
|
6
|
+
export function browserArgs(env) {
|
|
7
|
+
// Chromium's sandbox needs kernel features a container or a hardened CI
|
|
8
|
+
// runner often lacks. Turned off only when asked, and the Docker image asks.
|
|
9
|
+
return env["ORLA_IL_NO_SANDBOX"] === "1" ? ["--no-sandbox", "--disable-setuid-sandbox"] : [];
|
|
10
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* orla-il-banks: read Israeli bank and card accounts on this machine and file
|
|
4
|
+
* the rows into Orla through the push door.
|
|
5
|
+
*
|
|
6
|
+
* The order of a run is the order of trust: everything that can be checked
|
|
7
|
+
* without a bank is checked first (config, file permissions, the repository
|
|
8
|
+
* being private), then the banks are read one by one, then the rows are sent.
|
|
9
|
+
* A bank that fails does not stop the others, and nothing is sent for it.
|
|
10
|
+
*/
|
|
11
|
+
import { chmodSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createInterface } from "node:readline/promises";
|
|
13
|
+
import { optional, parse, RUN_FLAGS, TRUST_FLAGS } from "./args.js";
|
|
14
|
+
import { COMPANIES, isCompany, LEFT_OUT } from "./companies.js";
|
|
15
|
+
import { ConfigError, guardActions, loadConfig, redact, secretsOf } from "./config.js";
|
|
16
|
+
import { israelDay, mapAccount } from "./map.js";
|
|
17
|
+
import { defaultProfileBase, profileDir } from "./profile.js";
|
|
18
|
+
import { push, PushError } from "./push.js";
|
|
19
|
+
import { needsTrustHint, trustDevice } from "./trust.js";
|
|
20
|
+
// The library reads the banks' dates in the process's zone. Pinned before any
|
|
21
|
+
// bank is read, so a run on a UTC server books the same days as one in Haifa.
|
|
22
|
+
process.env["TZ"] = "Asia/Jerusalem";
|
|
23
|
+
const EXIT = { ok: 0, failure: 1, usage: 2 };
|
|
24
|
+
//: Every password and key of this run, once the config is read, so that even
|
|
25
|
+
//: an error nobody expected is printed without them.
|
|
26
|
+
let SECRETS = [];
|
|
27
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
|
|
28
|
+
.version;
|
|
29
|
+
const HELP = `orla-il-banks ${VERSION}
|
|
30
|
+
|
|
31
|
+
Reads Israeli bank and card accounts on this machine and files the rows into
|
|
32
|
+
Orla. Your bank passwords stay here; Orla only receives the rows.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
orla-il-banks run [--config <file>] [--days <n>] [--dry-run] [--only <ids>]
|
|
36
|
+
orla-il-banks companies
|
|
37
|
+
orla-il-banks check-browser
|
|
38
|
+
orla-il-banks trust <company> [--config <file>]
|
|
39
|
+
|
|
40
|
+
run:
|
|
41
|
+
--config <file> JSON with orla.url, orla.token, days and accounts.
|
|
42
|
+
Must be readable by you only (chmod 600).
|
|
43
|
+
Without it: ORLA_TOKEN, ORLA_IL_ACCOUNTS, ORLA_URL, ORLA_IL_DAYS.
|
|
44
|
+
--days <n> how far back to read, 1 to 730 (default 90)
|
|
45
|
+
--dry-run read the banks and show what would be sent; send nothing
|
|
46
|
+
--only <ids> only these companies, comma separated (e.g. isracard,max)
|
|
47
|
+
--save-json <file> also save what the banks returned (readable by you only)
|
|
48
|
+
--from-json <file> send a saved file instead of reading the banks
|
|
49
|
+
--show-browser show the browser while it logs in, for debugging
|
|
50
|
+
--profile-dir <dir> where kept browser profiles live (default ~/.orla-il-banks/profiles)
|
|
51
|
+
|
|
52
|
+
check-browser starts the browser on an empty page and closes it. Run it
|
|
53
|
+
first on a new machine, container or CI runner: it touches no bank.
|
|
54
|
+
|
|
55
|
+
trust opens a bank's own login page on the browser profile later runs use.
|
|
56
|
+
Log in there yourself, with the code the bank sends; the window closes when
|
|
57
|
+
the bank shows your accounts. Needed once per computer for Bank Hapoalim,
|
|
58
|
+
which asks for a code whenever a login comes from a device it does not know.
|
|
59
|
+
|
|
60
|
+
Exit status: 0 everything went through, 1 a bank or the delivery failed,
|
|
61
|
+
2 the command line or the config is wrong.
|
|
62
|
+
`;
|
|
63
|
+
function fail(message, code) {
|
|
64
|
+
process.stderr.write(`orla-il-banks: ${message}\n`);
|
|
65
|
+
process.exit(code);
|
|
66
|
+
}
|
|
67
|
+
function companiesText() {
|
|
68
|
+
const lines = Object.entries(COMPANIES).map(([id, spec]) => {
|
|
69
|
+
const note = "otp" in spec
|
|
70
|
+
? " (asks for a code at every login: run it from a terminal)"
|
|
71
|
+
: "trustedDevice" in spec
|
|
72
|
+
? ` (once per computer: orla-il-banks trust ${id})`
|
|
73
|
+
: "";
|
|
74
|
+
return ` ${id.padEnd(18)} ${spec.name.padEnd(20)} ${spec.fields.join(", ")}${note}`;
|
|
75
|
+
});
|
|
76
|
+
const out = Object.entries(LEFT_OUT).map(([id, why]) => ` ${id.padEnd(18)} not supported: ${why}`);
|
|
77
|
+
return `Company id Name Config fields\n${lines.join("\n")}\n\n${out.join("\n")}\n`;
|
|
78
|
+
}
|
|
79
|
+
function readSaved(path) {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new ConfigError(`${path} is not a file saved by --save-json`);
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(parsed))
|
|
88
|
+
throw new ConfigError(`${path} is not a file saved by --save-json`);
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
function onlyList(raw) {
|
|
92
|
+
if (!raw)
|
|
93
|
+
return null;
|
|
94
|
+
const ids = raw.split(",").map((id) => id.trim()).filter(Boolean);
|
|
95
|
+
for (const id of ids) {
|
|
96
|
+
if (!isCompany(id))
|
|
97
|
+
throw new ConfigError(`--only: unknown company "${id}"`);
|
|
98
|
+
}
|
|
99
|
+
return new Set(ids);
|
|
100
|
+
}
|
|
101
|
+
async function ask(question) {
|
|
102
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
103
|
+
try {
|
|
104
|
+
return (await rl.question(question)).trim();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
rl.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function readBanks(config, only, showBrowser, secrets, profileBase) {
|
|
111
|
+
const { scrape } = await import("./scrape.js");
|
|
112
|
+
const startDate = new Date(Date.now() - config.days * 24 * 3600 * 1000);
|
|
113
|
+
const read = [];
|
|
114
|
+
let failed = 0;
|
|
115
|
+
for (const account of config.accounts) {
|
|
116
|
+
if (only && !only.has(account.company))
|
|
117
|
+
continue;
|
|
118
|
+
const spec = COMPANIES[account.company];
|
|
119
|
+
const name = spec.name;
|
|
120
|
+
if ("otp" in spec && !process.stdin.isTTY) {
|
|
121
|
+
process.stderr.write(`${name}: skipped. It asks for a code at every login, so it only runs from a terminal.\n`);
|
|
122
|
+
failed += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let kept;
|
|
126
|
+
if ("trustedDevice" in spec) {
|
|
127
|
+
if (process.env["GITHUB_ACTIONS"] === "true" || process.env["ORLA_IL_EPHEMERAL"] === "1") {
|
|
128
|
+
// A runner or a container is a new device every time, and keeping the
|
|
129
|
+
// profile there would mean a bank session in a cache or an image.
|
|
130
|
+
const where = process.env["GITHUB_ACTIONS"] === "true" ? "a GitHub runner" : "this container";
|
|
131
|
+
process.stderr.write(`${name}: skipped. It asks for a code whenever a login comes from a device it does not know, and ${where} is new every time. Run it from your own computer.\n`);
|
|
132
|
+
failed += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
kept = profileDir(profileBase, account.company, account.credentials);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (!(error instanceof ConfigError))
|
|
140
|
+
throw error;
|
|
141
|
+
process.stderr.write(`${name}: skipped. ${error.message}\n`);
|
|
142
|
+
failed += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
process.stdout.write(`${name}: logging in\n`);
|
|
147
|
+
const outcome = await scrape(account, { startDate, showBrowser, ask, env: process.env, profileDir: kept });
|
|
148
|
+
if (!outcome.ok) {
|
|
149
|
+
process.stderr.write(`${name}: failed. ${redact(outcome.error, secrets)}\n`);
|
|
150
|
+
if (needsTrustHint(account.company, outcome.error)) {
|
|
151
|
+
process.stderr.write(`${name}: if the bank asked for a code, this computer is new to it. Run \`orla-il-banks trust ${account.company}\` once, log in on the bank's page, then run again.\n`);
|
|
152
|
+
}
|
|
153
|
+
failed += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
read.push({ company: account.company, accounts: outcome.accounts });
|
|
157
|
+
}
|
|
158
|
+
return { read, failed };
|
|
159
|
+
}
|
|
160
|
+
async function run(flags) {
|
|
161
|
+
const unknown = Object.keys(flags).filter((flag) => !RUN_FLAGS.has(flag));
|
|
162
|
+
if (unknown.length)
|
|
163
|
+
fail(`unknown flag ${unknown.map((f) => `--${f}`).join(", ")}. See --help.`, EXIT.usage);
|
|
164
|
+
const dryRun = flags["dry-run"] === true;
|
|
165
|
+
const fromJson = optional(flags, "from-json");
|
|
166
|
+
const saveJson = optional(flags, "save-json");
|
|
167
|
+
let config;
|
|
168
|
+
let only;
|
|
169
|
+
try {
|
|
170
|
+
config = loadConfig({
|
|
171
|
+
file: optional(flags, "config"),
|
|
172
|
+
env: process.env,
|
|
173
|
+
needToken: !dryRun,
|
|
174
|
+
needAccounts: !fromJson,
|
|
175
|
+
days: optional(flags, "days"),
|
|
176
|
+
});
|
|
177
|
+
only = onlyList(optional(flags, "only"));
|
|
178
|
+
guardActions(config, process.env, (line) => process.stdout.write(`${line}\n`));
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof ConfigError)
|
|
182
|
+
fail(error.message, EXIT.usage);
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
const secrets = secretsOf(config);
|
|
186
|
+
SECRETS = secrets;
|
|
187
|
+
let read;
|
|
188
|
+
let failed = 0;
|
|
189
|
+
if (fromJson) {
|
|
190
|
+
try {
|
|
191
|
+
read = readSaved(fromJson).filter((saved) => !only || only.has(saved.company));
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error instanceof ConfigError)
|
|
195
|
+
fail(error.message, EXIT.usage);
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const profileBase = optional(flags, "profile-dir") ?? defaultProfileBase(process.env);
|
|
201
|
+
({ read, failed } = await readBanks(config, only, flags["show-browser"] === true, secrets, profileBase));
|
|
202
|
+
}
|
|
203
|
+
if (saveJson && !fromJson) {
|
|
204
|
+
// A bank statement on disk: readable by this user only, like the config.
|
|
205
|
+
// `mode` applies only to a file being created, so an existing one is
|
|
206
|
+
// narrowed before it is written.
|
|
207
|
+
writeFileSync(saveJson, "", { mode: 0o600 });
|
|
208
|
+
chmodSync(saveJson, 0o600);
|
|
209
|
+
writeFileSync(saveJson, `${JSON.stringify(read, null, 2)}\n`);
|
|
210
|
+
process.stdout.write(`saved what the banks returned to ${saveJson}\n`);
|
|
211
|
+
}
|
|
212
|
+
const today = israelDay(new Date());
|
|
213
|
+
//: One delivery per login, never one for everything. Orla links two rows of
|
|
214
|
+
//: one delivery as a transfer by itself (same day, same amount, one each
|
|
215
|
+
//: way), a rule written for one login at one institution. Across
|
|
216
|
+
//: institutions it would take a card refund and an unrelated bank debit of
|
|
217
|
+
//: the same amount for a transfer and hide the expense. Per login, the card's
|
|
218
|
+
//: cycle row and the bank's line meet as a suggestion instead: one click.
|
|
219
|
+
const deliveries = [];
|
|
220
|
+
for (const saved of read) {
|
|
221
|
+
if (!isCompany(saved.company)) {
|
|
222
|
+
process.stderr.write(`${saved.company}: not a company this runner knows, left out\n`);
|
|
223
|
+
failed += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const company = saved.company;
|
|
227
|
+
const rows = [];
|
|
228
|
+
deliveries.push({ name: COMPANIES[company].name, rows });
|
|
229
|
+
for (const account of saved.accounts) {
|
|
230
|
+
const mapped = mapAccount(company, account, today);
|
|
231
|
+
rows.push(...mapped.rows);
|
|
232
|
+
const name = mapped.rows[0]?.account_name ?? COMPANIES[company].name;
|
|
233
|
+
const left = Object.entries(mapped.skipped)
|
|
234
|
+
.filter(([, count]) => count > 0)
|
|
235
|
+
.map(([why, count]) => `${count} ${why}`);
|
|
236
|
+
const cycles = mapped.cycles ? `, ${mapped.cycles} billing cycles` : "";
|
|
237
|
+
process.stdout.write(`${name}: ${mapped.rows.length - mapped.cycles} rows${cycles}${left.length ? ` (left out: ${left.join(", ")})` : ""}\n`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const count = deliveries.reduce((sum, d) => sum + d.rows.length, 0);
|
|
241
|
+
if (dryRun) {
|
|
242
|
+
process.stdout.write(`dry run: ${count} rows ready, nothing sent\n`);
|
|
243
|
+
return failed ? EXIT.failure : EXIT.ok;
|
|
244
|
+
}
|
|
245
|
+
if (!count) {
|
|
246
|
+
process.stdout.write("nothing to send\n");
|
|
247
|
+
return failed ? EXIT.failure : EXIT.ok;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const totals = { booked: 0, duplicates: 0, skipped_closed: 0, rejected: [] };
|
|
251
|
+
for (const delivery of deliveries) {
|
|
252
|
+
if (!delivery.rows.length)
|
|
253
|
+
continue;
|
|
254
|
+
const out = await push(config.url, config.token, delivery.rows);
|
|
255
|
+
totals.booked += out.booked;
|
|
256
|
+
totals.duplicates += out.duplicates;
|
|
257
|
+
totals.skipped_closed += out.skipped_closed;
|
|
258
|
+
totals.rejected.push(...out.rejected);
|
|
259
|
+
}
|
|
260
|
+
process.stdout.write(`Orla: ${totals.booked} new, ${totals.duplicates} already there` +
|
|
261
|
+
(totals.skipped_closed ? `, ${totals.skipped_closed} in closed months` : "") +
|
|
262
|
+
(totals.rejected.length ? `, ${totals.rejected.length} refused` : "") +
|
|
263
|
+
"\n");
|
|
264
|
+
for (const refusal of totals.rejected) {
|
|
265
|
+
process.stderr.write(`refused ${refusal["external_id"] ?? "a row"}: ${refusal["reason"] ?? JSON.stringify(refusal)}\n`);
|
|
266
|
+
}
|
|
267
|
+
if (totals.rejected.length)
|
|
268
|
+
failed += 1;
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error instanceof PushError) {
|
|
272
|
+
process.stderr.write(`orla-il-banks: ${redact(error.message, secrets)}\n`);
|
|
273
|
+
return EXIT.failure;
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
return failed ? EXIT.failure : EXIT.ok;
|
|
278
|
+
}
|
|
279
|
+
async function trust(company, flags) {
|
|
280
|
+
const unknown = Object.keys(flags).filter((flag) => !TRUST_FLAGS.has(flag));
|
|
281
|
+
if (unknown.length)
|
|
282
|
+
fail(`unknown flag ${unknown.map((f) => `--${f}`).join(", ")}. See --help.`, EXIT.usage);
|
|
283
|
+
if (!company || !isCompany(company))
|
|
284
|
+
fail(`trust needs a company id. See \`orla-il-banks companies\`.`, EXIT.usage);
|
|
285
|
+
const spec = COMPANIES[company];
|
|
286
|
+
if (!("trustedDevice" in spec)) {
|
|
287
|
+
fail(`${spec.name} does not need a trusted computer; \`orla-il-banks run\` logs in on its own.`, EXIT.usage);
|
|
288
|
+
}
|
|
289
|
+
if (process.env["GITHUB_ACTIONS"] === "true") {
|
|
290
|
+
fail("trust needs a person at a screen and a browser profile kept on this computer; it does not run in GitHub Actions.", EXIT.usage);
|
|
291
|
+
}
|
|
292
|
+
let config;
|
|
293
|
+
let dirs;
|
|
294
|
+
try {
|
|
295
|
+
// the config only names the profile: one per login, as `run` will use it
|
|
296
|
+
config = loadConfig({ file: optional(flags, "config"), env: process.env, needToken: false });
|
|
297
|
+
const base = optional(flags, "profile-dir") ?? defaultProfileBase(process.env);
|
|
298
|
+
dirs = config.accounts
|
|
299
|
+
.filter((account) => account.company === company)
|
|
300
|
+
.map((account) => profileDir(base, company, account.credentials));
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (error instanceof ConfigError)
|
|
304
|
+
fail(error.message, EXIT.usage);
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
SECRETS = secretsOf(config);
|
|
308
|
+
if (!dirs.length)
|
|
309
|
+
fail(`no ${spec.name} login in the config: add it first, then run trust.`, EXIT.usage);
|
|
310
|
+
let failed = 0;
|
|
311
|
+
for (const [index, dir] of dirs.entries()) {
|
|
312
|
+
const which = dirs.length > 1 ? ` (login ${index + 1} of ${dirs.length})` : "";
|
|
313
|
+
process.stdout.write(`${spec.name}${which}: a browser window opens on the bank's own login page. Log in there with the code the bank sends. The window closes by itself when the bank shows your accounts; you have 10 minutes.\n`);
|
|
314
|
+
const outcome = await trustDevice(company, { profileDir: dir, env: process.env });
|
|
315
|
+
if (outcome === "trusted") {
|
|
316
|
+
process.stdout.write(`${spec.name}${which}: this computer is known to the bank now. \`orla-il-banks run\` logs in from it.\n`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
process.stderr.write(`${spec.name}${which}: ${outcome === "closed" ? "the window was closed before the bank showed your accounts" : "no login within 10 minutes"}. Run trust again when ready.\n`);
|
|
320
|
+
failed += 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return failed ? EXIT.failure : EXIT.ok;
|
|
324
|
+
}
|
|
325
|
+
async function main(argv) {
|
|
326
|
+
const { words, flags } = parse(argv);
|
|
327
|
+
if (flags["version"]) {
|
|
328
|
+
process.stdout.write(`${VERSION}\n`);
|
|
329
|
+
return EXIT.ok;
|
|
330
|
+
}
|
|
331
|
+
const command = words[0];
|
|
332
|
+
if (!command || flags["help"] || command === "help") {
|
|
333
|
+
process.stdout.write(HELP);
|
|
334
|
+
return command || flags["help"] ? EXIT.ok : EXIT.usage;
|
|
335
|
+
}
|
|
336
|
+
if (command === "companies") {
|
|
337
|
+
process.stdout.write(companiesText());
|
|
338
|
+
return EXIT.ok;
|
|
339
|
+
}
|
|
340
|
+
if (command === "check-browser") {
|
|
341
|
+
const { checkBrowser } = await import("./scrape.js");
|
|
342
|
+
try {
|
|
343
|
+
process.stdout.write(`browser ok: ${await checkBrowser(process.env)}\n`);
|
|
344
|
+
return EXIT.ok;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
fail(`the browser did not start: ${error instanceof Error ? error.message : String(error)}`, EXIT.failure);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (command === "run")
|
|
351
|
+
return run(flags);
|
|
352
|
+
if (command === "trust")
|
|
353
|
+
return trust(words[1], flags);
|
|
354
|
+
fail(`unknown command "${command}". See --help.`, EXIT.usage);
|
|
355
|
+
}
|
|
356
|
+
main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
|
|
357
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
358
|
+
process.stderr.write(`orla-il-banks: ${redact(message, SECRETS)}\n`);
|
|
359
|
+
process.exit(EXIT.failure);
|
|
360
|
+
});
|