earnrate 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ I wanted a ghost card: one card that always charges the best of my cards. The ca
9
9
 
10
10
  **earnrate is the best-card decision layer for people and AI agents.** Give it a merchant, and it tells you which card earns the most there, from your own cards or from every card it knows, and why. Save your cards and it tracks your caps and chosen categories. It's valuation-aware and transparent about its assumptions. It never sees a card number.
11
11
 
12
- - Runs on your machine, in your browser at [earnrate.dev](https://earnrate.dev), or in Claude as a connector at `https://mcp.earnrate.dev`, with nothing to install. No account and no affiliate links; the hosted server counts requests by tool name, nothing about what you ask. See the [privacy page](https://earnrate.dev/privacy).
12
+ - Runs on your machine, in your browser at [earnrate.dev](https://earnrate.dev) (which you can add to your phone's Home Screen and use offline), or in Claude as a connector at `https://mcp.earnrate.dev`, with nothing to install. No account and no affiliate links; the hosted server counts requests by tool name, nothing about what you ask. See the [privacy page](https://earnrate.dev/privacy).
13
13
  - U.S. cards and U.S. merchants, for now.
14
14
 
15
15
  > **Status:** early, and it works: the engine, CLI, optional Claude merchant lookup, MCP server and the hosted Claude connector. See [the benchmark](docs/benchmark.md) (run 2026-10-24) for how it compares to an existing card-picking MCP server.
@@ -43,11 +43,11 @@ Whole Foods Market · $80
43
43
  Use Blue Cash Preferred: 6% back, about $4.80. High confidence.
44
44
 
45
45
  1. Blue Cash Preferred 6% $4.80
46
- 6% cash back from its "us-supermarkets" rule. ...
46
+ 6% cash back on supermarkets. ...
47
47
  2. Prime Visa 5% $4.00
48
- 5% cash back from its "whole-foods" rule. Online or in-store.
48
+ 5% cash back on Whole Foods Market, with Amazon Prime. ...
49
49
  3. Amex Gold 4% $3.20
50
- 4x Amex Membership Rewards from its "us-supermarkets" rule. ...
50
+ 4x Amex Membership Rewards on supermarkets. ...
51
51
  Valued at 1¢ per point: 4% back.
52
52
  + 6 more cards (--all lists them)
53
53
 
@@ -61,6 +61,10 @@ Options: `--online` or `--in-store`, `--valuation flat|points-heavy`, `--all`, `
61
61
 
62
62
  `earnrate cheatsheet` prints the card to use for each everyday kind of purchase this quarter (groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores, everything else), with your caps, picks and activations as they are now. `--cards` uses cards you name instead, and `--every-card` picks from every card to show what's worth getting. It's also in the My wallet tab on [earnrate.dev](https://earnrate.dev).
63
63
 
64
+ `earnrate todo` lists what your saved cards need this quarter, soonest first: bonuses to activate (with Chase's deadline), categories to pick, caps running low and welcome bonuses to finish. `--ics earnrate.ics` also writes the dates as a calendar file with reminders. My wallet on earnrate.dev shows the same list as "To do now", with an "Add the dates to your calendar" button.
65
+
66
+ `earnrate missed` lists the stores earnrate didn't recognize when you asked here, most asked about first. They're kept only on your computer; [open an issue](https://github.com/cwasley/earnrate/issues) to suggest one for the data.
67
+
64
68
  ### Your wallet
65
69
 
66
70
  The wallet is a YAML file (`~/.earnrate/wallet.yaml`) listing your cards by ID, which is each card's file name in [`data/cards/`](data/cards). `earnrate add`, `update`, `remove`, `memberships` and `values` write it for you and keep your comments, or you can edit it by hand. Only the cards are required:
@@ -184,7 +188,7 @@ Use the connector (or plugin) or the local extension, not both: each keeps its o
184
188
  - `best_card_for_purchase`: which card to use, with reasons
185
189
  - `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
186
190
  - `resolve_merchant`: how a merchant was identified
187
- - `list_wallet`: your cards, caps left this period, chosen categories, welcome-bonus progress and the point values you've set
191
+ - `list_wallet`: a to-do list for your cards right now, then your cards, caps left this period, chosen categories, welcome-bonus progress and the point values you've set
188
192
  - `cheat_sheet`: the card to use for each everyday kind of purchase this quarter
189
193
  - `compare_welcome_offers`: the best public sign-up offers on cards you don't have
190
194
  - `record_purchase`: books a purchase against its cap and any open welcome bonus in your wallet file, keeping your comments
@@ -228,7 +232,7 @@ merchant text
228
232
  - **Merchants resolve to a category plus the assumption behind it.** "Whole Foods codes as a supermarket (MCC 5411)" is stated in the answer, and venues that can code more than one way (a restaurant inside a stadium or hotel) come back at medium confidence with the alternative spelled out. Unknown merchants fall back to base rates, or to an opt-in, cached and labeled Claude guess.
229
233
  - **The engine is pure and deterministic.** Given a purchase, a date and a set of cards (yours, or every card), it checks every rule against network acceptance, the date window, caps already used (split when a purchase straddles one), quarterly picks and activations, then values points at your cents-per-point, including pooling (Freedom Unlimited points are worth Sapphire points when you hold both). Ties, the valuation at which the runner-up would win, stale data, and a rotating card whose new quarter isn't in the data yet all show up in the answer.
230
234
  - **Wallet edits keep your file intact.** Adding or changing a card, or recording a purchase, splices in only the lines that changed and checks the result before saving, so comments and formatting in your YAML survive.
231
- - **Drift detection.** A weekly GitHub Action opens each issuer page in a headless browser, checks every quote is still there, and opens an issue when terms move. It stores only hashes of issuer text. A daily one checks earnrate.dev and the hosted server end to end (sign in, ask, save a card, delete the wallet) and opens an issue if anything fails.
235
+ - **Drift detection.** A weekly GitHub Action opens each issuer page in a headless browser, checks every quote is still there, and opens an issue when terms move. It stores only hashes of issuer text. An hourly check confirms earnrate.dev and the hosted server are up, and a daily one checks them end to end (a browser load of the site, sign in, ask, save a card, delete the wallet, and the server's error counts), opening an issue if anything fails.
232
236
  - **Tested end to end.** Golden cases written from issuer terms, CLI snapshot tests, an MCP round trip over real stdio, an install test that packs and installs the npm package, and a check that no committed file contains a card number. CI runs all of it on every push.
233
237
 
234
238
  The code is TypeScript run directly by Node 24 (`src/`), with the engine in `src/engine/`, merchant resolution in `src/merchants/`, and the CLI and MCP server as thin layers over them. `site/` is earnrate.dev, which runs the same engine in the browser, and `remote/` is the hosted server at mcp.earnrate.dev.
@@ -249,7 +253,7 @@ Rotating categories (Freedom Flex) are added each quarter once the issuer posts
249
253
  earnrate needs no account. The local version has no telemetry; the hosted server counts requests and errors by method, tool name or page only, never what was asked. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
250
254
 
251
255
  - **The Claude connector:** earnrate's server at mcp.earnrate.dev receives each question Claude asks it (a store or kind of purchase, and any amount, spending or cards you gave) and answers without keeping it; request logging is off. It counts requests and errors by method, tool name or page only. Connecting it starts an anonymous wallet (a recovery code, no email). It holds only what you save: card IDs and the details you gave, memberships and point values, never card numbers, and is encrypted with its own key that only your recovery code or your connected apps can unlock. Delete it any time; it's deleted automatically after 13 months unused, or a week if it's never used.
252
- - **What it stores locally:** your wallet, a YAML file on your computer (`~/.earnrate/wallet.yaml` unless you set `EARNRATE_WALLET`). It lists cards by product ID, like `amex-gold`, plus your category picks, activations and how much you've spent toward caps. earnrate never asks for or stores card numbers, expiry dates, security codes or bank logins.
256
+ - **What it stores locally:** your wallet, a YAML file on your computer (`~/.earnrate/wallet.yaml` unless you set `EARNRATE_WALLET`). It lists cards by product ID, like `amex-gold`, plus your category picks, activations and how much you've spent toward caps. earnrate never asks for or stores card numbers, expiry dates, security codes or bank logins. When you ask about a store earnrate doesn't recognize, its name goes in `~/.earnrate/missed-merchants.json` (`earnrate missed` lists them; `EARNRATE_MISSED_MERCHANTS=off` keeps no list).
253
257
  - **What the local version sends:** nothing, by default. The optional merchant lookup (off unless you turn it on with your own Anthropic API key) sends only the name of a store earnrate doesn't recognize to Anthropic's API, and saves the answer in `~/.earnrate/merchant-cache.json`.
254
258
  - **AI assistants:** when you use earnrate through Claude or another assistant, that assistant's provider sees the conversation, including earnrate's answers, under its own privacy policy.
255
259
  - **earnrate.dev:** everything you enter on the site stays in your browser. The site counts visits with Cloudflare Web Analytics, without cookies.
package/data/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  Every change to card rules, merchants, categories or currencies under `data/`. Bump `data/VERSION` (`YYYY.MM.DD`, plus `.N` for a second release on the same day) and add a section here in the same commit. Each row cites the issuer page the new value came from.
4
4
 
5
+ ## 2026.11.02.5
6
+
7
+ Prime Visa's 3% without-Prime rate no longer applies at Whole Foods stores outside the U.S. Chase's terms exclude "purchases made at international Whole Foods Markets" from both the 5% and the 3% rate; only the 5% rule was U.S.-only.
8
+
9
+ | Card | Field | Old → new | Source |
10
+ | ---------------- | ------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
11
+ | chase-prime-visa | amazon-without-prime | Whole Foods moved to its own rule | [Chase Prime Visa page](https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards) |
12
+ | chase-prime-visa | whole-foods-without-prime | new rule: 3% at Whole Foods without Prime, U.S. only (abroad: 3% → 1%) | [Chase Prime Visa page](https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards) |
13
+
14
+ ## 2026.11.02.4
15
+
16
+ Customized Cash's todo about purchases that cross the $2,500 quarterly cap is resolved: past the cap, a BofA Rewards tier's boosted base rate now applies. No rates changed.
17
+
18
+ | Card | Field | Old → new | Source |
19
+ | --------------------------------------- | ----- | ----------------------------- | ------ |
20
+ | bank-of-america-customized-cash-rewards | todo | removed the cap-crossing note | n/a |
21
+
22
+ ## 2026.11.02.3
23
+
24
+ The engine now prices spending past a cap at the card's best uncapped rate for that purchase when it beats the cap's own rate, so Freedom Flex's todo about crossing its $1,500 quarterly cap is resolved. No rates changed.
25
+
26
+ | Card | Field | Old → new | Source |
27
+ | ------------------ | ----- | ----------------------------- | -------------------------------------------------------------------------------------- |
28
+ | chase-freedom-flex | todo | removed the cap-crossing note | [Freedom Flex FAQ](https://www.chase.com/personal/credit-cards/freedom/flex-learnmore) |
29
+
5
30
  ## 2026.11.02.2
6
31
 
7
32
  Rules can carry `activate_by`, the issuer's activation deadline, so answers mention it only while a bonus isn't activated. No rates changed.
package/data/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.11.02.2
1
+ 2026.11.02.5
@@ -212,5 +212,4 @@ evidence:
212
212
  todo:
213
213
  - New accounts earn 6% in the choice category for the first year (an extra 3%, inside the same $2,500 quarterly cap, with no BofA Rewards bonus on the extra 3%). earnrate can't see when an account was opened, so this isn't modeled.
214
214
  - 'The BofA Rewards rules use proposed membership ids: bofa-rewards-member, bofa-rewards-preferred-plus, bofa-rewards-preferred-honors and bofa-rewards-premier. Cardholders who joined Preferred Rewards before May 26, 2026 may have an "extended card benefit" that Bank of America doesn''t define.'
215
- - With a BofA Rewards tier, a purchase that crosses the $2,500 cap counts the part past the cap at 1% instead of the tier's boosted base rate, so it's slightly understated.
216
215
  - "Online shopping counts any online purchase earnrate can't place in an excluded category, including unknown merchants. Bank of America also excludes doctors, government, insurance, schools and other non-retail services, which earnrate can't tell apart."
@@ -187,5 +187,4 @@ evidence:
187
187
  url: https://creditcards.chase.com/cash-back-credit-cards/freedom/flex
188
188
  quote: 'Earn a $250 bonus after you spend $500 on purchases in the first 3 months from account opening'
189
189
  todo:
190
- - 'q4-2026-dining: a single dining purchase that crosses the $1,500 cap counts the part over the cap at 1%, not the everyday 3%; whole purchases after the cap correctly fall back to the 3% dining rule.'
191
190
  - 'Q2 2027 (Apr-Jun) shows "Coming Soon"; add it after Chase posts it (activation opens March 15, 2027).'
@@ -38,9 +38,14 @@ rules:
38
38
  rate: 5
39
39
  requires: { membership: amazon-prime }
40
40
  - id: amazon-without-prime
41
- match: { merchants: [amazon, amazon-fresh, whole-foods, audible] }
41
+ match: { merchants: [amazon, amazon-fresh, audible] }
42
42
  rate: 3
43
43
  note: Without an eligible Prime membership these earn 3% instead of 5%.
44
+ - id: whole-foods-without-prime
45
+ match: { merchants: [whole-foods] }
46
+ rate: 3
47
+ geography: us_only
48
+ note: Without an eligible Prime membership, Whole Foods earns 3% instead of 5%, in the U.S. only.
44
49
  - id: chase-travel
45
50
  match: { merchants: [chase-travel] }
46
51
  rate: 5
@@ -93,6 +98,12 @@ evidence:
93
98
  - rule: amazon-without-prime
94
99
  url: https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards
95
100
  quote: '3% back without an eligible Prime membership for each $1 of eligible purchases'
101
+ - rule: whole-foods-without-prime
102
+ url: https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards
103
+ quote: '3% back without an eligible Prime membership for each $1 of eligible purchases'
104
+ - rule: whole-foods-without-prime
105
+ url: https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards
106
+ quote: 'purchases made at international Whole Foods Markets'
96
107
  - rule: chase-travel
97
108
  url: https://creditcards.chase.com/cash-back-credit-cards/amazon-prime-rewards
98
109
  quote: '5% back or 3% back through Chase Travel'
package/dist/brand.js CHANGED
@@ -5,6 +5,7 @@ export const MCP_SERVER_NAME = 'earnrate';
5
5
  export const CONFIG_DIR = '.earnrate'; // under the home directory
6
6
  export const WALLET_ENV_VAR = 'EARNRATE_WALLET';
7
7
  export const MERCHANT_CACHE_ENV_VAR = 'EARNRATE_MERCHANT_CACHE';
8
+ export const MISSED_ENV_VAR = 'EARNRATE_MISSED_MERCHANTS';
8
9
  export const LLM_ENV_VAR = 'EARNRATE_LLM';
9
10
  export const LLM_MODEL_ENV_VAR = 'EARNRATE_LLM_MODEL';
10
11
  export const LLM_MAX_CALLS_ENV_VAR = 'EARNRATE_LLM_MAX_CALLS';
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { writeFileAtomic } from "../data/atomic-write.js";
3
4
  import path from 'node:path';
4
5
  import { parseArgs } from 'node:util';
5
6
  import { z } from 'zod';
@@ -247,10 +248,7 @@ export async function runImport(argv, io) {
247
248
  recorded.add(fingerprint(entry));
248
249
  for (const entry of bonusEntries)
249
250
  recorded.add(fingerprint(entry, 'bonus'));
250
- await mkdir(path.dirname(ledgerFile), { recursive: true });
251
- const tmp = `${ledgerFile}.${String(process.pid)}.tmp`;
252
- await writeFile(tmp, `${JSON.stringify({ version: 1, recorded: [...recorded] }, null, 2)}\n`);
253
- await rename(tmp, ledgerFile);
251
+ await writeFileAtomic(ledgerFile, `${JSON.stringify({ version: 1, recorded: [...recorded] }, null, 2)}\n`);
254
252
  io.stdout(`Recorded ${toRecord.join(' and ')} in ${displayPath(walletFile)}.\n`);
255
253
  return 0;
256
254
  }
@@ -0,0 +1,65 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { parseArgs } from 'node:util';
3
+ import { CLI_NAME, MISSED_ENV_VAR } from "../brand.js";
4
+ import { loadCatalog } from "../data/load.js";
5
+ import { categoryPhrase, shortDate } from "../engine/text.js";
6
+ import { missedPath, readMissed } from "../merchants/missed.js";
7
+ import { displayPath, USAGE_ERROR } from "./run.js";
8
+ const MISSED_HELP = `Usage: ${CLI_NAME} missed [--clear]
9
+
10
+ Stores earnrate didn't recognize when you asked about them here (the command
11
+ line or the local MCP server), most asked about first. They're kept only on
12
+ this computer. To suggest one for earnrate's data, open an issue at
13
+ https://github.com/cwasley/earnrate/issues.
14
+
15
+ Set ${MISSED_ENV_VAR}=off to stop keeping the list, or to a path to keep it
16
+ somewhere else.
17
+
18
+ Options:
19
+ --clear Delete the list.
20
+ -h, --help Show this help.
21
+ `;
22
+ export async function runMissed(argv, io) {
23
+ let parsed;
24
+ try {
25
+ parsed = parseArgs({
26
+ args: argv,
27
+ options: {
28
+ clear: { type: 'boolean' },
29
+ help: { type: 'boolean', short: 'h' },
30
+ },
31
+ });
32
+ }
33
+ catch (e) {
34
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\n${MISSED_HELP}`);
35
+ return USAGE_ERROR;
36
+ }
37
+ if (parsed.values.help) {
38
+ io.stdout(MISSED_HELP);
39
+ return 0;
40
+ }
41
+ const file = missedPath(io.env);
42
+ if (file === null) {
43
+ io.stdout(`The list is off (${MISSED_ENV_VAR}=off).\n`);
44
+ return 0;
45
+ }
46
+ if (parsed.values.clear) {
47
+ await rm(file, { force: true });
48
+ io.stdout(`Deleted ${displayPath(file)}.\n`);
49
+ return 0;
50
+ }
51
+ const missed = await readMissed(file);
52
+ if (missed.length === 0) {
53
+ io.stdout('No unrecognized stores yet.\n');
54
+ return 0;
55
+ }
56
+ const catalog = await loadCatalog();
57
+ const lines = missed
58
+ .sort((a, b) => b.count - a.count || b.last_seen.localeCompare(a.last_seen))
59
+ .map((m) => {
60
+ const kinds = m.categories.map((c) => categoryPhrase(catalog, c));
61
+ return `- ${m.name}: ${String(m.count)} ${m.count === 1 ? 'time' : 'times'}, last ${shortDate(m.last_seen)}${kinds.length === 0 ? '' : ` (taken as ${kinds.join(', ')})`}`;
62
+ });
63
+ io.stdout(`${lines.join('\n')}\n\nKept in ${displayPath(file)}. Suggest additions at https://github.com/cwasley/earnrate/issues.\n`);
64
+ return 0;
65
+ }
package/dist/cli/run.js CHANGED
@@ -14,10 +14,13 @@ import { formatRecommendation, plain, } from "../format/recommendation.js";
14
14
  import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
15
15
  import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
16
16
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
17
+ import { missedPath, missLogAt } from "../merchants/missed.js";
17
18
  import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
18
19
  import { packageVersion } from "../version.js";
19
20
  import { membershipProblem, parseCliArgs } from "./args.js";
20
21
  import { runCheatSheet } from "./cheatsheet.js";
22
+ import { runMissed } from "./missed.js";
23
+ import { runTodo } from "./todo.js";
21
24
  import { runBest, runSimulate } from "./explore.js";
22
25
  import { runImport } from "./import.js";
23
26
  import { runOffers } from "./offers.js";
@@ -47,6 +50,8 @@ Save your cards (optional):
47
50
 
48
51
  Other commands:
49
52
  ${CLI_NAME} cheatsheet The best card for each category this quarter.
53
+ ${CLI_NAME} todo [--ics file] What your cards need this quarter, with dates.
54
+ ${CLI_NAME} missed Stores earnrate didn't recognize, kept on this computer.
50
55
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
51
56
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
52
57
  ${CLI_NAME} mcp Run the MCP server over stdio, for AI assistants.
@@ -160,6 +165,10 @@ export async function run(argv, io) {
160
165
  return runBest(argv.slice(1), io);
161
166
  if (argv[0] === 'cheatsheet')
162
167
  return runCheatSheet(argv.slice(1), io);
168
+ if (argv[0] === 'todo')
169
+ return runTodo(argv.slice(1), io);
170
+ if (argv[0] === 'missed')
171
+ return runMissed(argv.slice(1), io);
163
172
  if (argv[0] === 'simulate')
164
173
  return runSimulate(argv.slice(1), io);
165
174
  const command = argv[0];
@@ -214,6 +223,12 @@ export async function run(argv, io) {
214
223
  merchant = resolved.merchant;
215
224
  notices.push(...resolved.notices);
216
225
  }
226
+ const missedFile = missedPath(io.env);
227
+ if (missedFile !== null &&
228
+ (merchant.source === 'none' ||
229
+ merchant.source === 'caller' ||
230
+ merchant.source === 'llm'))
231
+ await missLogAt(missedFile).note(purchase.merchant, merchant.source === 'none' ? null : merchant.category, io.today);
217
232
  if (loaded === 'none') {
218
233
  const saved = await savedSettings(io);
219
234
  const explored = exploreCards(catalog, {
@@ -0,0 +1,62 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { parseArgs } from 'node:util';
3
+ import { CLI_NAME } from "../brand.js";
4
+ import { loadCatalog, walletPath } from "../data/load.js";
5
+ import { checklist, checklistCalendar } from "../engine/checklist.js";
6
+ import { cardText } from "../format/card-text.js";
7
+ import { dateProblem } from "./args.js";
8
+ import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
9
+ const TODO_HELP = `Usage: ${CLI_NAME} todo [--ics <file>] [--date <YYYY-MM-DD>]
10
+
11
+ What your saved cards need this period, soonest first: bonuses to activate,
12
+ categories to pick, spending caps running low, welcome bonuses to finish, and
13
+ rotating categories the issuer hasn't posted yet.
14
+
15
+ Options:
16
+ --ics <file> Also write the dated items as a calendar file to import
17
+ into your calendar app, with reminders two days before.
18
+ --date <YYYY-MM-DD> The day to check. Default: today.
19
+ -h, --help Show this help.
20
+ `;
21
+ export async function runTodo(argv, io) {
22
+ let parsed;
23
+ try {
24
+ parsed = parseArgs({
25
+ args: argv,
26
+ options: {
27
+ ics: { type: 'string' },
28
+ date: { type: 'string' },
29
+ help: { type: 'boolean', short: 'h' },
30
+ },
31
+ });
32
+ }
33
+ catch (e) {
34
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\n${TODO_HELP}`);
35
+ return USAGE_ERROR;
36
+ }
37
+ const { values } = parsed;
38
+ if (values.help) {
39
+ io.stdout(TODO_HELP);
40
+ return 0;
41
+ }
42
+ const problem = dateProblem(values.date);
43
+ if (problem !== null) {
44
+ io.stderr(`${problem}\n`);
45
+ return USAGE_ERROR;
46
+ }
47
+ const catalog = await loadCatalog();
48
+ const wallet = await readWallet(walletPath(io.env), io);
49
+ if (wallet === null)
50
+ return RUNTIME_ERROR;
51
+ const today = values.date ?? io.today;
52
+ const items = checklist(catalog, wallet, today, undefined, cardText(catalog).shortName);
53
+ io.stdout(items.length === 0
54
+ ? 'Nothing to do: every bonus is activated, every pick is made, and no cap or welcome bonus needs attention.\n'
55
+ : `${items.map((i) => `- ${i.text}`).join('\n')}\n`);
56
+ if (values.ics !== undefined) {
57
+ const stamp = `${new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`;
58
+ await writeFile(values.ics, checklistCalendar(items, stamp));
59
+ io.stdout(`Wrote ${displayPath(values.ics)}; open it to add the dates to your calendar.\n`);
60
+ }
61
+ return 0;
62
+ }
@@ -208,7 +208,7 @@ export async function runWalletCommand(command, argv, io) {
208
208
  if (command === 'memberships') {
209
209
  if (positionals.length === 0 && !values.none) {
210
210
  const held = await heldWallet(io);
211
- io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
211
+ io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
212
212
  return 0;
213
213
  }
214
214
  if (positionals.length > 0 && values.none) {
@@ -0,0 +1,13 @@
1
+ import { chmod, mkdir, realpath, rename, stat, writeFile, } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ // Writes through a symlink rather than replacing it, and keeps the file's
4
+ // mode: a user's chmod 600 must survive every edit.
5
+ export async function writeFileAtomic(file, text) {
6
+ const target = await realpath(file).catch(() => file);
7
+ const mode = await stat(target).then((s) => s.mode & 0o777, () => 0o600);
8
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
9
+ const tmp = `${target}.${String(process.pid)}.tmp`;
10
+ await writeFile(tmp, text, { mode });
11
+ await chmod(tmp, mode); // writeFile's mode is narrowed by the umask
12
+ await rename(tmp, target);
13
+ }
@@ -1,5 +1,6 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
2
  import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
3
+ import { withoutCardNumbers } from "../schema/card-number.js";
3
4
  import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
4
5
  const failed = (error) => ({ ok: false, error });
5
6
  // Memberships and point values can be saved before any card.
@@ -19,7 +20,9 @@ export class JsonWalletStore {
19
20
  }
20
21
  async load() {
21
22
  const text = await this.backend.read();
22
- return text === null ? null : WalletSchema.parse(JSON.parse(text));
23
+ return text === null
24
+ ? null
25
+ : WalletSchema.parse(withoutCardNumbers(JSON.parse(text)));
23
26
  }
24
27
  serialized(task) {
25
28
  const run = this.queue.then(task, task);
@@ -1,5 +1,5 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { writeFileAtomic } from "./atomic-write.js";
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { isMap, isSeq, parseDocument, stringify } from 'yaml';
5
5
  import { applyBonusBooking, applyBooking, } from "../engine/record.js";
@@ -359,8 +359,5 @@ export async function commitWalletText(file, after, expected, fallback = BY_HAND
359
359
  if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
360
360
  throw new Error(`couldn't update ${file} safely; ${fallback}`);
361
361
  }
362
- await mkdir(path.dirname(file), { recursive: true });
363
- const tmp = `${file}.${String(process.pid)}.tmp`;
364
- await writeFile(tmp, after);
365
- await rename(tmp, file);
362
+ await writeFileAtomic(file, after);
366
363
  }
@@ -4,6 +4,7 @@ import { bonusStatus } from "./bonus.js";
4
4
  import { EPSILON, isClose } from "./compare.js";
5
5
  import { periodKey } from "./periods.js";
6
6
  import { recommend } from "./recommend.js";
7
+ import { checklist } from "./checklist.js";
7
8
  import { setupNotes } from "./setup.js";
8
9
  import { capitalize, feeText, listed, money, shortDate } from "./text.js";
9
10
  // The everyday kinds of purchase: the cheat sheet's rows and the site's
@@ -82,10 +83,18 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
82
83
  : 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
83
84
  ];
84
85
  });
86
+ // Picks and activations in plain words, as the to-do list says them; the
87
+ // setup notes name ids for an agent to set.
88
+ const todo = checklist(catalog, wallet, date).filter((i) => i.kind === 'pick' || i.kind === 'activate');
85
89
  const notes = [
86
90
  ...new Set([
87
91
  ...bonuses,
88
- ...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text)),
92
+ ...todo.map((i) => i.text),
93
+ ...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date)
94
+ .filter((n) => n.kind !== 'pick' && n.kind !== 'activation')
95
+ .map((n) => n.kind === 'membership'
96
+ ? n.text.replace(/ \([a-z0-9-]+\)/g, '')
97
+ : n.text)),
89
98
  ]),
90
99
  ];
91
100
  const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
@@ -0,0 +1,152 @@
1
+ import { daysBetween, periodEnd, periodKey } from "./periods.js";
2
+ import { CAP_PERIODS, choiceName, listed, money, shortDate, wholeDollars, } from "./text.js";
3
+ import { optionName } from "./rule-text.js";
4
+ import { activationsDue, walletSummary, } from "./wallet-summary.js";
5
+ // What the wallet's cards need this period, soonest first: bonuses to
6
+ // activate, categories to pick, caps running low, welcome bonuses to finish,
7
+ // and rotating categories the issuer hasn't posted yet.
8
+ export function checklist(catalog, wallet, today, summary = walletSummary(catalog, wallet, today),
9
+ // A shorter card name than the issuer's full one, where the caller has it.
10
+ shortName) {
11
+ const items = [];
12
+ for (const held of wallet.cards) {
13
+ const card = catalog.cards.find((c) => c.id === held.id);
14
+ const s = summary.cards.find((c) => c.cardId === held.id);
15
+ if (!card || !s)
16
+ continue;
17
+ const name = s.checkoutLabel ?? shortName?.(card.id) ?? s.cardName;
18
+ for (const a of activationsDue(catalog, card, held, today)) {
19
+ const starts = a.from !== null && a.from > today
20
+ ? `, starting ${shortDate(a.from)}`
21
+ : '';
22
+ items.push({
23
+ kind: 'activate',
24
+ cardId: card.id,
25
+ text: `Activate ${name}'s bonus on ${a.what}${starts}${a.activateBy === null ? '' : `, by ${shortDate(a.activateBy)}`}.`,
26
+ due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
27
+ });
28
+ }
29
+ const picked = (id) => s.chosen?.picks[id] !== undefined;
30
+ const current = s.chosen?.current === true;
31
+ const unpicked = card.choices.filter((c) => !c.default &&
32
+ !(current && picked(c.id)) &&
33
+ c.period !== 'until_changed');
34
+ const [first] = unpicked;
35
+ if (first) {
36
+ const period = first.period === 'until_changed'
37
+ ? null
38
+ : periodKey(first.period, today, null);
39
+ items.push({
40
+ kind: 'pick',
41
+ cardId: card.id,
42
+ text: `Pick ${name}'s ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
43
+ due: period === null ? null : periodEnd(period),
44
+ });
45
+ }
46
+ else {
47
+ // Picks made for this period: near its end, a reminder to make the
48
+ // next period's (Cash+ asks each quarter).
49
+ const ending = card.choices.find((c) => c.period !== 'until_changed' && !c.default && current);
50
+ const period = ending === undefined || ending.period === 'until_changed'
51
+ ? null
52
+ : periodKey(ending.period, today, null);
53
+ const end = period === null ? null : periodEnd(period);
54
+ if (ending && end !== null && daysBetween(today, end) <= 14)
55
+ items.push({
56
+ kind: 'pick',
57
+ cardId: card.id,
58
+ text: `Pick ${name}'s categories for next ${ending.period.replace('_', ' ')}; this one ends ${shortDate(end)}.`,
59
+ due: end,
60
+ });
61
+ }
62
+ // A pick earnrate assumes because none was saved: worth confirming.
63
+ const assumed = card.choices.filter((c) => c.default && !picked(c.id));
64
+ if (assumed.length > 0)
65
+ items.push({
66
+ kind: 'pick',
67
+ cardId: card.id,
68
+ text: `${name}'s ${listed(assumed.map((c) => choiceName(c.id)))} pick is assumed to be ${listed(assumed.flatMap((c) => (c.default ?? []).map((o) => optionName(catalog, card, c.id, o))))}; set it if you picked something else.`,
69
+ due: null,
70
+ });
71
+ for (const cap of s.caps) {
72
+ // Only caps nearly used up: the rest aren't something to do.
73
+ if (cap.remaining === null || cap.remaining > cap.amount * 0.25)
74
+ continue;
75
+ const resets = cap.currentPeriod === null ? null : periodEnd(cap.currentPeriod);
76
+ items.push({
77
+ kind: 'cap',
78
+ cardId: card.id,
79
+ text: cap.remaining === 0
80
+ ? `${name}'s ${cap.label} cap is used up${resets === null ? '' : ` until ${shortDate(resets)}`}; another card may earn more there.`
81
+ : `${name}: ${money(cap.remaining)} left of ${money(cap.amount)} ${CAP_PERIODS[cap.period]} at the bonus rate on ${cap.label}.`,
82
+ due: resets,
83
+ });
84
+ }
85
+ const b = s.bonus;
86
+ if (b?.state === 'open' && b.remaining !== null && b.deadline !== null) {
87
+ const weeks = Math.max(1, (b.daysLeft ?? 7) / 7);
88
+ items.push({
89
+ kind: 'bonus',
90
+ cardId: card.id,
91
+ text: `${name}'s welcome bonus: ${money(b.remaining)} to go by ${shortDate(b.deadline)}, about ${wholeDollars(b.remaining / weeks)} a week.`,
92
+ due: b.deadline,
93
+ });
94
+ }
95
+ if (s.rotationGap !== null) {
96
+ items.push({
97
+ kind: 'rotation',
98
+ cardId: card.id,
99
+ text: `${name}'s ${s.rotationGap} categories aren't out yet; earnrate adds them once the issuer posts them.`,
100
+ due: null,
101
+ });
102
+ }
103
+ }
104
+ return items.sort((a, b) => (a.due ?? '9999').localeCompare(b.due ?? '9999') ||
105
+ a.cardId.localeCompare(b.cardId));
106
+ }
107
+ // An all-day calendar event for each dated action (activations, picks,
108
+ // welcome-bonus deadlines), with a reminder two days before. Built on the
109
+ // device: nothing is sent anywhere.
110
+ export function checklistCalendar(items, stamp) {
111
+ const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
112
+ const fold = (line) => {
113
+ const parts = [];
114
+ for (let i = 0; i < line.length; i += 73)
115
+ parts.push(line.slice(i, i + 73));
116
+ return parts.join('\r\n ');
117
+ };
118
+ const day = (d) => d.replace(/-/g, '');
119
+ const next = (d) => {
120
+ const t = new Date(`${d}T00:00:00Z`);
121
+ t.setUTCDate(t.getUTCDate() + 1);
122
+ return t.toISOString().slice(0, 10);
123
+ };
124
+ const events = items
125
+ .filter((i) => i.due !== null && i.kind !== 'cap' && i.kind !== 'rotation')
126
+ .flatMap((i) => {
127
+ const due = i.due ?? '';
128
+ return [
129
+ 'BEGIN:VEVENT',
130
+ `UID:${i.kind}-${i.cardId}-${due}@earnrate.dev`,
131
+ `DTSTAMP:${stamp}`,
132
+ `DTSTART;VALUE=DATE:${day(due)}`,
133
+ `DTEND;VALUE=DATE:${day(next(due))}`,
134
+ fold(`SUMMARY:${escape(i.text)}`),
135
+ 'BEGIN:VALARM',
136
+ 'TRIGGER:-P2D',
137
+ 'ACTION:DISPLAY',
138
+ fold(`DESCRIPTION:${escape(i.text)}`),
139
+ 'END:VALARM',
140
+ 'END:VEVENT',
141
+ ];
142
+ });
143
+ return [
144
+ 'BEGIN:VCALENDAR',
145
+ 'VERSION:2.0',
146
+ 'PRODID:-//earnrate//checklist//EN',
147
+ 'CALSCALE:GREGORIAN',
148
+ ...events,
149
+ 'END:VCALENDAR',
150
+ '',
151
+ ].join('\r\n');
152
+ }