earnrate 0.1.2 → 0.1.3
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 +17 -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/explore.js +364 -0
- package/dist/cli/run.js +9 -0
- package/dist/data/wallet-setup.js +23 -165
- 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 +70 -0
- package/dist/mcp/server.js +155 -5
- package/dist/schema/card.js +2 -0
- package/dist/schema/category.js +2 -0
- package/package.json +6 -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.
|
|
@@ -98,6 +98,16 @@ While the bonus is open, that card wins wherever it's accepted, and the answer s
|
|
|
98
98
|
|
|
99
99
|
### Which new card?
|
|
100
100
|
|
|
101
|
+
No wallet needed for these:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
earnrate best groceries # every card, ranked for one purchase
|
|
105
|
+
earnrate simulate groceries=600 dining=300 other=1000 # best cards and pairs for a year
|
|
106
|
+
earnrate simulate groceries=600 other=1000 --wallet # what a new card would add to yours
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`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:
|
|
110
|
+
|
|
101
111
|
```sh
|
|
102
112
|
earnrate offers --cash
|
|
103
113
|
```
|
|
@@ -142,9 +152,10 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
|
|
|
142
152
|
|
|
143
153
|
## Use it from an AI agent (MCP)
|
|
144
154
|
|
|
145
|
-
`earnrate mcp` runs earnrate as a local MCP server over stdio, with
|
|
155
|
+
`earnrate mcp` runs earnrate as a local MCP server over stdio, with twelve tools:
|
|
146
156
|
|
|
147
157
|
- `best_card_for_purchase`: which card to use, with reasons
|
|
158
|
+
- `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
|
|
148
159
|
- `resolve_merchant`: how a merchant was identified
|
|
149
160
|
- `list_wallet`: your cards, caps left this period, chosen categories and welcome-bonus progress
|
|
150
161
|
- `compare_welcome_offers`: the best public sign-up offers on cards you don't have
|
|
@@ -185,8 +196,8 @@ merchant text
|
|
|
185
196
|
|
|
186
197
|
- **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
198
|
- **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.**
|
|
199
|
+
- **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.
|
|
200
|
+
- **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
201
|
- **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
202
|
- **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
203
|
|
|
@@ -201,6 +212,8 @@ Every rule in `data/cards/` carries a short quote from the issuer's own page. In
|
|
|
201
212
|
|
|
202
213
|
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
214
|
|
|
215
|
+
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.
|
|
216
|
+
|
|
204
217
|
## Working on earnrate
|
|
205
218
|
|
|
206
219
|
```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,364 @@
|
|
|
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 { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
|
|
6
|
+
import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
7
|
+
import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
|
|
8
|
+
import { knownMemberships } from "../engine/setup.js";
|
|
9
|
+
import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
10
|
+
const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
|
|
11
|
+
--no-fee Cards with no annual fee.
|
|
12
|
+
--issuer <id> One issuer's cards, e.g. chase or amex.
|
|
13
|
+
--business Business cards instead of personal ones.
|
|
14
|
+
--closed Include cards the issuer no longer takes applications for.
|
|
15
|
+
--member <name> A membership you have, e.g. amazon-prime. Repeatable.
|
|
16
|
+
--valuation <profile> flat (points at 1¢, the default) or points-heavy
|
|
17
|
+
(MR and UR at 1.7¢).`;
|
|
18
|
+
export const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
|
|
19
|
+
|
|
20
|
+
Ranks every card in earnrate's catalog for one purchase, not just yours. Each
|
|
21
|
+
card is set up at its best: the right category pick, activated, cap unused.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
${CLI_NAME} best groceries
|
|
25
|
+
${CLI_NAME} best amazon 120 --member amazon-prime
|
|
26
|
+
${CLI_NAME} best dining --no-fee
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
${FILTER_OPTIONS}
|
|
30
|
+
--online, --in-store How you're paying. Default: inferred from the merchant.
|
|
31
|
+
--date <YYYY-MM-DD> Purchase date. Default: today.
|
|
32
|
+
--all List every card, not just the top 10.
|
|
33
|
+
--json Print the full result as JSON.
|
|
34
|
+
-h, --help Show this help.
|
|
35
|
+
`;
|
|
36
|
+
export const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate <what>=<dollars a month>... [options]
|
|
37
|
+
|
|
38
|
+
Spends this much every month for a year and ranks the cards, and the
|
|
39
|
+
two-card pairs, that earn the most after annual fees. Each <what> is a
|
|
40
|
+
category (groceries, dining, gas, travel, streaming, other) or a merchant
|
|
41
|
+
(amazon, costco).
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
${CLI_NAME} simulate groceries=600 dining=300 gas=150 other=1000
|
|
45
|
+
${CLI_NAME} simulate groceries=600 amazon=150 other=800 --wallet
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--wallet Start from your wallet and rank the cards that would
|
|
49
|
+
add the most to it, after their fee.
|
|
50
|
+
${FILTER_OPTIONS}
|
|
51
|
+
--start <YYYY-MM-DD> First month of the year simulated. Default: today.
|
|
52
|
+
--all List more results.
|
|
53
|
+
--json Print the full result as JSON.
|
|
54
|
+
-h, --help Show this help.
|
|
55
|
+
`;
|
|
56
|
+
const FILTER_FLAGS = {
|
|
57
|
+
cash: { type: 'boolean' },
|
|
58
|
+
points: { type: 'boolean' },
|
|
59
|
+
'no-fee': { type: 'boolean' },
|
|
60
|
+
issuer: { type: 'string' },
|
|
61
|
+
business: { type: 'boolean' },
|
|
62
|
+
closed: { type: 'boolean' },
|
|
63
|
+
member: { type: 'string', multiple: true },
|
|
64
|
+
valuation: { type: 'string' },
|
|
65
|
+
all: { type: 'boolean' },
|
|
66
|
+
json: { type: 'boolean' },
|
|
67
|
+
help: { type: 'boolean', short: 'h' },
|
|
68
|
+
};
|
|
69
|
+
function settingsFrom(values, catalog) {
|
|
70
|
+
if (values.cash && values.points)
|
|
71
|
+
return 'Pick --cash or --points, not both.';
|
|
72
|
+
if (values.valuation !== undefined && !isValuationProfile(values.valuation))
|
|
73
|
+
return `Unknown valuation "${values.valuation}". Use flat or points-heavy.`;
|
|
74
|
+
const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
75
|
+
if (values.issuer !== undefined && !issuers.includes(values.issuer))
|
|
76
|
+
return `Unknown issuer "${values.issuer}". Use one of: ${issuers.join(', ')}.`;
|
|
77
|
+
const known = knownMemberships(catalog);
|
|
78
|
+
const unknown = (values.member ?? []).filter((m) => !known.includes(m));
|
|
79
|
+
if (unknown.length > 0)
|
|
80
|
+
return `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
|
|
81
|
+
return {
|
|
82
|
+
filters: {
|
|
83
|
+
...ALL_PERSONAL_CARDS,
|
|
84
|
+
kind: values.cash ? 'cash' : values.points ? 'points' : 'any',
|
|
85
|
+
business: values.business ?? false,
|
|
86
|
+
noAnnualFee: values['no-fee'] ?? false,
|
|
87
|
+
issuer: values.issuer ?? null,
|
|
88
|
+
includeClosed: values.closed ?? false,
|
|
89
|
+
},
|
|
90
|
+
memberships: values.member ?? [],
|
|
91
|
+
valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
92
|
+
? VALUATION_PROFILES[values.valuation]
|
|
93
|
+
: {},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
97
|
+
const wholeUsd = (n) => usd(Math.round(n));
|
|
98
|
+
const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
|
|
99
|
+
function feeText(e) {
|
|
100
|
+
if (e.annualFee === null)
|
|
101
|
+
return 'annual fee not on file';
|
|
102
|
+
if (e.annualFee === 0)
|
|
103
|
+
return 'no annual fee';
|
|
104
|
+
if (e.firstYearFee !== null && e.firstYearFee !== e.annualFee)
|
|
105
|
+
return `${usd(e.annualFee)} annual fee (${e.firstYearFee === 0 ? 'waived' : usd(e.firstYearFee)} the first year)`;
|
|
106
|
+
return `${usd(e.annualFee)} annual fee`;
|
|
107
|
+
}
|
|
108
|
+
const TOP_BEST = 10;
|
|
109
|
+
export function formatExploration(r, all) {
|
|
110
|
+
const m = r.merchant;
|
|
111
|
+
const title = m.source === 'caller'
|
|
112
|
+
? m.resolvedName.toLowerCase()
|
|
113
|
+
: m.source === 'none'
|
|
114
|
+
? `"${m.input}" (not recognized)`
|
|
115
|
+
: m.resolvedName;
|
|
116
|
+
if (r.ranked.length === 0)
|
|
117
|
+
return `No cards match at ${title}.\n`;
|
|
118
|
+
const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
|
|
119
|
+
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
120
|
+
const lines = [`Best cards for ${title}:`, ''];
|
|
121
|
+
shown.forEach((e, i) => {
|
|
122
|
+
const earned = e.earned === null ? '' : ` ${usd(e.earned)}`;
|
|
123
|
+
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
124
|
+
for (const c of e.conditions)
|
|
125
|
+
lines.push(` ${c}`);
|
|
126
|
+
const upgrade = e.withMembership[0];
|
|
127
|
+
if (upgrade)
|
|
128
|
+
lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
129
|
+
for (const w of e.warnings)
|
|
130
|
+
lines.push(` ! ${w}`);
|
|
131
|
+
});
|
|
132
|
+
if (r.ranked.length > shown.length)
|
|
133
|
+
lines.push(` + ${String(r.ranked.length - shown.length)} more (--all lists them)`);
|
|
134
|
+
for (const w of r.warnings)
|
|
135
|
+
lines.push('', `! ${w}`);
|
|
136
|
+
lines.push('', ...r.assumptions);
|
|
137
|
+
return `${lines.join('\n')}\n`;
|
|
138
|
+
}
|
|
139
|
+
function picksText(picks) {
|
|
140
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
|
|
141
|
+
return parts.length === 0 ? '' : ` (picks ${parts.join('; ')})`;
|
|
142
|
+
}
|
|
143
|
+
function cardNamer(catalog) {
|
|
144
|
+
return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
145
|
+
}
|
|
146
|
+
function splitText(sim, name) {
|
|
147
|
+
const byCard = new Map();
|
|
148
|
+
for (const line of sim.lines) {
|
|
149
|
+
const first = line.cards[0];
|
|
150
|
+
if (first === undefined)
|
|
151
|
+
continue;
|
|
152
|
+
byCard.set(first, [...(byCard.get(first) ?? []), line.label]);
|
|
153
|
+
}
|
|
154
|
+
return [...byCard].map(([id, labels]) => `${labels.join(', ')} → ${name(id)}`);
|
|
155
|
+
}
|
|
156
|
+
const TOP_SIMULATED = 5;
|
|
157
|
+
export function formatCatalogSimulation(r, catalog, all) {
|
|
158
|
+
const name = cardNamer(catalog);
|
|
159
|
+
const first = r.singles[0];
|
|
160
|
+
if (!first)
|
|
161
|
+
return 'No cards match.\n';
|
|
162
|
+
const top = all ? 20 : TOP_SIMULATED;
|
|
163
|
+
const lines = [
|
|
164
|
+
`A year of this spending: ${wholeUsd(first.spend)}.`,
|
|
165
|
+
'',
|
|
166
|
+
'Best single cards (rewards minus annual fee, per year):',
|
|
167
|
+
];
|
|
168
|
+
r.singles.slice(0, top).forEach((s, i) => {
|
|
169
|
+
const card = s.cards[0];
|
|
170
|
+
if (!card)
|
|
171
|
+
return;
|
|
172
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${picksText(card.picks)}`);
|
|
173
|
+
});
|
|
174
|
+
const pairs = r.pairs.slice(0, top);
|
|
175
|
+
if (pairs.length > 0) {
|
|
176
|
+
lines.push('', 'Best two-card pairs:');
|
|
177
|
+
pairs.forEach((s, i) => {
|
|
178
|
+
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeUsd(s.net)}`);
|
|
179
|
+
if (s.alternatives.length > 0)
|
|
180
|
+
lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
|
|
181
|
+
for (const split of splitText(s, name))
|
|
182
|
+
lines.push(` ${split}`);
|
|
183
|
+
});
|
|
184
|
+
const bestPair = r.pairs[0];
|
|
185
|
+
if (bestPair && bestPair.net <= first.net)
|
|
186
|
+
lines.push('', `One card is enough here: no pair beats ${name(first.cards[0]?.cardId ?? '')}.`);
|
|
187
|
+
}
|
|
188
|
+
const notes = new Set([
|
|
189
|
+
...r.singles.slice(0, top).flatMap((s) => s.notes),
|
|
190
|
+
...pairs.flatMap((s) => s.notes),
|
|
191
|
+
]);
|
|
192
|
+
if (notes.size > 0)
|
|
193
|
+
lines.push('', ...notes);
|
|
194
|
+
lines.push('', "Assumes each card's best picks, activations done each quarter, and no welcome bonuses.");
|
|
195
|
+
return `${lines.join('\n')}\n`;
|
|
196
|
+
}
|
|
197
|
+
export function formatAdditions(r, catalog, all) {
|
|
198
|
+
const name = cardNamer(catalog);
|
|
199
|
+
const lines = [
|
|
200
|
+
`Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees` : ''}.`,
|
|
201
|
+
];
|
|
202
|
+
for (const split of splitText(r.current, name))
|
|
203
|
+
lines.push(` ${split}`);
|
|
204
|
+
const gains = r.additions.filter((a) => a.gain > 0);
|
|
205
|
+
if (gains.length === 0) {
|
|
206
|
+
lines.push('', 'No card would add more than its annual fee costs.');
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
lines.push('', 'Cards that would add the most (after their annual fee):');
|
|
210
|
+
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
211
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee)${picksText(a.picks)}`);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const notes = new Set(r.current.notes);
|
|
215
|
+
if (notes.size > 0)
|
|
216
|
+
lines.push('', ...notes);
|
|
217
|
+
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
|
|
218
|
+
return `${lines.join('\n')}\n`;
|
|
219
|
+
}
|
|
220
|
+
export async function runBest(argv, io) {
|
|
221
|
+
let parsed;
|
|
222
|
+
try {
|
|
223
|
+
parsed = parseArgs({
|
|
224
|
+
args: argv,
|
|
225
|
+
allowPositionals: true,
|
|
226
|
+
options: {
|
|
227
|
+
...FILTER_FLAGS,
|
|
228
|
+
online: { type: 'boolean' },
|
|
229
|
+
'in-store': { type: 'boolean' },
|
|
230
|
+
date: { type: 'string' },
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
io.stderr(`${e instanceof Error ? e.message : String(e)}\n${BEST_HELP}`);
|
|
236
|
+
return USAGE_ERROR;
|
|
237
|
+
}
|
|
238
|
+
const { values, positionals } = parsed;
|
|
239
|
+
if (values.help) {
|
|
240
|
+
io.stdout(BEST_HELP);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
const words = [...positionals];
|
|
244
|
+
const last = words.at(-1);
|
|
245
|
+
const amount = words.length > 1 && last !== undefined && /^\$?\d+(\.\d+)?$/.test(last)
|
|
246
|
+
? Number(last.replace('$', ''))
|
|
247
|
+
: null;
|
|
248
|
+
if (amount !== null)
|
|
249
|
+
words.pop();
|
|
250
|
+
const text = words.join(' ').trim();
|
|
251
|
+
if (text === '') {
|
|
252
|
+
io.stderr(`Say what you're buying, e.g. ${CLI_NAME} best groceries.\n`);
|
|
253
|
+
return USAGE_ERROR;
|
|
254
|
+
}
|
|
255
|
+
if (values.online && values['in-store']) {
|
|
256
|
+
io.stderr('Pick --online or --in-store, not both.\n');
|
|
257
|
+
return USAGE_ERROR;
|
|
258
|
+
}
|
|
259
|
+
const catalog = await loadCatalog();
|
|
260
|
+
const settings = settingsFrom(values, catalog);
|
|
261
|
+
if (typeof settings === 'string') {
|
|
262
|
+
io.stderr(`${settings}\n`);
|
|
263
|
+
return USAGE_ERROR;
|
|
264
|
+
}
|
|
265
|
+
const category = findCategory(catalog, text);
|
|
266
|
+
const result = exploreCards(catalog, {
|
|
267
|
+
merchant: category === null ? text : null,
|
|
268
|
+
category,
|
|
269
|
+
amount,
|
|
270
|
+
channel: values.online
|
|
271
|
+
? 'online'
|
|
272
|
+
: values['in-store']
|
|
273
|
+
? 'in_store'
|
|
274
|
+
: null,
|
|
275
|
+
date: values.date ?? io.today,
|
|
276
|
+
}, settings);
|
|
277
|
+
io.stdout(values.json
|
|
278
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
279
|
+
: formatExploration(result, values.all ?? false));
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
export function parseSpend(catalog, args) {
|
|
283
|
+
const lines = [];
|
|
284
|
+
for (const arg of args) {
|
|
285
|
+
const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
|
|
286
|
+
if (!match?.[1] || match[2] === undefined)
|
|
287
|
+
return `"${arg}" isn't <what>=<dollars a month>, e.g. groceries=600.`;
|
|
288
|
+
const what = match[1].replace(/[-_]/g, ' ');
|
|
289
|
+
const category = findCategory(catalog, what);
|
|
290
|
+
lines.push({
|
|
291
|
+
merchant: category === null ? what : null,
|
|
292
|
+
category,
|
|
293
|
+
monthly: Number(match[2]),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (lines.length === 0)
|
|
297
|
+
return `Give your monthly spending, e.g. ${CLI_NAME} simulate groceries=600 dining=300 other=1000.`;
|
|
298
|
+
return lines;
|
|
299
|
+
}
|
|
300
|
+
export async function runSimulate(argv, io) {
|
|
301
|
+
let parsed;
|
|
302
|
+
try {
|
|
303
|
+
parsed = parseArgs({
|
|
304
|
+
args: argv,
|
|
305
|
+
allowPositionals: true,
|
|
306
|
+
options: {
|
|
307
|
+
...FILTER_FLAGS,
|
|
308
|
+
wallet: { type: 'boolean' },
|
|
309
|
+
start: { type: 'string' },
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
io.stderr(`${e instanceof Error ? e.message : String(e)}\n${SIMULATE_HELP}`);
|
|
315
|
+
return USAGE_ERROR;
|
|
316
|
+
}
|
|
317
|
+
const { values, positionals } = parsed;
|
|
318
|
+
if (values.help) {
|
|
319
|
+
io.stdout(SIMULATE_HELP);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
const catalog = await loadCatalog();
|
|
323
|
+
const settings = settingsFrom(values, catalog);
|
|
324
|
+
if (typeof settings === 'string') {
|
|
325
|
+
io.stderr(`${settings}\n`);
|
|
326
|
+
return USAGE_ERROR;
|
|
327
|
+
}
|
|
328
|
+
const spend = parseSpend(catalog, positionals);
|
|
329
|
+
if (typeof spend === 'string') {
|
|
330
|
+
io.stderr(`${spend}\n`);
|
|
331
|
+
return USAGE_ERROR;
|
|
332
|
+
}
|
|
333
|
+
const lines = resolveSpendLines(catalog, spend);
|
|
334
|
+
const unknown = lines.filter((l) => l.merchant.source === 'none');
|
|
335
|
+
if (unknown.length > 0) {
|
|
336
|
+
io.stderr(`Not a category or a merchant earnrate knows: ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Categories include groceries, dining, gas, travel, streaming and other.\n`);
|
|
337
|
+
return USAGE_ERROR;
|
|
338
|
+
}
|
|
339
|
+
const options = { ...settings, start: values.start ?? io.today };
|
|
340
|
+
if (values.wallet) {
|
|
341
|
+
const file = walletPath(io.env);
|
|
342
|
+
const wallet = await readWallet(file, io);
|
|
343
|
+
if (!wallet)
|
|
344
|
+
return RUNTIME_ERROR;
|
|
345
|
+
const problems = walletProblems(wallet, catalog);
|
|
346
|
+
if (problems.length > 0) {
|
|
347
|
+
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
348
|
+
return RUNTIME_ERROR;
|
|
349
|
+
}
|
|
350
|
+
const valued = values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
351
|
+
? withValuationProfile(wallet, values.valuation)
|
|
352
|
+
: wallet;
|
|
353
|
+
const result = simulateAdditions(catalog, valued, lines, options);
|
|
354
|
+
io.stdout(values.json
|
|
355
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
356
|
+
: formatAdditions(result, catalog, values.all ?? false));
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
const result = simulateCatalog(catalog, lines, options);
|
|
360
|
+
io.stdout(values.json
|
|
361
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
362
|
+
: formatCatalogSimulation(result, catalog, values.all ?? false));
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
package/dist/cli/run.js
CHANGED
|
@@ -12,6 +12,7 @@ import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
|
12
12
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
13
13
|
import { packageVersion } from "../version.js";
|
|
14
14
|
import { parseCliArgs } from "./args.js";
|
|
15
|
+
import { runBest, runSimulate } from "./explore.js";
|
|
15
16
|
import { runImport } from "./import.js";
|
|
16
17
|
import { runOffers } from "./offers.js";
|
|
17
18
|
import { runWalletCommand, WALLET_HELP } from "./wallet.js";
|
|
@@ -25,6 +26,10 @@ Examples:
|
|
|
25
26
|
${CLI_NAME} costco --in-store
|
|
26
27
|
${CLI_NAME} delta.com 450 --valuation points-heavy
|
|
27
28
|
|
|
29
|
+
Compare every card, no wallet needed:
|
|
30
|
+
${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
|
|
31
|
+
${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
|
|
32
|
+
|
|
28
33
|
Set up your wallet:
|
|
29
34
|
${CLI_NAME} cards [search words] Find cards and their ids.
|
|
30
35
|
${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
|
|
@@ -87,6 +92,10 @@ export async function run(argv, io) {
|
|
|
87
92
|
return runImport(argv.slice(1), io);
|
|
88
93
|
if (argv[0] === 'offers')
|
|
89
94
|
return runOffers(argv.slice(1), io);
|
|
95
|
+
if (argv[0] === 'best')
|
|
96
|
+
return runBest(argv.slice(1), io);
|
|
97
|
+
if (argv[0] === 'simulate')
|
|
98
|
+
return runSimulate(argv.slice(1), io);
|
|
90
99
|
const command = argv[0];
|
|
91
100
|
if (command !== undefined && Object.hasOwn(WALLET_HELP, command)) {
|
|
92
101
|
return runWalletCommand(command, argv.slice(1), io);
|