earnrate 0.2.6 → 0.2.7

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,22 @@
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.4
6
+
7
+ 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.
8
+
9
+ | Card | Field | Old → new | Source |
10
+ | --------------------------------------- | ----- | ----------------------------- | ------ |
11
+ | bank-of-america-customized-cash-rewards | todo | removed the cap-crossing note | n/a |
12
+
13
+ ## 2026.11.02.3
14
+
15
+ 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.
16
+
17
+ | Card | Field | Old → new | Source |
18
+ | ------------------ | ----- | ----------------------------- | -------------------------------------------------------------------------------------- |
19
+ | chase-freedom-flex | todo | removed the cap-crossing note | [Freedom Flex FAQ](https://www.chase.com/personal/credit-cards/freedom/flex-learnmore) |
20
+
5
21
  ## 2026.11.02.2
6
22
 
7
23
  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.4
@@ -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).'
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';
@@ -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) {
@@ -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
+ }
@@ -147,14 +147,14 @@ function capState(rule, card, held, date) {
147
147
  const { period, spent } = capUsed(cap, key, held, date);
148
148
  return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
149
149
  }
150
- function rateAfterCap(rate, cap, amount) {
150
+ function rateAfterCap(rate, cap, afterCap, amount) {
151
151
  if (!cap)
152
152
  return rate;
153
153
  if (amount !== null && amount > 0) {
154
154
  const inCap = Math.min(amount, cap.remaining);
155
- return (inCap * rate + (amount - inCap) * cap.cap.then_rate) / amount;
155
+ return (inCap * rate + (amount - inCap) * afterCap) / amount;
156
156
  }
157
- return cap.remaining > 0 ? rate : cap.cap.then_rate;
157
+ return cap.remaining > 0 ? rate : afterCap;
158
158
  }
159
159
  // On equal rates an uncapped rule wins, so spend that can earn the same
160
160
  // without a cap doesn't use one up. Otherwise the earlier rule stays.
@@ -170,24 +170,31 @@ export function evaluateCard(card, held, ctx) {
170
170
  nominalRate: card.base_rate,
171
171
  rate: card.base_rate,
172
172
  cap: null,
173
+ afterCap: null,
173
174
  };
174
175
  let best = base;
175
176
  let nominal = base;
176
177
  const blocked = [];
178
+ const applying = [];
177
179
  for (const rule of card.rules) {
178
180
  const status = applicability(card, rule, ctx, held, picked);
179
181
  if (status === 'no')
180
182
  continue;
181
- if (status === 'needs-activation') {
183
+ if (status === 'needs-activation')
182
184
  blocked.push(rule);
183
- continue;
184
- }
185
+ else
186
+ applying.push(rule);
187
+ }
188
+ const uncapped = Math.max(card.base_rate, ...applying.filter((r) => r.cap === undefined).map((r) => r.rate));
189
+ for (const rule of applying) {
185
190
  const cap = capState(rule, card, held, ctx.date);
191
+ const afterCap = cap ? Math.max(cap.cap.then_rate, uncapped) : null;
186
192
  const earning = {
187
193
  rule,
188
194
  nominalRate: rule.rate,
189
- rate: rateAfterCap(rule.rate, cap, ctx.amount),
195
+ rate: rateAfterCap(rule.rate, cap, afterCap ?? rule.rate, ctx.amount),
190
196
  cap,
197
+ afterCap,
191
198
  };
192
199
  if (beats(earning, earning.rate, best, best.rate))
193
200
  best = earning;
@@ -5,7 +5,7 @@ import { choicePeriod, periodKey } from "./periods.js";
5
5
  import { recommend } from "./recommend.js";
6
6
  import { rotationGap } from "./rotation.js";
7
7
  import { membershipsUsed } from "./setup.js";
8
- import { optionName } from "./wallet-summary.js";
8
+ import { optionName } from "./rule-text.js";
9
9
  import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
10
10
  import { cardValuation } from "./valuation.js";
11
11
  import { EPSILON } from "./compare.js";
@@ -99,7 +99,7 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
99
99
  ? {
100
100
  amount: cap.amount,
101
101
  period: cap.period,
102
- thenRate: cap.then_rate,
102
+ thenRate: best.afterCap ?? cap.then_rate,
103
103
  currency: cardValuation(catalog, wallet, card).currency,
104
104
  }
105
105
  : null,
@@ -1,7 +1,9 @@
1
1
  import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
4
+ import { membershipName } from "./memberships.js";
4
5
  import { daysBetween } from "./periods.js";
6
+ import { ruleWhat } from "./rule-text.js";
5
7
  import { rotationGap } from "./rotation.js";
6
8
  import { EPSILON, isClose } from "./compare.js";
7
9
  import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
@@ -84,7 +86,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
84
86
  const currency = valuation.currency;
85
87
  if (best.rule) {
86
88
  const note = best.rule.note ? ` ${best.rule.note}` : '';
87
- lines.push(`${earnText(catalog, best.nominalRate, currency)} from its "${best.rule.id}" rule.${note}`);
89
+ lines.push(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
88
90
  }
89
91
  else {
90
92
  lines.push(`${earnText(catalog, card.base_rate, currency)}, its base rate. No bonus category applies here.`);
@@ -92,7 +94,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
92
94
  const cap = best.cap;
93
95
  if (cap && amount !== null && amount > cap.remaining) {
94
96
  const over = amount - cap.remaining;
95
- lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
97
+ lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
96
98
  }
97
99
  if (valuation.pooledFrom !== null) {
98
100
  const partners = card.pools_into?.when_wallet_has ?? [];
@@ -121,17 +123,17 @@ function warningsFor(catalog, entry, purchase) {
121
123
  warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
122
124
  }
123
125
  else if (purchase.amount !== null && purchase.amount > remaining) {
124
- warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${money(remaining)} is left for ${period}.`);
126
+ warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
125
127
  }
126
128
  else if (remaining < cap.amount * NEAR_CAP_SHARE) {
127
- warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
129
+ warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
128
130
  }
129
131
  }
130
132
  if (nominal.rule &&
131
133
  nominal.cap &&
132
134
  nominal !== best &&
133
135
  nominal.cap.remaining === 0) {
134
- warnings.push(`The ${capLabel(nominal)} on "${nominal.rule.id}" is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
136
+ warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
135
137
  }
136
138
  if (best.rule?.unverified !== undefined) {
137
139
  warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
@@ -0,0 +1,47 @@
1
+ import { findMerchant } from "../merchants/resolve.js";
2
+ import { categoryPhrase, choiceName, listed, words } from "./text.js";
3
+ // What a rule or a pick covers, in words, for answers and wallet lists.
4
+ // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
5
+ // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
6
+ const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
7
+ function merchantName(catalog, id) {
8
+ const merchant = findMerchant(catalog, id);
9
+ const name = merchant?.name ?? words(id);
10
+ return merchant?.kind === 'booking'
11
+ ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
12
+ : name;
13
+ }
14
+ export function optionName(catalog, card, choiceId, option) {
15
+ const o = card.choices.find((c) => c.id === choiceId)?.options[option];
16
+ if (!o)
17
+ return words(option);
18
+ const parts = [
19
+ ...o.categories.map((c) => categoryPhrase(catalog, c)),
20
+ ...o.merchants.map((m) => merchantName(catalog, m)),
21
+ ];
22
+ if (o.online)
23
+ parts.push('online shopping');
24
+ return parts.length > 0 ? listed(parts) : words(option);
25
+ }
26
+ // What a rule covers, as a list of phrases.
27
+ export function ruleParts(catalog, rule) {
28
+ if (rule.match.any)
29
+ return ['everything else'];
30
+ if (rule.match.choice !== undefined)
31
+ return [`your ${choiceName(rule.match.choice)} picks`];
32
+ const merchants = rule.match.merchants ?? [];
33
+ // A long list of one kind of store reads as "select streaming services".
34
+ const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
35
+ const [kind] = kinds;
36
+ const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
37
+ ? [`select ${categoryPhrase(catalog, kind)}`]
38
+ : merchants.map((m) => merchantName(catalog, m));
39
+ const parts = [
40
+ ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
41
+ ...merchantParts,
42
+ ];
43
+ return parts.length > 0 ? parts : [words(rule.id)];
44
+ }
45
+ export function ruleWhat(catalog, rule) {
46
+ return listed(ruleParts(catalog, rule));
47
+ }
@@ -1,66 +1,19 @@
1
- import { findMerchant } from "../merchants/resolve.js";
2
1
  import { bonusStatus } from "./bonus.js";
3
2
  import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
4
3
  import { membershipName } from "./memberships.js";
4
+ import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
5
5
  import { daysBetween, inEffect, periodEnd } from "./periods.js";
6
6
  import { STALE_AFTER_DAYS } from "./recommend.js";
7
7
  import { rotationGap } from "./rotation.js";
8
8
  import { activationRules } from "./setup.js";
9
- import { CAP_PERIODS, categoryPhrase, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
9
+ import { CAP_PERIODS, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
10
10
  import { cardValuation, currencyValuation } from "./valuation.js";
11
- // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
12
- // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
13
- const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
14
- function merchantName(catalog, id) {
15
- const merchant = findMerchant(catalog, id);
16
- const name = merchant?.name ?? words(id);
17
- return merchant?.kind === 'booking'
18
- ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
19
- : name;
20
- }
21
- export function optionName(catalog, card, choiceId, option) {
22
- const o = card.choices.find((c) => c.id === choiceId)?.options[option];
23
- if (!o)
24
- return words(option);
25
- const parts = [
26
- ...o.categories.map((c) => categoryPhrase(catalog, c)),
27
- ...o.merchants.map((m) => merchantName(catalog, m)),
28
- ];
29
- if (o.online)
30
- parts.push('online shopping');
31
- return parts.length > 0 ? listed(parts) : words(option);
32
- }
33
- // What a rule covers, as a list of phrases.
34
- function ruleParts(catalog, rule) {
35
- if (rule.match.choice !== undefined)
36
- return [`your ${choiceName(rule.match.choice)} picks`];
37
- const merchants = rule.match.merchants ?? [];
38
- // A long list of one kind of store reads as "select streaming services".
39
- const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
40
- const [kind] = kinds;
41
- const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
42
- ? [`select ${categoryPhrase(catalog, kind)}`]
43
- : merchants.map((m) => merchantName(catalog, m));
44
- const parts = [
45
- ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
46
- ...merchantParts,
47
- ];
48
- return parts.length > 0 ? parts : [words(rule.id)];
49
- }
50
- function ruleWhat(catalog, rule) {
51
- return listed(ruleParts(catalog, rule));
52
- }
53
11
  const matchKeys = (rule) => rule.match.choice !== undefined
54
12
  ? []
55
13
  : [
56
14
  ...(rule.match.categories ?? []).map((c) => `category:${c}`),
57
15
  ...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
58
16
  ];
59
- // Every bonus rate the cardholder gets now, best first, so none reads as the
60
- // base rate. Rates behind a membership the wallet lacks are left out, and so
61
- // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
62
- // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
63
- // a line.
64
17
  // "first $1,500 a quarter, combined": how much of a rate's spending earns it.
65
18
  function capLimit(card, shown, rule) {
66
19
  if (rule.cap === undefined)
@@ -74,6 +27,11 @@ function capLimit(card, shown, rule) {
74
27
  shown.filter((r) => r.cap === rule.cap).length > 1;
75
28
  return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
76
29
  }
30
+ // Every bonus rate the cardholder gets now, best first, so none reads as the
31
+ // base rate. Rates behind a membership the wallet lacks are left out, and so
32
+ // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
33
+ // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
34
+ // a line.
77
35
  function headlineRates(catalog, wallet, card, held, currency, today) {
78
36
  const shown = card.rules
79
37
  .filter((r) => inEffect(r, today) &&
@@ -91,6 +49,16 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
91
49
  .flatMap(matchKeys);
92
50
  if (keys.length > 0 && keys.every((k) => higher.includes(k)))
93
51
  continue;
52
+ // A membership tier's higher rate on the same purchases replaces the
53
+ // card's own (BofA Rewards tiers), cap or not.
54
+ const replaced = shown.some((h) => h !== r &&
55
+ h.rate > r.rate &&
56
+ h.requires?.membership !== undefined &&
57
+ r.requires?.membership === undefined &&
58
+ JSON.stringify(h.match) === JSON.stringify(r.match) &&
59
+ JSON.stringify(h.exclude) === JSON.stringify(r.exclude));
60
+ if (replaced)
61
+ continue;
94
62
  const pending = r.requires?.activation && !isActivated(card, held, r.id);
95
63
  const key = [
96
64
  `${earnText(catalog, r.rate, currency)} on`,
@@ -109,10 +77,14 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
109
77
  const after = `${limit}${activate}`;
110
78
  return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
111
79
  }),
112
- `${earnText(catalog, card.base_rate, currency)} on everything else`,
80
+ ...(shown.some((r) => r.match.any && r.rate > card.base_rate)
81
+ ? []
82
+ : [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
113
83
  ];
114
84
  }
115
- function toActivate(catalog, card, held, today) {
85
+ // Bonuses current or starting soon that the card hasn't activated, one per
86
+ // activation group (one activation covers a quarter's categories).
87
+ export function activationsDue(catalog, card, held, today) {
116
88
  const seen = new Set();
117
89
  const out = [];
118
90
  for (const id of activationRules(card, today)) {
@@ -123,18 +95,25 @@ function toActivate(catalog, card, held, today) {
123
95
  seen.add(g);
124
96
  const rules = card.rules.filter((r) => group.includes(r.id));
125
97
  const first = rules[0];
126
- const deadline = first?.activate_by === undefined
127
- ? ''
128
- : `, activate by ${shortDate(first.activate_by)}`;
129
- const when = first?.valid_from !== undefined && first.valid_to !== undefined
130
- ? ` (${shortDate(first.valid_from)} to ${shortDate(first.valid_to)}${deadline})`
131
- : deadline === ''
132
- ? ''
133
- : ` (${deadline.slice(2)})`;
134
- out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
98
+ out.push({
99
+ what: rules.map((r) => ruleWhat(catalog, r)).join(', '),
100
+ from: first?.valid_from ?? null,
101
+ to: first?.valid_to ?? null,
102
+ activateBy: first?.activate_by ?? null,
103
+ });
135
104
  }
136
105
  return out;
137
106
  }
107
+ function toActivate(catalog, card, held, today) {
108
+ return activationsDue(catalog, card, held, today).map((a) => {
109
+ const deadline = a.activateBy === null ? '' : `activate by ${shortDate(a.activateBy)}`;
110
+ const dates = a.from !== null && a.to !== null
111
+ ? `${shortDate(a.from)} to ${shortDate(a.to)}`
112
+ : '';
113
+ const when = [dates, deadline].filter((x) => x !== '').join(', ');
114
+ return `${a.what}${when === '' ? '' : ` (${when})`}`;
115
+ });
116
+ }
138
117
  function capSummaries(catalog, card, held, today) {
139
118
  const caps = new Map();
140
119
  for (const shared of card.caps)
@@ -1,4 +1,10 @@
1
1
  import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
2
+ // "To do now", above the card details; nothing when all's done.
3
+ export function formatChecklist(items) {
4
+ return items.length === 0
5
+ ? ''
6
+ : `To do now:\n${items.map((i) => `- ${i.text}`).join('\n')}\n\n`;
7
+ }
2
8
  export function formatWalletSummary(summary) {
3
9
  const cards = summary.cards.map((c) => {
4
10
  const label = c.checkoutLabel
@@ -61,10 +61,14 @@ export function makeContext(deps) {
61
61
  // neither ever overrides a merchant earnrate already knows.
62
62
  async function resolve(merchant, channel, category) {
63
63
  const known = resolvePlace(catalog, merchant, channel);
64
- if (known.source !== 'none' || category !== null || !deps.llm) {
65
- return { merchant: withCallerCategory(known, category), notices: [] };
66
- }
67
- return deps.llm.resolve(catalog, merchant, channel);
64
+ const resolved = known.source !== 'none' || category !== null || !deps.llm
65
+ ? { merchant: withCallerCategory(known, category), notices: [] }
66
+ : await deps.llm.resolve(catalog, merchant, channel);
67
+ const source = resolved.merchant.source;
68
+ if (deps.missed &&
69
+ (source === 'none' || source === 'caller' || source === 'llm'))
70
+ await deps.missed.note(merchant, source === 'none' ? null : resolved.merchant.category, deps.today());
71
+ return resolved;
68
72
  }
69
73
  // Cards named in the conversation come first, then the saved wallet; with
70
74
  // neither (null), answers cover every card earnrate knows.
@@ -86,6 +86,14 @@ export const WalletSummarySchema = z.object({
86
86
  redemption: z.string().nullable(),
87
87
  })),
88
88
  started: z.string().nullable(),
89
+ checklist: z
90
+ .array(z.object({
91
+ kind: z.enum(['activate', 'pick', 'cap', 'bonus', 'rotation']),
92
+ cardId: z.string(),
93
+ text: z.string(),
94
+ due: z.string().nullable(),
95
+ }))
96
+ .optional(),
89
97
  cards: z.array(z.object({
90
98
  cardId: z.string(),
91
99
  cardName: z.string(),
package/dist/mcp/serve.js CHANGED
@@ -3,12 +3,15 @@ import { FileWalletStore } from "../data/file-store.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { localToday as today } from "../engine/periods.js";
5
5
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
6
+ import { missedPath, missLogAt } from "../merchants/missed.js";
6
7
  import { packageVersion } from "../version.js";
7
8
  import { createEarnrateServer } from "./server.js";
8
9
  // stdout carries the protocol, so diagnostics go to stderr only.
9
10
  export async function serveMcp(env) {
10
11
  const version = await packageVersion();
11
12
  const catalog = await loadCatalog();
13
+ const missedFile = missedPath(env);
14
+ const missed = missedFile === null ? null : missLogAt(missedFile);
12
15
  let llm = null;
13
16
  if (llmEnabled(env)) {
14
17
  const opened = await openLlmSession({ env, today });
@@ -26,6 +29,7 @@ export async function serveMcp(env) {
26
29
  savedWallets: 'file',
27
30
  today,
28
31
  llm,
32
+ missed,
29
33
  version,
30
34
  }));
31
35
  }
@@ -39,7 +39,7 @@ const WHICH_WALLET = {
39
39
  };
40
40
  const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
41
41
 
42
- Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
42
+ Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing. When the user asks what to do with their cards (this quarter, or what they're forgetting), call list_wallet: it starts with a to-do list.
43
43
 
44
44
  The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
45
45
 
@@ -3,8 +3,11 @@ import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
3
3
  import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../../engine/text.js";
4
4
  import { membershipName } from "../../engine/memberships.js";
5
5
  import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
6
- import { optionName, walletSummary } from "../../engine/wallet-summary.js";
7
- import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
6
+ import { optionName } from "../../engine/rule-text.js";
7
+ import { walletSummary } from "../../engine/wallet-summary.js";
8
+ import { checklist } from "../../engine/checklist.js";
9
+ import { cardText } from "../../format/card-text.js";
10
+ import { formatBooking, formatChecklist, formatWalletSummary, } from "../../format/wallet.js";
8
11
  import { fail } from "../context.js";
9
12
  import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
10
13
  import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
@@ -52,7 +55,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
52
55
  const { catalog, store } = deps;
53
56
  server.registerTool('list_wallet', {
54
57
  title: 'List wallet',
55
- description: `Lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
58
+ description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
56
59
  outputSchema: WalletSummarySchema,
57
60
  annotations: {
58
61
  title: 'List wallet',
@@ -64,9 +67,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
64
67
  if (typeof read === 'string')
65
68
  return fail(read);
66
69
  const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
70
+ const base = walletSummary(catalog, read.wallet, deps.today());
67
71
  const summary = {
68
- ...walletSummary(catalog, read.wallet, deps.today()),
72
+ ...base,
69
73
  started,
74
+ checklist: checklist(catalog, read.wallet, deps.today(), base, cardText(catalog).shortName),
70
75
  };
71
76
  const when = started === null
72
77
  ? `This is ${ctx.walletName}.\n`
@@ -75,7 +80,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
75
80
  content: [
76
81
  {
77
82
  type: 'text',
78
- text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatWalletSummary(summary)}`,
83
+ text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
79
84
  },
80
85
  ],
81
86
  structuredContent: summary,
@@ -0,0 +1,78 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ import { CONFIG_DIR, MISSED_ENV_VAR } from "../brand.js";
6
+ import { normalizeMerchantName } from "./resolve.js";
7
+ const MissedFileSchema = z.object({
8
+ version: z.literal(1),
9
+ merchants: z.record(z.string(), z.object({
10
+ name: z.string(),
11
+ count: z.number().int().positive(),
12
+ last_seen: z.string(),
13
+ categories: z.array(z.string()).default([]),
14
+ })),
15
+ });
16
+ const MAX = 500;
17
+ // $EARNRATE_MISSED_MERCHANTS names another file, or "off" keeps no log.
18
+ export function missedPath(env = process.env) {
19
+ const given = env[MISSED_ENV_VAR]?.trim();
20
+ if (given === 'off')
21
+ return null;
22
+ return given || path.join(homedir(), CONFIG_DIR, 'missed-merchants.json');
23
+ }
24
+ export async function readMissed(file) {
25
+ try {
26
+ const parsed = MissedFileSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
27
+ return parsed.success ? Object.values(parsed.data.merchants) : [];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ // Writes are queued, so concurrent tool calls never lose a count. A file that
34
+ // can't be read as a log is left alone.
35
+ export function missLogAt(file) {
36
+ let queue = Promise.resolve();
37
+ return {
38
+ note(name, category, today) {
39
+ const key = normalizeMerchantName(name);
40
+ if (key === '')
41
+ return Promise.resolve();
42
+ const run = queue.then(async () => {
43
+ let merchants = {};
44
+ try {
45
+ const parsed = MissedFileSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
46
+ if (!parsed.success)
47
+ return;
48
+ merchants = parsed.data.merchants;
49
+ }
50
+ catch (e) {
51
+ if (!(e instanceof Error && 'code' in e && e.code === 'ENOENT'))
52
+ return;
53
+ }
54
+ const seen = merchants[key];
55
+ merchants[key] = {
56
+ name: name.trim(),
57
+ count: (seen?.count ?? 0) + 1,
58
+ last_seen: today,
59
+ categories: [
60
+ ...new Set([
61
+ ...(seen?.categories ?? []),
62
+ ...(category === null ? [] : [category]),
63
+ ]),
64
+ ],
65
+ };
66
+ const kept = Object.entries(merchants)
67
+ .sort(([, a], [, b]) => b.last_seen.localeCompare(a.last_seen))
68
+ .slice(0, MAX);
69
+ await mkdir(path.dirname(file), { recursive: true });
70
+ const tmp = `${file}.${String(process.pid)}.tmp`;
71
+ await writeFile(tmp, `${JSON.stringify({ version: 1, merchants: Object.fromEntries(kept) }, null, 2)}\n`);
72
+ await rename(tmp, file);
73
+ });
74
+ queue = run.catch(() => undefined);
75
+ return run.catch(() => undefined);
76
+ },
77
+ };
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",