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 +11 -7
- package/data/CHANGELOG.md +16 -0
- package/data/VERSION +1 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +0 -1
- package/data/cards/chase-freedom-flex.yaml +0 -1
- package/dist/brand.js +1 -0
- package/dist/cli/missed.js +65 -0
- package/dist/cli/run.js +15 -0
- package/dist/cli/todo.js +62 -0
- package/dist/cli/wallet.js +1 -1
- package/dist/engine/cheat-sheet.js +10 -1
- package/dist/engine/checklist.js +152 -0
- package/dist/engine/evaluate.js +14 -7
- package/dist/engine/explore.js +2 -2
- package/dist/engine/recommend.js +7 -5
- package/dist/engine/rule-text.js +47 -0
- package/dist/engine/wallet-summary.js +39 -60
- package/dist/format/wallet.js +6 -0
- package/dist/mcp/context.js +8 -4
- package/dist/mcp/schemas.js +8 -0
- package/dist/mcp/serve.js +4 -0
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools/wallet.js +10 -5
- package/dist/merchants/missed.js +78 -0
- package/package.json +1 -1
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
|
|
46
|
+
6% cash back on supermarkets. ...
|
|
47
47
|
2. Prime Visa 5% $4.00
|
|
48
|
-
5% cash back
|
|
48
|
+
5% cash back on Whole Foods Market, with Amazon Prime. ...
|
|
49
49
|
3. Amex Gold 4% $3.20
|
|
50
|
-
4x Amex Membership Rewards
|
|
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.
|
|
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.
|
|
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, {
|
package/dist/cli/todo.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cli/wallet.js
CHANGED
|
@@ -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
|
-
...
|
|
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
|
+
}
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -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) *
|
|
155
|
+
return (inCap * rate + (amount - inCap) * afterCap) / amount;
|
|
156
156
|
}
|
|
157
|
-
return cap.remaining > 0 ? 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
|
-
|
|
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;
|
package/dist/engine/explore.js
CHANGED
|
@@ -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 "./
|
|
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,
|
package/dist/engine/recommend.js
CHANGED
|
@@ -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)}
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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)
|
package/dist/format/wallet.js
CHANGED
|
@@ -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
|
package/dist/mcp/context.js
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -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
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -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
|
|
7
|
-
import {
|
|
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: `
|
|
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
|
-
...
|
|
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
|
+
}
|