earnrate 0.1.2 → 0.1.4
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 +22 -4
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/cards/citi-custom-cash.yaml +4 -3
- package/data/categories.yaml +24 -0
- package/dist/cli/cheatsheet.js +81 -0
- package/dist/cli/explore.js +364 -0
- package/dist/cli/run.js +13 -0
- package/dist/data/wallet-setup.js +23 -165
- package/dist/engine/cheat-sheet.js +48 -0
- package/dist/engine/explore.js +292 -0
- package/dist/engine/recommend.js +7 -7
- package/dist/engine/setup.js +1 -1
- package/dist/engine/simulate.js +309 -0
- package/dist/engine/wallet-change.js +179 -0
- package/dist/mcp/schemas.js +83 -0
- package/dist/mcp/server.js +219 -17
- package/dist/schema/card.js +2 -0
- package/dist/schema/category.js +2 -0
- package/package.json +9 -3
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 in your wallet earns the most there, and why. It's stateful (it knows your caps and chosen categories), valuation-aware, and transparent about its assumptions. It never sees a card number.
|
|
11
11
|
|
|
12
|
-
- Runs on your machine. No account, no telemetry, no affiliate links. See the [privacy page](https://earnrate.dev/privacy).
|
|
12
|
+
- Runs on your machine, or in your browser at [earnrate.dev](https://earnrate.dev) with nothing to install. No account, no telemetry, no affiliate links. See the [privacy page](https://earnrate.dev/privacy).
|
|
13
13
|
- US cards and US merchants, for now.
|
|
14
14
|
|
|
15
15
|
> **Status:** early, and it works: the engine, CLI, optional Claude merchant lookup and MCP server. See [the benchmark](docs/benchmark.md) for how it compares to an existing card-picking MCP server.
|
|
@@ -51,6 +51,8 @@ Assumes Whole Foods Market codes as supermarkets (MCC 5411).
|
|
|
51
51
|
|
|
52
52
|
Options: `--online` or `--in-store`, `--valuation flat|points-heavy`, `--all`, `--json`, `--date`, `--country`. Run `earnrate --help` for details. To keep the wallet somewhere else, set `EARNRATE_WALLET` to its path.
|
|
53
53
|
|
|
54
|
+
`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. It's also in the My wallet tab on [earnrate.dev](https://earnrate.dev).
|
|
55
|
+
|
|
54
56
|
### Your wallet
|
|
55
57
|
|
|
56
58
|
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` and `memberships` write it for you and keep your comments, or you can edit it by hand. Only the cards are required:
|
|
@@ -98,6 +100,16 @@ While the bonus is open, that card wins wherever it's accepted, and the answer s
|
|
|
98
100
|
|
|
99
101
|
### Which new card?
|
|
100
102
|
|
|
103
|
+
No wallet needed for these:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
earnrate best groceries # every card, ranked for one purchase
|
|
107
|
+
earnrate simulate groceries=600 dining=300 other=1000 # best cards and pairs for a year
|
|
108
|
+
earnrate simulate groceries=600 other=1000 --wallet # what a new card would add to yours
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`best` ranks every card earnrate knows for a merchant or a category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Rates that need a membership like Amazon Prime are listed separately unless you pass `--member amazon-prime`. `simulate` spends your monthly amounts for a year, applying caps as they fill and reset, and ranks single cards and two-card pairs by rewards minus annual fees. Both take `--no-fee`, `--cash` or `--points`, `--issuer`, `--business` and `--valuation`. Welcome bonuses are left out of both; for those:
|
|
112
|
+
|
|
101
113
|
```sh
|
|
102
114
|
earnrate offers --cash
|
|
103
115
|
```
|
|
@@ -142,11 +154,13 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
|
|
|
142
154
|
|
|
143
155
|
## Use it from an AI agent (MCP)
|
|
144
156
|
|
|
145
|
-
`earnrate mcp` runs earnrate as a local MCP server over stdio, with
|
|
157
|
+
`earnrate mcp` runs earnrate as a local MCP server over stdio, with thirteen tools:
|
|
146
158
|
|
|
147
159
|
- `best_card_for_purchase`: which card to use, with reasons
|
|
160
|
+
- `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
|
|
148
161
|
- `resolve_merchant`: how a merchant was identified
|
|
149
162
|
- `list_wallet`: your cards, caps left this period, chosen categories and welcome-bonus progress
|
|
163
|
+
- `cheat_sheet`: the card to use for each everyday kind of purchase this quarter
|
|
150
164
|
- `compare_welcome_offers`: the best public sign-up offers on cards you don't have
|
|
151
165
|
- `record_purchase`: books a purchase against its cap and any open welcome bonus in your wallet file, keeping your comments
|
|
152
166
|
- `search_cards`, `add_card`, `update_card`, `remove_card` and `set_memberships`: set up the wallet in conversation
|
|
@@ -155,6 +169,8 @@ There's no wallet to write first: tell the assistant which cards you have, and i
|
|
|
155
169
|
|
|
156
170
|
For a merchant earnrate doesn't know, the calling model can pass a `category` (e.g. `grocery.supermarket`). It's used only for unknown merchants and is labeled in the answer. The command line takes the same thing as `--category`.
|
|
157
171
|
|
|
172
|
+
**Claude Desktop, one click:** download [earnrate.mcpb](https://github.com/cwasley/earnrate/releases/latest/download/earnrate.mcpb) and open it; Claude Desktop installs it (Settings → Extensions). No terminal or Node install needed. Its one setting is where your wallet file lives, by default `~/.earnrate/wallet.yaml`, the same file the command line uses.
|
|
173
|
+
|
|
158
174
|
To add it to Claude Code:
|
|
159
175
|
|
|
160
176
|
```sh
|
|
@@ -185,8 +201,8 @@ merchant text
|
|
|
185
201
|
|
|
186
202
|
- **Card terms are data, not code.** Each card is a YAML file of rules (category, rate, cap, channel, dates, activation, required membership), validated with Zod. Every rule carries a short quote from the issuer's own page, and a test fails if one is missing. The engine has no card-specific branches.
|
|
187
203
|
- **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.
|
|
188
|
-
- **The engine is pure and deterministic.** Given a purchase, a date and a wallet, 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, and
|
|
189
|
-
- **Wallet edits keep your file intact.**
|
|
204
|
+
- **The engine is pure and deterministic.** Given a purchase, a date and a wallet, 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.
|
|
205
|
+
- **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.
|
|
190
206
|
- **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.
|
|
191
207
|
- **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.
|
|
192
208
|
|
|
@@ -201,6 +217,8 @@ Every rule in `data/cards/` carries a short quote from the issuer's own page. In
|
|
|
201
217
|
|
|
202
218
|
A GitHub Action runs it every Monday and opens a "Card source changes" issue when something moved. After updating a card file, run `yarn check-sources --update` to record the new baseline. Some issuers block headless browsers on shared servers; those pages are listed as "couldn't check", and running the command locally covers them. The first run needs `yarn playwright install chromium`.
|
|
203
219
|
|
|
220
|
+
Rotating categories (Freedom Flex) are added each quarter once the issuer posts them. Until then, answers say that quarter's categories are missing rather than guessing.
|
|
221
|
+
|
|
204
222
|
## Working on earnrate
|
|
205
223
|
|
|
206
224
|
```sh
|
package/data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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.10.30.2
|
|
6
|
+
|
|
7
|
+
Cards record when the issuer stopped taking applications, so "best card for" lists can leave out cards nobody can get. Categories gain `aliases`, the everyday words people search by ("groceries" finds supermarkets). No rates changed.
|
|
8
|
+
|
|
9
|
+
| Card | Field | Old → new | Source |
|
|
10
|
+
| ---------------- | ------------------- | ---------- | --------------------------------------------------------------------------- |
|
|
11
|
+
| citi-custom-cash | applications_closed | 2026-05-28 | [card page](https://www.citi.com/credit-cards/citi-custom-cash-credit-card) |
|
|
12
|
+
|
|
5
13
|
## 2026.10.30.1
|
|
6
14
|
|
|
7
15
|
Freedom Flex's quarterly cap is marked `rotating`, so a quarter whose categories aren't in the data yet gets a warning instead of quietly ranking Flex at its everyday rates. Capital One's issuer id is now spelled the same on every card. No rates changed.
|
package/data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.10.30.
|
|
1
|
+
2026.10.30.2
|
|
@@ -5,9 +5,10 @@ network: mastercard
|
|
|
5
5
|
currency: cash
|
|
6
6
|
last_verified: 2026-10-26
|
|
7
7
|
verified_by: collin
|
|
8
|
-
|
|
9
|
-
# the card and its rewards. The card page no longer
|
|
10
|
-
# the definitions come from the card's current
|
|
8
|
+
applications_closed: 2026-05-28
|
|
9
|
+
# Existing cardmembers keep the card and its rewards. The card page no longer
|
|
10
|
+
# lists the categories, so the definitions come from the card's current
|
|
11
|
+
# ThankYou terms (Sept 2026).
|
|
11
12
|
source_urls:
|
|
12
13
|
- https://www.citi.com/credit-cards/citi-custom-cash-credit-card
|
|
13
14
|
- https://www.thankyou.com/ccterms
|
package/data/categories.yaml
CHANGED
|
@@ -5,24 +5,30 @@
|
|
|
5
5
|
|
|
6
6
|
- id: dining
|
|
7
7
|
name: Restaurants
|
|
8
|
+
aliases: [dining, restaurant, food, eating out, takeout]
|
|
8
9
|
mccs: [5812, 5813, 5814]
|
|
9
10
|
- id: dining.fast_food
|
|
10
11
|
name: Fast food
|
|
12
|
+
aliases: [fast food restaurants]
|
|
11
13
|
mccs: [5814]
|
|
12
14
|
|
|
13
15
|
- id: grocery
|
|
14
16
|
name: Grocery
|
|
15
17
|
- id: grocery.supermarket
|
|
16
18
|
name: Supermarkets
|
|
19
|
+
aliases: [groceries, grocery, grocery stores, supermarket]
|
|
17
20
|
mccs: [5411]
|
|
18
21
|
- id: grocery.online
|
|
19
22
|
name: Online grocery and delivery
|
|
23
|
+
aliases: [grocery delivery]
|
|
20
24
|
|
|
21
25
|
- id: superstore
|
|
22
26
|
name: Superstores
|
|
27
|
+
aliases: [superstore, big box stores]
|
|
23
28
|
mccs: [5310]
|
|
24
29
|
- id: warehouse_club
|
|
25
30
|
name: Warehouse clubs
|
|
31
|
+
aliases: [warehouse club, wholesale clubs]
|
|
26
32
|
mccs: [5300]
|
|
27
33
|
|
|
28
34
|
- id: convenience_store
|
|
@@ -31,6 +37,7 @@
|
|
|
31
37
|
|
|
32
38
|
- id: gas
|
|
33
39
|
name: Gas stations
|
|
40
|
+
aliases: [gas, fuel, gas station]
|
|
34
41
|
mccs: [5541, 5542]
|
|
35
42
|
- id: gas.grocery_store
|
|
36
43
|
name: Gas at grocery stores
|
|
@@ -38,6 +45,7 @@
|
|
|
38
45
|
name: Gas at superstores and warehouse clubs
|
|
39
46
|
- id: ev_charging
|
|
40
47
|
name: EV charging
|
|
48
|
+
aliases: [ev, charging]
|
|
41
49
|
mccs: [5552]
|
|
42
50
|
|
|
43
51
|
- id: transit
|
|
@@ -45,6 +53,7 @@
|
|
|
45
53
|
mccs: [4111, 4112, 4131, 4784, 7523]
|
|
46
54
|
- id: transit.rideshare
|
|
47
55
|
name: Rideshare and taxis
|
|
56
|
+
aliases: [rideshare, taxi, taxis]
|
|
48
57
|
mccs: [4121]
|
|
49
58
|
|
|
50
59
|
- id: travel
|
|
@@ -52,41 +61,53 @@
|
|
|
52
61
|
mccs: [4722]
|
|
53
62
|
- id: travel.flights
|
|
54
63
|
name: Flights
|
|
64
|
+
aliases: [flights, airfare, airlines]
|
|
55
65
|
mccs: [4511]
|
|
56
66
|
- id: travel.hotels
|
|
57
67
|
name: Hotels
|
|
68
|
+
aliases: [hotel]
|
|
58
69
|
mccs: [7011]
|
|
59
70
|
- id: travel.car_rental
|
|
60
71
|
name: Car rental
|
|
72
|
+
aliases: [car rentals, rental cars]
|
|
61
73
|
mccs: [7512]
|
|
62
74
|
- id: travel.cruise
|
|
63
75
|
name: Cruises
|
|
76
|
+
aliases: [cruise]
|
|
64
77
|
mccs: [4411]
|
|
65
78
|
|
|
66
79
|
- id: streaming
|
|
67
80
|
name: Streaming services
|
|
81
|
+
aliases: [streaming, subscriptions]
|
|
68
82
|
mccs: [4899, 5815]
|
|
69
83
|
- id: tv_internet
|
|
70
84
|
name: TV and internet service
|
|
85
|
+
aliases: [internet, cable]
|
|
71
86
|
mccs: [4899]
|
|
72
87
|
- id: phone
|
|
73
88
|
name: Cell phone service
|
|
89
|
+
aliases: [phone, cell phone, wireless]
|
|
74
90
|
mccs: [4814]
|
|
75
91
|
- id: utilities
|
|
76
92
|
name: Home utilities
|
|
93
|
+
aliases: [utilities, electric, electricity]
|
|
77
94
|
mccs: [4900]
|
|
78
95
|
|
|
79
96
|
- id: drugstore
|
|
80
97
|
name: Drugstores and pharmacies
|
|
98
|
+
aliases: [drugstores, pharmacy, pharmacies]
|
|
81
99
|
mccs: [5912]
|
|
82
100
|
- id: movie_theaters
|
|
83
101
|
name: Movie theaters
|
|
102
|
+
aliases: [movies, movie theater]
|
|
84
103
|
mccs: [7832]
|
|
85
104
|
- id: entertainment
|
|
86
105
|
name: Live entertainment and events
|
|
106
|
+
aliases: [concerts, events, tickets]
|
|
87
107
|
mccs: [7922, 7941]
|
|
88
108
|
- id: fitness
|
|
89
109
|
name: Gyms and fitness
|
|
110
|
+
aliases: [gym, gyms, fitness]
|
|
90
111
|
mccs: [7997]
|
|
91
112
|
|
|
92
113
|
- id: department_store
|
|
@@ -103,6 +124,7 @@
|
|
|
103
124
|
mccs: [5941]
|
|
104
125
|
- id: home_improvement
|
|
105
126
|
name: Home improvement
|
|
127
|
+
aliases: [hardware stores]
|
|
106
128
|
mccs: [5200, 5211]
|
|
107
129
|
- id: furniture
|
|
108
130
|
name: Furniture stores
|
|
@@ -110,6 +132,7 @@
|
|
|
110
132
|
|
|
111
133
|
- id: online_retail
|
|
112
134
|
name: Online retail
|
|
135
|
+
aliases: [online shopping, shopping online]
|
|
113
136
|
- id: office_supply
|
|
114
137
|
name: Office supply stores
|
|
115
138
|
- id: shipping
|
|
@@ -119,3 +142,4 @@
|
|
|
119
142
|
|
|
120
143
|
- id: other
|
|
121
144
|
name: Everything else
|
|
145
|
+
aliases: [everything, everything else, other]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { CLI_NAME } from "../brand.js";
|
|
3
|
+
import { walletProblems } from "../data/integrity.js";
|
|
4
|
+
import { loadCatalog, walletPath } from "../data/load.js";
|
|
5
|
+
import { cheatSheet } from "../engine/cheat-sheet.js";
|
|
6
|
+
import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
|
|
7
|
+
import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
8
|
+
export const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
|
|
9
|
+
|
|
10
|
+
The card in your wallet to use for each everyday kind of purchase this
|
|
11
|
+
quarter, with your caps, picks and activations as they are now.
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--valuation <profile> flat or points-heavy, instead of your wallet's values.
|
|
15
|
+
--date <YYYY-MM-DD> The day to check. Default: today.
|
|
16
|
+
--json Print the cheat sheet as JSON.
|
|
17
|
+
-h, --help Show this help.
|
|
18
|
+
`;
|
|
19
|
+
const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
|
|
20
|
+
export function formatCheatSheet(sheet) {
|
|
21
|
+
const labelWidth = Math.max(...sheet.rows.map((r) => r.label.length));
|
|
22
|
+
const cardWidth = Math.max(...sheet.rows.map((r) => (r.cardName ?? 'none of your cards').length));
|
|
23
|
+
const lines = [`Your cheat sheet for ${sheet.quarter}`, ''];
|
|
24
|
+
for (const row of sheet.rows) {
|
|
25
|
+
lines.push(` ${row.label.padEnd(labelWidth)} ${(row.cardName ?? 'none of your cards').padEnd(cardWidth)} ${row.effectiveReturnPct === null ? '' : pct(row.effectiveReturnPct).padStart(5)}`.trimEnd());
|
|
26
|
+
if (row.note)
|
|
27
|
+
lines.push(` ${' '.repeat(labelWidth)} ! ${row.note}`);
|
|
28
|
+
}
|
|
29
|
+
if (sheet.notes.length > 0)
|
|
30
|
+
lines.push('', ...sheet.notes);
|
|
31
|
+
return `${lines.join('\n')}\n`;
|
|
32
|
+
}
|
|
33
|
+
export async function runCheatSheet(argv, io) {
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = parseArgs({
|
|
37
|
+
args: argv,
|
|
38
|
+
options: {
|
|
39
|
+
valuation: { type: 'string' },
|
|
40
|
+
date: { type: 'string' },
|
|
41
|
+
json: { type: 'boolean' },
|
|
42
|
+
help: { type: 'boolean', short: 'h' },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
io.stderr(`${e instanceof Error ? e.message : String(e)}\n${CHEATSHEET_HELP}`);
|
|
48
|
+
return USAGE_ERROR;
|
|
49
|
+
}
|
|
50
|
+
const { values } = parsed;
|
|
51
|
+
if (values.help) {
|
|
52
|
+
io.stdout(CHEATSHEET_HELP);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (values.valuation !== undefined && !isValuationProfile(values.valuation)) {
|
|
56
|
+
io.stderr(`Unknown valuation "${values.valuation}". Use flat or points-heavy.\n`);
|
|
57
|
+
return USAGE_ERROR;
|
|
58
|
+
}
|
|
59
|
+
if (values.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(values.date)) {
|
|
60
|
+
io.stderr(`"${values.date}" isn't a date. Use YYYY-MM-DD.\n`);
|
|
61
|
+
return USAGE_ERROR;
|
|
62
|
+
}
|
|
63
|
+
const file = walletPath(io.env);
|
|
64
|
+
const loaded = await readWallet(file, io);
|
|
65
|
+
if (!loaded)
|
|
66
|
+
return RUNTIME_ERROR;
|
|
67
|
+
const catalog = await loadCatalog();
|
|
68
|
+
const problems = walletProblems(loaded, catalog);
|
|
69
|
+
if (problems.length > 0) {
|
|
70
|
+
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
71
|
+
return RUNTIME_ERROR;
|
|
72
|
+
}
|
|
73
|
+
const wallet = values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
74
|
+
? withValuationProfile(loaded, values.valuation)
|
|
75
|
+
: loaded;
|
|
76
|
+
const sheet = cheatSheet(catalog, wallet, values.date ?? io.today);
|
|
77
|
+
io.stdout(values.json
|
|
78
|
+
? `${JSON.stringify(sheet, null, 2)}\n`
|
|
79
|
+
: formatCheatSheet(sheet));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|