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 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"]);
@@ -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
+ });