earnrate 0.1.4 → 0.2.1
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 +52 -17
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/categories.yaml +22 -7
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +67 -45
- package/dist/cli/cheatsheet.js +36 -35
- package/dist/cli/explore.js +47 -163
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +14 -56
- package/dist/cli/run.js +90 -20
- package/dist/cli/wallet.js +47 -6
- package/dist/data/file-store.js +95 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +92 -0
- package/dist/data/wallet-edit.js +93 -4
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +59 -4
- package/dist/engine/evaluate.js +34 -10
- package/dist/engine/explore.js +16 -28
- package/dist/engine/offers.js +8 -21
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +17 -53
- package/dist/engine/record.js +34 -4
- package/dist/engine/simulate.js +33 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +26 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +36 -15
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +19 -0
- package/dist/format/explore.js +114 -0
- package/dist/format/offers.js +33 -0
- package/dist/{cli/format.js → format/recommendation.js} +9 -11
- package/dist/format/wallet-yaml.js +45 -0
- package/dist/format/wallet.js +38 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +148 -0
- package/dist/mcp/schemas.js +8 -2
- package/dist/mcp/serve.js +3 -1
- package/dist/mcp/server.js +43 -660
- package/dist/mcp/tools/account.js +71 -0
- package/dist/mcp/tools/answers.js +329 -0
- package/dist/mcp/tools/wallet.js +128 -0
- package/dist/merchants/from-env.js +1 -1
- package/dist/merchants/resolve.js +3 -3
- package/dist/schema/catalog.js +1 -0
- package/package.json +8 -3
- package/dist/data/wallet-setup.js +0 -60
- package/dist/data/wallet-write.js +0 -140
- /package/dist/{cli → format}/cards.js +0 -0
package/README.md
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
I wanted a ghost card: one card that always charges the best of my cards. The card networks won't allow one, so I built the next best thing.
|
|
9
9
|
|
|
10
|
-
**earnrate is the best-card decision layer for people and AI agents.** Give it a merchant, and it tells you which card
|
|
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,
|
|
13
|
-
-
|
|
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).
|
|
13
|
+
- U.S. cards and U.S. merchants, for now.
|
|
14
14
|
|
|
15
|
-
> **Status:** early, and it works: the engine, CLI, optional Claude merchant lookup and
|
|
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.
|
|
16
16
|
|
|
17
17
|
## Try it
|
|
18
18
|
|
|
@@ -20,13 +20,20 @@ Needs Node 22 or later.
|
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
22
|
npm install -g earnrate
|
|
23
|
-
earnrate
|
|
23
|
+
earnrate restaurants # the best cards earnrate knows, no setup
|
|
24
|
+
earnrate whole foods 80 --cards amex-gold,chase-prime-visa # just these cards
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
You never have to enter your cards: with none, every answer covers every card earnrate knows. To have earnrate remember yours, track caps and welcome bonuses, and give a cheat sheet for your own wallet, save them:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
earnrate cards sapphire # find your cards' IDs
|
|
24
31
|
earnrate add chase-sapphire-preferred
|
|
25
32
|
earnrate add amex-gold
|
|
26
33
|
earnrate whole foods 80
|
|
27
34
|
```
|
|
28
35
|
|
|
29
|
-
Adding a card tells you anything else it needs to earn correctly, such as the categories you picked on Cash+, Amazon Prime for Prime Visa's 5%, or which rotating categories you've activated. `earnrate update` sets those. Using an AI assistant instead? [Add
|
|
36
|
+
Adding a card tells you anything else it needs to earn correctly, such as the categories you picked on Cash+, Amazon Prime for Prime Visa's 5%, or which rotating categories you've activated. `earnrate update` sets those. Using an AI assistant instead? [Add earnrate to it](#use-it-in-an-ai-assistant-mcp) and ask; name your cards or save them there.
|
|
30
37
|
|
|
31
38
|
With the [example wallet](wallet.example.yaml), it prints:
|
|
32
39
|
|
|
@@ -74,7 +81,7 @@ cards:
|
|
|
74
81
|
two-percent: [gas-ev-charging]
|
|
75
82
|
```
|
|
76
83
|
|
|
77
|
-
Rule IDs for `activated`, the choice slots for `chosen` and the membership IDs that unlock bonuses (such as `bofa-rewards-premier` for a BofA Rewards tier) are in each card's file. Currencies and their default values are in [`data/currencies.yaml`](data/currencies.yaml). Spending caps fill up as you record purchases from an AI
|
|
84
|
+
Rule IDs for `activated`, the choice slots for `chosen` and the membership IDs that unlock bonuses (such as `bofa-rewards-premier` for a BofA Rewards tier) are in each card's file. Currencies and their default values are in [`data/currencies.yaml`](data/currencies.yaml). Spending caps fill up as you record purchases from an AI assistant or import a statement with `--apply`, and reset with each period.
|
|
78
85
|
|
|
79
86
|
### Merchants earnrate doesn't know
|
|
80
87
|
|
|
@@ -87,20 +94,26 @@ earnrate ships with a curated list of merchants. For anything else it falls back
|
|
|
87
94
|
|
|
88
95
|
### Welcome bonuses
|
|
89
96
|
|
|
90
|
-
When you're working toward a card's sign-up bonus,
|
|
97
|
+
When you're working toward a card's sign-up bonus, tell earnrate when the account opened:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
earnrate update chase-sapphire-preferred --bonus-opened 2026-10-01 --bonus-spent 400
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or tell your AI assistant ("I opened my Sapphire Preferred on October 1"), or check "I'm working on this card's welcome bonus" in My wallet on earnrate.dev. `--no-bonus` stops tracking it. In the wallet file it looks like this:
|
|
91
104
|
|
|
92
105
|
```yaml
|
|
93
106
|
- id: chase-sapphire-preferred
|
|
94
107
|
bonus: { opened: 2026-10-01, spend: 5000, by: 2026-12-31, points: 75000 }
|
|
95
108
|
```
|
|
96
109
|
|
|
97
|
-
While the bonus is open, that card wins wherever it's accepted, and the answer says how much is left, the weekly pace to finish on time, what the bonus is worth at your point values, and what using it costs compared with your best earner. Spend counts up as you record purchases from an AI
|
|
110
|
+
While the bonus is open, that card wins wherever it's accepted, and the answer says how much is left, the weekly pace to finish on time, what the bonus is worth at your point values, and what using it costs compared with your best earner. Spend counts up as you record purchases (from an AI assistant or the website) or import a statement with `--apply`; refunds count down. Once it's met or the deadline passes, the card goes back to the normal ranking. With two open bonuses, the one needing more spend per day comes first.
|
|
98
111
|
|
|
99
112
|
`spend`, `by` and the reward (`points`, or `dollars` for cash back) default to the card's published offer once earnrate has one; set them when your offer was different.
|
|
100
113
|
|
|
101
114
|
### Which new card?
|
|
102
115
|
|
|
103
|
-
|
|
116
|
+
These compare every card earnrate knows:
|
|
104
117
|
|
|
105
118
|
```sh
|
|
106
119
|
earnrate best groceries # every card, ranked for one purchase
|
|
@@ -152,9 +165,17 @@ Something the format can't express yet (Bilt's rent earning, Citi Nights) is lis
|
|
|
152
165
|
|
|
153
166
|
Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/cwasley/earnrate/issues) with a link to the issuer's own page.
|
|
154
167
|
|
|
155
|
-
## Use it
|
|
168
|
+
## Use it in an AI assistant (MCP)
|
|
156
169
|
|
|
157
|
-
`earnrate
|
|
170
|
+
**In Claude, nothing to install:** add a custom connector (Settings → Connectors) with the address `https://mcp.earnrate.dev`. It works in Claude on the web, desktop and phone. Questions need no sign-in. If you ask it to save your cards, it opens an earnrate page where you start an anonymous wallet: no email, just a recovery code you keep. The wallet tracks caps and welcome bonuses across your Claude apps, and you can export it for the command line or delete it from the chat. In Claude Code:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
claude mcp add --transport http earnrate https://mcp.earnrate.dev
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Or install it as a Claude Code plugin, which connects Claude Code and Cowork to the same server: `/plugin marketplace add cwasley/earnrate`, then `/plugin install earnrate@earnrate`.
|
|
177
|
+
|
|
178
|
+
**Or run it on your computer:** `earnrate mcp` runs earnrate as a local MCP server over stdio, with 13 tools (the connector has the same ones, plus `export_wallet`, `import_wallet` and `delete_wallet` for its saved wallets):
|
|
158
179
|
|
|
159
180
|
- `best_card_for_purchase`: which card to use, with reasons
|
|
160
181
|
- `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
|
|
@@ -165,13 +186,13 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
|
|
|
165
186
|
- `record_purchase`: books a purchase against its cap and any open welcome bonus in your wallet file, keeping your comments
|
|
166
187
|
- `search_cards`, `add_card`, `update_card`, `remove_card` and `set_memberships`: set up the wallet in conversation
|
|
167
188
|
|
|
168
|
-
|
|
189
|
+
Saving cards is optional: name them in the conversation for one answer, or ask the assistant to save them, and it finds them, adds them, and asks about anything a card needs, like this quarter's Cash+ picks.
|
|
169
190
|
|
|
170
191
|
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`.
|
|
171
192
|
|
|
172
|
-
**Claude Desktop,
|
|
193
|
+
**Claude Desktop, running locally:** 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
194
|
|
|
174
|
-
To
|
|
195
|
+
To run it locally in Claude Code:
|
|
175
196
|
|
|
176
197
|
```sh
|
|
177
198
|
claude mcp add --scope user earnrate -- npx -y earnrate mcp
|
|
@@ -201,12 +222,12 @@ merchant text
|
|
|
201
222
|
|
|
202
223
|
- **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.
|
|
203
224
|
- **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.
|
|
204
|
-
- **The engine is pure and deterministic.** Given a purchase, a date and a
|
|
225
|
+
- **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.
|
|
205
226
|
- **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.
|
|
206
227
|
- **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.
|
|
207
228
|
- **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.
|
|
208
229
|
|
|
209
|
-
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.
|
|
230
|
+
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.
|
|
210
231
|
|
|
211
232
|
## Keeping card data current
|
|
212
233
|
|
|
@@ -219,8 +240,22 @@ A GitHub Action runs it every Monday and opens a "Card source changes" issue whe
|
|
|
219
240
|
|
|
220
241
|
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
242
|
|
|
243
|
+
## Privacy Policy
|
|
244
|
+
|
|
245
|
+
earnrate needs no account. The local version has no telemetry; the hosted server counts requests by tool name only. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
|
|
246
|
+
|
|
247
|
+
- **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 by tool name only. A saved wallet is optional and anonymous (a recovery code, no email), holds card IDs and the details you gave, 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.
|
|
248
|
+
- **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.
|
|
249
|
+
- **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`.
|
|
250
|
+
- **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.
|
|
251
|
+
- **earnrate.dev:** everything you enter on the site stays in your browser. The site counts visits with Cloudflare Web Analytics, without cookies.
|
|
252
|
+
- **Retention:** locally, your data lives only in files you control, for as long as you keep them. Delete the wallet file, or clear the site's data in your browser, to remove it. A saved wallet on mcp.earnrate.dev lasts until you delete it or leave it unused for 13 months.
|
|
253
|
+
- **Contact:** [hello@earnrate.dev](mailto:hello@earnrate.dev).
|
|
254
|
+
|
|
222
255
|
## Working on earnrate
|
|
223
256
|
|
|
257
|
+
Development needs Node 24 or later.
|
|
258
|
+
|
|
224
259
|
```sh
|
|
225
260
|
git clone https://github.com/cwasley/earnrate.git && cd earnrate
|
|
226
261
|
yarn install
|
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.3
|
|
6
|
+
|
|
7
|
+
More everyday words find categories, for questions asked without a store name ("coffee shop", "dinner", "train"). Each maps to the category those purchases already fall under. No rates changed.
|
|
8
|
+
|
|
9
|
+
| Category | Field | Old → new | Source |
|
|
10
|
+
| ------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------- | ------ |
|
|
11
|
+
| dining, dining.fast_food, gas, transit, travel.hotels, travel.flights, convenience_store, home_improvement, online_retail | aliases | adds everyday words | n/a |
|
|
12
|
+
|
|
5
13
|
## 2026.10.30.2
|
|
6
14
|
|
|
7
15
|
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.
|
package/data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.10.30.
|
|
1
|
+
2026.10.30.3
|
package/data/categories.yaml
CHANGED
|
@@ -5,11 +5,24 @@
|
|
|
5
5
|
|
|
6
6
|
- id: dining
|
|
7
7
|
name: Restaurants
|
|
8
|
-
aliases:
|
|
8
|
+
aliases:
|
|
9
|
+
[
|
|
10
|
+
dining,
|
|
11
|
+
restaurant,
|
|
12
|
+
food,
|
|
13
|
+
eating out,
|
|
14
|
+
takeout,
|
|
15
|
+
dinner,
|
|
16
|
+
lunch,
|
|
17
|
+
breakfast,
|
|
18
|
+
brunch,
|
|
19
|
+
food delivery,
|
|
20
|
+
]
|
|
9
21
|
mccs: [5812, 5813, 5814]
|
|
10
22
|
- id: dining.fast_food
|
|
11
23
|
name: Fast food
|
|
12
|
-
aliases:
|
|
24
|
+
aliases:
|
|
25
|
+
[fast food restaurants, coffee shop, coffee shops, coffee, cafe, cafes]
|
|
13
26
|
mccs: [5814]
|
|
14
27
|
|
|
15
28
|
- id: grocery
|
|
@@ -33,11 +46,12 @@
|
|
|
33
46
|
|
|
34
47
|
- id: convenience_store
|
|
35
48
|
name: Convenience stores
|
|
49
|
+
aliases: [convenience store, corner store, bodega]
|
|
36
50
|
mccs: [5499]
|
|
37
51
|
|
|
38
52
|
- id: gas
|
|
39
53
|
name: Gas stations
|
|
40
|
-
aliases: [gas, fuel, gas station]
|
|
54
|
+
aliases: [gas, fuel, gas station, gas stations]
|
|
41
55
|
mccs: [5541, 5542]
|
|
42
56
|
- id: gas.grocery_store
|
|
43
57
|
name: Gas at grocery stores
|
|
@@ -50,6 +64,7 @@
|
|
|
50
64
|
|
|
51
65
|
- id: transit
|
|
52
66
|
name: Transit
|
|
67
|
+
aliases: [public transit, train, trains, bus, commuting]
|
|
53
68
|
mccs: [4111, 4112, 4131, 4784, 7523]
|
|
54
69
|
- id: transit.rideshare
|
|
55
70
|
name: Rideshare and taxis
|
|
@@ -61,11 +76,11 @@
|
|
|
61
76
|
mccs: [4722]
|
|
62
77
|
- id: travel.flights
|
|
63
78
|
name: Flights
|
|
64
|
-
aliases: [flights, airfare, airlines]
|
|
79
|
+
aliases: [flights, airfare, airlines, flight, plane tickets]
|
|
65
80
|
mccs: [4511]
|
|
66
81
|
- id: travel.hotels
|
|
67
82
|
name: Hotels
|
|
68
|
-
aliases: [hotel]
|
|
83
|
+
aliases: [hotel, hotels, motel, motels]
|
|
69
84
|
mccs: [7011]
|
|
70
85
|
- id: travel.car_rental
|
|
71
86
|
name: Car rental
|
|
@@ -124,7 +139,7 @@
|
|
|
124
139
|
mccs: [5941]
|
|
125
140
|
- id: home_improvement
|
|
126
141
|
name: Home improvement
|
|
127
|
-
aliases: [hardware stores]
|
|
142
|
+
aliases: [hardware stores, hardware store, home improvement store]
|
|
128
143
|
mccs: [5200, 5211]
|
|
129
144
|
- id: furniture
|
|
130
145
|
name: Furniture stores
|
|
@@ -132,7 +147,7 @@
|
|
|
132
147
|
|
|
133
148
|
- id: online_retail
|
|
134
149
|
name: Online retail
|
|
135
|
-
aliases: [online shopping, shopping online]
|
|
150
|
+
aliases: [online shopping, shopping online, online]
|
|
136
151
|
- id: office_supply
|
|
137
152
|
name: Office supply stores
|
|
138
153
|
- id: shipping
|
package/dist/brand.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
export const PRODUCT_NAME = 'earnrate';
|
|
1
|
+
// Names users type or set. package.json's `name` and `bin` must match
|
|
2
|
+
// CLI_NAME by hand, since JSON can't import this.
|
|
4
3
|
export const CLI_NAME = 'earnrate';
|
|
5
4
|
export const MCP_SERVER_NAME = 'earnrate';
|
|
6
5
|
export const CONFIG_DIR = '.earnrate'; // under the home directory
|
package/dist/cli/args.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { isValuationProfile, } from "../engine/profiles.js";
|
|
3
|
+
import { knownMemberships } from "../engine/setup.js";
|
|
4
|
+
// Shared by every subcommand, so each flag means the same thing everywhere.
|
|
5
|
+
// A string result is the error to show.
|
|
3
6
|
const LOOKS_LIKE_AMOUNT = /^\$?[\d,]*\.?\d+$/;
|
|
4
7
|
const AMOUNT = /^\$?(\d{1,3}(,\d{3})+|\d+)(\.\d{1,2})?$/;
|
|
5
8
|
function parseAmount(text) {
|
|
@@ -9,11 +12,54 @@ function parseAmount(text) {
|
|
|
9
12
|
const amount = Number(text.replace(/[$,]/g, ''));
|
|
10
13
|
return amount > 0 ? amount : 'The amount must be more than $0.';
|
|
11
14
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
// A trailing number is the amount only when something comes before it, so
|
|
16
|
+
// a merchant named "76" still works on its own.
|
|
17
|
+
export function splitAmount(positionals) {
|
|
18
|
+
const words = [...positionals];
|
|
19
|
+
const last = words.at(-1);
|
|
20
|
+
let amount = null;
|
|
21
|
+
if (words.length > 1 && last !== undefined && LOOKS_LIKE_AMOUNT.test(last)) {
|
|
22
|
+
const result = parseAmount(last);
|
|
23
|
+
if (typeof result === 'string')
|
|
24
|
+
return result;
|
|
25
|
+
amount = result;
|
|
26
|
+
words.pop();
|
|
27
|
+
}
|
|
28
|
+
return { text: words.join(' ').trim(), amount };
|
|
29
|
+
}
|
|
30
|
+
export function dateProblem(text) {
|
|
31
|
+
if (text === undefined)
|
|
32
|
+
return null;
|
|
15
33
|
const date = new Date(`${text}T00:00:00Z`);
|
|
16
|
-
return
|
|
34
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(text) &&
|
|
35
|
+
!Number.isNaN(date.getTime()) &&
|
|
36
|
+
date.toISOString().startsWith(text)
|
|
37
|
+
? null
|
|
38
|
+
: `"${text}" isn't a date. Use YYYY-MM-DD.`;
|
|
39
|
+
}
|
|
40
|
+
export function valuationProblem(text) {
|
|
41
|
+
return text === undefined || isValuationProfile(text)
|
|
42
|
+
? null
|
|
43
|
+
: `Unknown valuation "${text}". Use flat or points-heavy.`;
|
|
44
|
+
}
|
|
45
|
+
export function membershipProblem(catalog, members) {
|
|
46
|
+
const known = knownMemberships(catalog);
|
|
47
|
+
const unknown = members.filter((m) => !known.includes(m));
|
|
48
|
+
return unknown.length === 0
|
|
49
|
+
? null
|
|
50
|
+
: `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
|
|
51
|
+
}
|
|
52
|
+
export const BOTH_CHANNELS = 'Pick --online or --in-store, not both.';
|
|
53
|
+
export function channelOf(values) {
|
|
54
|
+
return values.online ? 'online' : values['in-store'] ? 'in_store' : null;
|
|
55
|
+
}
|
|
56
|
+
export function cardList(text) {
|
|
57
|
+
return text === undefined
|
|
58
|
+
? null
|
|
59
|
+
: text
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((c) => c.trim())
|
|
62
|
+
.filter((c) => c !== '');
|
|
17
63
|
}
|
|
18
64
|
export function parseCliArgs(argv) {
|
|
19
65
|
let parsed;
|
|
@@ -32,6 +78,8 @@ export function parseCliArgs(argv) {
|
|
|
32
78
|
category: { type: 'string' },
|
|
33
79
|
all: { type: 'boolean' },
|
|
34
80
|
llm: { type: 'boolean' },
|
|
81
|
+
cards: { type: 'string' },
|
|
82
|
+
member: { type: 'string', multiple: true },
|
|
35
83
|
help: { type: 'boolean', short: 'h' },
|
|
36
84
|
},
|
|
37
85
|
});
|
|
@@ -45,49 +93,21 @@ export function parseCliArgs(argv) {
|
|
|
45
93
|
const { values, positionals } = parsed;
|
|
46
94
|
if (values.help)
|
|
47
95
|
return { kind: 'help' };
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const last = words.at(-1);
|
|
53
|
-
if (words.length > 1 && last !== undefined && LOOKS_LIKE_AMOUNT.test(last)) {
|
|
54
|
-
const result = parseAmount(last);
|
|
55
|
-
if (typeof result === 'string')
|
|
56
|
-
return { kind: 'error', message: result };
|
|
57
|
-
amount = result;
|
|
58
|
-
words.pop();
|
|
59
|
-
}
|
|
60
|
-
const merchant = words.join(' ').trim();
|
|
96
|
+
const split = splitAmount(positionals);
|
|
97
|
+
if (typeof split === 'string')
|
|
98
|
+
return { kind: 'error', message: split };
|
|
99
|
+
const { text: merchant, amount } = split;
|
|
61
100
|
if (merchant === '') {
|
|
62
101
|
return { kind: 'error', message: 'Name a merchant, like "whole foods".' };
|
|
63
102
|
}
|
|
64
|
-
if (values.online && values['in-store'])
|
|
65
|
-
return {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
: values['in-store']
|
|
73
|
-
? 'in_store'
|
|
74
|
-
: null;
|
|
75
|
-
let valuation = null;
|
|
76
|
-
if (values.valuation !== undefined) {
|
|
77
|
-
if (!isValuationProfile(values.valuation)) {
|
|
78
|
-
return {
|
|
79
|
-
kind: 'error',
|
|
80
|
-
message: `Unknown valuation "${values.valuation}". Use flat or points-heavy.`,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
valuation = values.valuation;
|
|
84
|
-
}
|
|
85
|
-
if (values.date !== undefined && !isValidDate(values.date)) {
|
|
86
|
-
return {
|
|
87
|
-
kind: 'error',
|
|
88
|
-
message: `"${values.date}" isn't a date. Use YYYY-MM-DD.`,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
103
|
+
if (values.online && values['in-store'])
|
|
104
|
+
return { kind: 'error', message: BOTH_CHANNELS };
|
|
105
|
+
const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
|
|
106
|
+
if (problem !== null)
|
|
107
|
+
return { kind: 'error', message: problem };
|
|
108
|
+
const valuation = values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
109
|
+
? values.valuation
|
|
110
|
+
: null;
|
|
91
111
|
const country = values.country ?? 'US';
|
|
92
112
|
if (!/^[A-Za-z]{2}$/.test(country)) {
|
|
93
113
|
return {
|
|
@@ -100,7 +120,7 @@ export function parseCliArgs(argv) {
|
|
|
100
120
|
options: {
|
|
101
121
|
merchant,
|
|
102
122
|
amount,
|
|
103
|
-
channel,
|
|
123
|
+
channel: channelOf(values),
|
|
104
124
|
valuation,
|
|
105
125
|
date: values.date ?? null,
|
|
106
126
|
country: country.toUpperCase(),
|
|
@@ -108,6 +128,8 @@ export function parseCliArgs(argv) {
|
|
|
108
128
|
all: values.all ?? false,
|
|
109
129
|
category: values.category ?? null,
|
|
110
130
|
llm: values.llm ?? null,
|
|
131
|
+
cards: cardList(values.cards),
|
|
132
|
+
members: values.member ?? [],
|
|
111
133
|
},
|
|
112
134
|
};
|
|
113
135
|
}
|
package/dist/cli/cheatsheet.js
CHANGED
|
@@ -1,35 +1,26 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
|
-
import { walletProblems } from "../data/integrity.js";
|
|
4
3
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
5
|
-
import { cheatSheet } from "../engine/cheat-sheet.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
4
|
+
import { catalogCheatSheet, cheatSheet } from "../engine/cheat-sheet.js";
|
|
5
|
+
import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
6
|
+
import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
7
|
+
import { formatCheatSheet } from "../format/cheat-sheet.js";
|
|
8
|
+
import { cardList, dateProblem, valuationProblem } from "./args.js";
|
|
9
|
+
import { cardsToUse, NO_WALLET_HINT, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
10
|
+
const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
|
|
9
11
|
|
|
10
|
-
The card
|
|
11
|
-
|
|
12
|
+
The card to use for each everyday kind of purchase this quarter: from your
|
|
13
|
+
wallet (with your caps, picks and activations as they are now), from cards you
|
|
14
|
+
name with --cards, or else from every card earnrate knows.
|
|
12
15
|
|
|
13
16
|
Options:
|
|
17
|
+
--cards <ids> Use these cards instead of your wallet, comma separated.
|
|
18
|
+
--member <name> A membership you have, like amazon-prime. Repeatable.
|
|
14
19
|
--valuation <profile> flat or points-heavy, instead of your wallet's values.
|
|
15
20
|
--date <YYYY-MM-DD> The day to check. Default: today.
|
|
16
21
|
--json Print the cheat sheet as JSON.
|
|
17
22
|
-h, --help Show this help.
|
|
18
23
|
`;
|
|
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
24
|
export async function runCheatSheet(argv, io) {
|
|
34
25
|
let parsed;
|
|
35
26
|
try {
|
|
@@ -38,6 +29,8 @@ export async function runCheatSheet(argv, io) {
|
|
|
38
29
|
options: {
|
|
39
30
|
valuation: { type: 'string' },
|
|
40
31
|
date: { type: 'string' },
|
|
32
|
+
cards: { type: 'string' },
|
|
33
|
+
member: { type: 'string', multiple: true },
|
|
41
34
|
json: { type: 'boolean' },
|
|
42
35
|
help: { type: 'boolean', short: 'h' },
|
|
43
36
|
},
|
|
@@ -52,28 +45,36 @@ export async function runCheatSheet(argv, io) {
|
|
|
52
45
|
io.stdout(CHEATSHEET_HELP);
|
|
53
46
|
return 0;
|
|
54
47
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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`);
|
|
48
|
+
const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
|
|
49
|
+
if (problem !== null) {
|
|
50
|
+
io.stderr(`${problem}\n`);
|
|
61
51
|
return USAGE_ERROR;
|
|
62
52
|
}
|
|
63
|
-
const file = walletPath(io.env);
|
|
64
|
-
const loaded = await readWallet(file, io);
|
|
65
|
-
if (!loaded)
|
|
66
|
-
return RUNTIME_ERROR;
|
|
67
53
|
const catalog = await loadCatalog();
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
54
|
+
const loaded = await cardsToUse(catalog, walletPath(io.env), io, {
|
|
55
|
+
cards: cardList(values.cards),
|
|
56
|
+
members: values.member ?? [],
|
|
57
|
+
});
|
|
58
|
+
if (loaded === null)
|
|
71
59
|
return RUNTIME_ERROR;
|
|
60
|
+
const date = values.date ?? io.today;
|
|
61
|
+
if (loaded === 'none') {
|
|
62
|
+
const sheet = catalogCheatSheet(catalog, date, {
|
|
63
|
+
filters: ALL_PERSONAL_CARDS,
|
|
64
|
+
memberships: values.member ?? [],
|
|
65
|
+
valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
66
|
+
? VALUATION_PROFILES[values.valuation]
|
|
67
|
+
: {},
|
|
68
|
+
});
|
|
69
|
+
io.stdout(values.json
|
|
70
|
+
? `${JSON.stringify(sheet, null, 2)}\n`
|
|
71
|
+
: `${formatCheatSheet(sheet)}\n${NO_WALLET_HINT}\n`);
|
|
72
|
+
return 0;
|
|
72
73
|
}
|
|
73
74
|
const wallet = values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
74
75
|
? withValuationProfile(loaded, values.valuation)
|
|
75
76
|
: loaded;
|
|
76
|
-
const sheet = cheatSheet(catalog, wallet,
|
|
77
|
+
const sheet = cheatSheet(catalog, wallet, date);
|
|
77
78
|
io.stdout(values.json
|
|
78
79
|
? `${JSON.stringify(sheet, null, 2)}\n`
|
|
79
80
|
: formatCheatSheet(sheet));
|