earnrate 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,23 +9,26 @@ 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.
12
+ - Runs on your machine. 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.
16
16
 
17
17
  ## Try it
18
18
 
19
- Needs Node 24 or later.
19
+ Needs Node 22 or later.
20
20
 
21
21
  ```sh
22
22
  npm install -g earnrate
23
- mkdir -p ~/.earnrate
24
- curl -o ~/.earnrate/wallet.yaml https://raw.githubusercontent.com/cwasley/earnrate/main/wallet.example.yaml
23
+ earnrate cards sapphire # find your cards' ids
24
+ earnrate add chase-sapphire-preferred
25
+ earnrate add amex-gold
25
26
  earnrate whole foods 80
26
27
  ```
27
28
 
28
- Then edit `~/.earnrate/wallet.yaml` to list your own cards (see [Your wallet](#your-wallet)).
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 the MCP server](#use-it-from-an-ai-agent-mcp) and tell it which cards you have.
30
+
31
+ With the [example wallet](wallet.example.yaml), it prints:
29
32
 
30
33
  ```
31
34
  Whole Foods Market · $80
@@ -50,7 +53,7 @@ Options: `--online` or `--in-store`, `--valuation flat|points-heavy`, `--all`, `
50
53
 
51
54
  ### Your wallet
52
55
 
53
- The wallet is a YAML file listing your cards by ID, which is each card's file name in [`data/cards/`](data/cards). Only the cards are required:
56
+ 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:
54
57
 
55
58
  ```yaml
56
59
  valuations:
@@ -69,7 +72,7 @@ cards:
69
72
  two-percent: [gas-ev-charging]
70
73
  ```
71
74
 
72
- Rule IDs for `activated` and the choice slots for `chosen` 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 agent or import a statement with `--apply`, and reset with each period.
75
+ 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 agent or import a statement with `--apply`, and reset with each period.
73
76
 
74
77
  ### Merchants earnrate doesn't know
75
78
 
@@ -119,7 +122,7 @@ It totals what each card earned against the best card in your wallet for each pu
119
122
 
120
123
  ## Supported cards
121
124
 
122
- 56 cards, each modeled from the issuer's own terms and checked weekly against them:
125
+ 57 cards, each modeled from the issuer's own terms and checked weekly against them:
123
126
 
124
127
  - **American Express:** Gold, Platinum, Blue Cash Preferred, Blue Cash Everyday, Blue Business Plus
125
128
  - **Chase:** Sapphire Preferred, Sapphire Reserve, Freedom Unlimited, Freedom Flex, Ink Business Cash, Ink Business Preferred, Prime Visa
@@ -127,7 +130,7 @@ It totals what each card earned against the best card in your wallet for each pu
127
130
  - **Citi:** Strata Elite, Strata Premier, Double Cash, Custom Cash, My Best Buy Visa, Costco Anywhere Visa (personal and business); AAdvantage MileUp, Platinum Select, Globe, Executive, Business
128
131
  - **Capital One:** Venture X, Venture, Savor, Kohl's Card, Kohl's Rewards Visa
129
132
  - **Wells Fargo:** Active Cash, Autograph
130
- - **Bank of America:** Atmos Rewards Ascent, Summit, Business
133
+ - **Bank of America:** Atmos Rewards Ascent, Summit, Business; Customized Cash Rewards
131
134
  - **TD Bank:** Target Circle Card, Nordstrom card, Nordstrom Visa
132
135
  - **Others:** U.S. Bank Cash+, Bilt Obsidian, SoFi Unlimited 2%
133
136
 
@@ -139,13 +142,16 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
139
142
 
140
143
  ## Use it from an AI agent (MCP)
141
144
 
142
- `earnrate mcp` runs earnrate as a local MCP server over stdio, with five tools:
145
+ `earnrate mcp` runs earnrate as a local MCP server over stdio, with ten tools:
143
146
 
144
147
  - `best_card_for_purchase`: which card to use, with reasons
145
148
  - `resolve_merchant`: how a merchant was identified
146
149
  - `list_wallet`: your cards, caps left this period, chosen categories and welcome-bonus progress
147
150
  - `compare_welcome_offers`: the best public sign-up offers on cards you don't have
148
151
  - `record_purchase`: books a purchase against its cap and any open welcome bonus in your wallet file, keeping your comments
152
+ - `search_cards`, `add_card`, `update_card`, `remove_card` and `set_memberships`: set up the wallet in conversation
153
+
154
+ There's no wallet to write first: tell the assistant which cards you have, and it finds them, adds them, and asks about anything a card needs, like this quarter's Cash+ picks.
149
155
 
150
156
  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`.
151
157
 
package/data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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.1
6
+
7
+ 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.
8
+
9
+ | Card | Field | Old → new | Source |
10
+ | ------------------------------------------------------------- | --------------------------- | ------------------------ | ------ |
11
+ | chase-freedom-flex | caps.five-percent-quarterly | adds `rotating` | n/a |
12
+ | capital-one-venture-x, capital-one-venture, capital-one-savor | issuer | capitalone → capital-one | n/a |
13
+
14
+ ## 2026.10.28.2
15
+
16
+ Bank of America Customized Cash Rewards, reviewed by Collin (`verified_by: collin`). Choice options can now be "any online purchase except these categories", and a choice period can be `until_changed` for picks that carry over.
17
+
18
+ | Card | Field | Old → new | Source |
19
+ | --------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
20
+ | bank-of-america-customized-cash-rewards | new | 3% in a choice category, 2% at grocery stores and wholesale clubs ($2,500 combined a quarter), 1% otherwise; BofA Rewards tiers add 10%–75% | [card page](https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/), [categories](https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/cash-back-category-choices/), terms, [BofA Rewards](https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/) |
21
+
5
22
  ## 2026.10.28.1
6
23
 
7
24
  Collin reviewed the family-cards and offers packets and kept every value as recorded, including the open calls: no Peloton rule on Sapphire Preferred yet, My Best Buy Visa's fee at the top of Citi's $0–59 range, and Amex "as high as" offers at the public figure. All 56 cards are now `verified_by: collin`.
package/data/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.10.28.1
1
+ 2026.10.30.1
@@ -0,0 +1,210 @@
1
+ id: bank-of-america-customized-cash-rewards
2
+ name: Bank of America Customized Cash Rewards Credit Card
3
+ issuer: bank-of-america
4
+ # Bank of America's terms carry only the Visa and Visa Signature trademark
5
+ # notice, so the network is Visa.
6
+ network: visa
7
+ currency: cash
8
+ last_verified: 2026-10-28
9
+ verified_by: collin
10
+ source_urls:
11
+ - https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/
12
+ - https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/cash-back-category-choices/
13
+ - https://www.bankofamerica.com/credit-cards/terms-and-conditions/?campaignid=4079537&productoffercode=8H&locale=en_US
14
+ - https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
15
+ base_rate: 1
16
+ annual_fee: 0
17
+ welcome_offer:
18
+ dollars: 200
19
+ spend: 1000
20
+ months: 3
21
+ note: Online offer; the window is 90 days from account opening, modeled as 3 months. The offer may not be available elsewhere after leaving the page.
22
+ caps:
23
+ - id: three-and-two-percent-combined
24
+ amount: 2500
25
+ period: quarter
26
+ then_rate: 1
27
+ choices:
28
+ # Gas is the default until changed. It can be changed once per calendar
29
+ # month and otherwise carries over.
30
+ - id: three-percent
31
+ pick: 1
32
+ period: until_changed
33
+ default: [gas-ev-charging]
34
+ options:
35
+ gas-ev-charging: [gas, ev_charging]
36
+ # Bank of America also excludes doctors and hospitals, government
37
+ # services and taxes, insurance, schools and other non-retail services,
38
+ # which have no earnrate category.
39
+ online-shopping:
40
+ { channel: online, exclude: [utilities, shipping, fitness] }
41
+ dining: [dining]
42
+ travel: [travel, transit]
43
+ drug-stores: [drugstore]
44
+ home-improvement-furnishings: [home_improvement, furniture]
45
+ rules:
46
+ - id: three-percent
47
+ match: { choice: three-percent }
48
+ rate: 3
49
+ cap: three-and-two-percent-combined
50
+ note: Gas and EV charging, online shopping, dining, travel, drug stores, or home improvement and furnishings, whichever you picked. Travel includes parking, transit, rideshare and tolls. Online shopping is any purchase paid on a website or app, as reported by the merchant; ordered online but paid in store, in-store mobile wallet and phone or mail orders don't count, and doctors, government, insurance, membership organizations, schools, some utilities and other non-retail services are excluded. The first $2,500 a calendar quarter across the 3% and 2% categories, then 1%.
51
+ - id: two-percent
52
+ match: { categories: [grocery.supermarket, warehouse_club] }
53
+ rate: 2
54
+ cap: three-and-two-percent-combined
55
+ note: Grocery stores, supermarkets, wholesale clubs, and meat and seafood, candy, dairy and bakery stores. Superstores and smaller stores that also sell groceries, like drug stores, are excluded. Shares the $2,500 quarterly cap with the 3% category.
56
+ # BofA Rewards members earn a tier bonus on all cash back: 10% for Member,
57
+ # 25% Preferred Plus, 50% Preferred Honors, 75% Premier.
58
+ - id: three-percent-bofa-rewards-member
59
+ match: { choice: three-percent }
60
+ rate: 3.3
61
+ cap: three-and-two-percent-combined
62
+ requires: { membership: bofa-rewards-member }
63
+ note: 3% plus the 10% BofA Rewards Member bonus.
64
+ - id: two-percent-bofa-rewards-member
65
+ match: { categories: [grocery.supermarket, warehouse_club] }
66
+ rate: 2.2
67
+ cap: three-and-two-percent-combined
68
+ requires: { membership: bofa-rewards-member }
69
+ note: 2% plus the 10% BofA Rewards Member bonus.
70
+ - id: everything-else-bofa-rewards-member
71
+ match: { any: true }
72
+ rate: 1.1
73
+ requires: { membership: bofa-rewards-member }
74
+ note: 1% plus the 10% BofA Rewards Member bonus.
75
+ - id: three-percent-bofa-rewards-preferred-plus
76
+ match: { choice: three-percent }
77
+ rate: 3.75
78
+ cap: three-and-two-percent-combined
79
+ requires: { membership: bofa-rewards-preferred-plus }
80
+ note: 3% plus the 25% BofA Rewards Preferred Plus bonus.
81
+ - id: two-percent-bofa-rewards-preferred-plus
82
+ match: { categories: [grocery.supermarket, warehouse_club] }
83
+ rate: 2.5
84
+ cap: three-and-two-percent-combined
85
+ requires: { membership: bofa-rewards-preferred-plus }
86
+ note: 2% plus the 25% BofA Rewards Preferred Plus bonus.
87
+ - id: everything-else-bofa-rewards-preferred-plus
88
+ match: { any: true }
89
+ rate: 1.25
90
+ requires: { membership: bofa-rewards-preferred-plus }
91
+ note: 1% plus the 25% BofA Rewards Preferred Plus bonus.
92
+ - id: three-percent-bofa-rewards-preferred-honors
93
+ match: { choice: three-percent }
94
+ rate: 4.5
95
+ cap: three-and-two-percent-combined
96
+ requires: { membership: bofa-rewards-preferred-honors }
97
+ note: 3% plus the 50% BofA Rewards Preferred Honors bonus.
98
+ - id: two-percent-bofa-rewards-preferred-honors
99
+ match: { categories: [grocery.supermarket, warehouse_club] }
100
+ rate: 3
101
+ cap: three-and-two-percent-combined
102
+ requires: { membership: bofa-rewards-preferred-honors }
103
+ note: 2% plus the 50% BofA Rewards Preferred Honors bonus.
104
+ - id: everything-else-bofa-rewards-preferred-honors
105
+ match: { any: true }
106
+ rate: 1.5
107
+ requires: { membership: bofa-rewards-preferred-honors }
108
+ note: 1% plus the 50% BofA Rewards Preferred Honors bonus.
109
+ - id: three-percent-bofa-rewards-premier
110
+ match: { choice: three-percent }
111
+ rate: 5.25
112
+ cap: three-and-two-percent-combined
113
+ requires: { membership: bofa-rewards-premier }
114
+ note: 3% plus the 75% BofA Rewards Premier bonus.
115
+ - id: two-percent-bofa-rewards-premier
116
+ match: { categories: [grocery.supermarket, warehouse_club] }
117
+ rate: 3.5
118
+ cap: three-and-two-percent-combined
119
+ requires: { membership: bofa-rewards-premier }
120
+ note: 2% plus the 75% BofA Rewards Premier bonus.
121
+ - id: everything-else-bofa-rewards-premier
122
+ match: { any: true }
123
+ rate: 1.75
124
+ requires: { membership: bofa-rewards-premier }
125
+ note: 1% plus the 75% BofA Rewards Premier bonus.
126
+ # Issuer text backing each rule; `yarn check-sources` checks it weekly.
127
+ evidence:
128
+ - rule: base_rate
129
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/
130
+ quote: 'Unlimited 1% cash back on all other purchases'
131
+ - rule: three-percent
132
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/cash-back-category-choices/
133
+ quote: '3% cash back in the category of your choice'
134
+ - rule: three-percent
135
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/cash-back-category-choices/
136
+ quote: 'You can change it once each calendar month, or make no change and it stays the same.'
137
+ - rule: three-percent
138
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/cash-back-category-choices/
139
+ quote: 'Until you change your category it will be set to Gas & EV Charging Stations.'
140
+ - rule: three-percent
141
+ url: https://www.bankofamerica.com/credit-cards/terms-and-conditions/?campaignid=4079537&productoffercode=8H&locale=en_US
142
+ quote: 'first $2,500 of combined Net Purchases posted to the account each calendar quarter'
143
+ - rule: three-percent
144
+ url: https://www.bankofamerica.com/credit-cards/terms-and-conditions/?campaignid=4079537&productoffercode=8H&locale=en_US
145
+ quote: 'purchases must be made online via a website or a digital application (an app)'
146
+ - rule: three-percent
147
+ url: https://www.bankofamerica.com/credit-cards/terms-and-conditions/?campaignid=4079537&productoffercode=8H&locale=en_US
148
+ quote: 'doctors and hospitals, government services and taxes, insurances, membership organizations, education/schools, and some utilities'
149
+ - rule: three-percent
150
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/cash-back-category-choices/
151
+ quote: 'Courier Services (Air and Ground, Freight Forwarders)'
152
+ - rule: three-percent
153
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/cash-back-category-choices/
154
+ quote: 'Clubs, Country Clubs, Membership (Athletic, Recreation, Sports)'
155
+ - rule: three-percent
156
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/cash-back-category-choices/
157
+ quote: 'parking, public transit, taxis/ride share'
158
+ - rule: two-percent
159
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/
160
+ quote: 'Automatic 2% cash back at grocery stores and wholesale clubs'
161
+ - rule: two-percent
162
+ url: https://www.bankofamerica.com/credit-cards/terms-and-conditions/?campaignid=4079537&productoffercode=8H&locale=en_US
163
+ quote: 'Excluded from this Bonus Category are superstores and smaller stores (like drug stores)'
164
+ - rule: three-percent-bofa-rewards-member
165
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
166
+ quote: 'a $100 purchase that earns 3% ($3.00) will actually earn $3.30'
167
+ - rule: two-percent-bofa-rewards-member
168
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
169
+ quote: 'bonus of 10% for the Member tier'
170
+ - rule: everything-else-bofa-rewards-member
171
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
172
+ quote: 'applied after all base and bonus cash rewards have been calculated on a purchase'
173
+ - rule: three-percent-bofa-rewards-preferred-plus
174
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
175
+ quote: 'will actually earn $3.30, $3.75, $4.50 or $5.25 based on your tier'
176
+ - rule: two-percent-bofa-rewards-preferred-plus
177
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
178
+ quote: '25% for the Preferred Plus tier'
179
+ - rule: everything-else-bofa-rewards-preferred-plus
180
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
181
+ quote: '25% for the Preferred Plus tier'
182
+ - rule: three-percent-bofa-rewards-preferred-honors
183
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
184
+ quote: 'will actually earn $3.30, $3.75, $4.50 or $5.25 based on your tier'
185
+ - rule: two-percent-bofa-rewards-preferred-honors
186
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
187
+ quote: '50% for the Preferred Honors tier'
188
+ - rule: everything-else-bofa-rewards-preferred-honors
189
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
190
+ quote: '50% for the Preferred Honors tier'
191
+ - rule: three-percent-bofa-rewards-premier
192
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
193
+ quote: 'will actually earn $3.30, $3.75, $4.50 or $5.25 based on your tier'
194
+ - rule: two-percent-bofa-rewards-premier
195
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
196
+ quote: 'or 75% for the Premier tier'
197
+ - rule: everything-else-bofa-rewards-premier
198
+ url: https://www.bankofamerica.com/bofa-rewards/increasing-bofa-rewards-with-credit-cards/
199
+ quote: 'or 75% for the Premier tier'
200
+ - rule: annual_fee
201
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/
202
+ quote: 'Annual Fee $0'
203
+ - rule: welcome_offer
204
+ url: https://www.bankofamerica.com/credit-cards/products/cash-back-credit-card/
205
+ quote: 'Online $200 cash rewards bonus after making at least $1,000 in purchases in the first 90 days'
206
+ todo:
207
+ - 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.
208
+ - '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.'
209
+ - 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.
210
+ - "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."
@@ -1,6 +1,6 @@
1
1
  id: capital-one-savor
2
2
  name: Capital One Savor Rewards Credit Card
3
- issuer: capitalone
3
+ issuer: capital-one
4
4
  # The card page never names the network in visible text; its embedded
5
5
  # product data says MasterCard.
6
6
  network: mastercard
@@ -1,6 +1,6 @@
1
1
  id: capital-one-venture-x
2
2
  name: Capital One Venture X Rewards Credit Card
3
- issuer: capitalone
3
+ issuer: capital-one
4
4
  network: visa # Visa Infinite, per the card page's footnotes
5
5
  currency: C1
6
6
  last_verified: 2026-10-26
@@ -1,6 +1,6 @@
1
1
  id: capital-one-venture
2
2
  name: Capital One Venture Rewards Credit Card
3
- issuer: capitalone
3
+ issuer: capital-one
4
4
  # The card page never names the network in visible text. Its embedded product
5
5
  # data says MasterCard; older Venture accounts may be on Visa.
6
6
  network: mastercard
@@ -31,6 +31,7 @@ caps:
31
31
  amount: 1500
32
32
  period: quarter
33
33
  then_rate: 1
34
+ rotating: true
34
35
  rules:
35
36
  - id: chase-travel
36
37
  match: { merchants: [chase-travel] }
@@ -0,0 +1,34 @@
1
+ const NETWORKS = {
2
+ visa: 'Visa',
3
+ mastercard: 'Mastercard',
4
+ amex: 'Amex',
5
+ discover: 'Discover',
6
+ store: 'store card',
7
+ };
8
+ export function formatCardInfo(card) {
9
+ const fee = card.annualFee === null
10
+ ? ''
11
+ : card.annualFee === 0
12
+ ? ', no annual fee'
13
+ : `, $${String(card.annualFee)} a year`;
14
+ const needs = [
15
+ ...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
16
+ ...(card.memberships.length > 0
17
+ ? [`rates that need a membership: ${card.memberships.join(', ')}`]
18
+ : []),
19
+ ...(card.activation.length > 0
20
+ ? [`activate with the issuer: ${card.activation.join(', ')}`]
21
+ : []),
22
+ ...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
23
+ ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
24
+ ];
25
+ return [
26
+ `${card.id}: ${card.name} (${NETWORKS[card.network]}${fee})${card.held ? ' [in wallet]' : ''}`,
27
+ ...needs.map((n) => ` ${n}`),
28
+ ].join('\n');
29
+ }
30
+ export function formatNotes(notes) {
31
+ return notes.length === 0
32
+ ? 'Nothing else to set up for it.'
33
+ : `Still to set:\n${notes.map((n) => `- ${n.text}`).join('\n')}`;
34
+ }
package/dist/cli/run.js CHANGED
@@ -14,6 +14,7 @@ import { packageVersion } from "../version.js";
14
14
  import { parseCliArgs } from "./args.js";
15
15
  import { runImport } from "./import.js";
16
16
  import { runOffers } from "./offers.js";
17
+ import { runWalletCommand, WALLET_HELP } from "./wallet.js";
17
18
  import { formatRecommendation, plain } from "./format.js";
18
19
  export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
19
20
 
@@ -24,11 +25,18 @@ Examples:
24
25
  ${CLI_NAME} costco --in-store
25
26
  ${CLI_NAME} delta.com 450 --valuation points-heavy
26
27
 
28
+ Set up your wallet:
29
+ ${CLI_NAME} cards [search words] Find cards and their ids.
30
+ ${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
31
+ ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
32
+ ${CLI_NAME} remove <card-id> Remove a card.
33
+ ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
34
+
27
35
  Other commands:
28
36
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
29
37
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
30
38
  ${CLI_NAME} mcp Run the MCP server over stdio, for AI agents.
31
- Add --help to import or offers for its options.
39
+ Add --help to any command for its options.
32
40
 
33
41
  Options:
34
42
  --online, --in-store How you're paying. Default: inferred from the merchant.
@@ -66,7 +74,7 @@ export async function readWallet(file, io) {
66
74
  }
67
75
  catch (e) {
68
76
  if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
69
- io.stderr(`No wallet at ${displayPath(file)}. Copy the example there and edit it:\n mkdir -p ${displayPath(path.dirname(file))} && cp ${displayPath(EXAMPLE_WALLET)} ${displayPath(file)}\nOr set ${WALLET_ENV_VAR} to your wallet's path.\n`);
77
+ io.stderr(`No wallet at ${displayPath(file)} yet. Add the cards you hold:\n ${CLI_NAME} cards sapphire find a card's id\n ${CLI_NAME} add chase-sapphire-preferred\nOr start from the example: mkdir -p ${displayPath(path.dirname(file))} && cp ${displayPath(EXAMPLE_WALLET)} ${displayPath(file)}\nOr set ${WALLET_ENV_VAR} to your wallet's path.\n`);
70
78
  }
71
79
  else {
72
80
  io.stderr(`Couldn't read your wallet:\n${e instanceof Error ? e.message : String(e)}\n`);
@@ -79,6 +87,10 @@ export async function run(argv, io) {
79
87
  return runImport(argv.slice(1), io);
80
88
  if (argv[0] === 'offers')
81
89
  return runOffers(argv.slice(1), io);
90
+ const command = argv[0];
91
+ if (command !== undefined && Object.hasOwn(WALLET_HELP, command)) {
92
+ return runWalletCommand(command, argv.slice(1), io);
93
+ }
82
94
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
83
95
  io.stdout(`${CLI_NAME} ${await packageVersion()}\n`);
84
96
  return 0;
@@ -0,0 +1,194 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CLI_NAME } from "../brand.js";
3
+ import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
4
+ import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
5
+ import { knownMemberships, searchCards } from "../engine/setup.js";
6
+ import { formatCardInfo, formatNotes } from "./cards.js";
7
+ import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
8
+ const CARD_OPTIONS = `Options:
9
+ --label <text> How the card appears at checkout, e.g. "Amex Gold".
10
+ --pick <choice>=<options> Bonus categories you picked for this period, e.g.
11
+ --pick five-percent=fast-food,movie-theaters.
12
+ Repeat for each choice.
13
+ --anniversary <MM-DD> Account anniversary, for caps that reset on it.
14
+ --activated <rules> Rules you've activated with the issuer, comma
15
+ separated. Replaces the current list.
16
+ -h, --help Show this help.`;
17
+ export const WALLET_HELP = {
18
+ cards: `Usage: ${CLI_NAME} cards [search words]
19
+
20
+ Finds cards in earnrate's catalog by name or issuer, with the id to add them
21
+ by and anything they need set up. With no words, lists every card.
22
+
23
+ Examples:
24
+ ${CLI_NAME} cards sapphire
25
+ ${CLI_NAME} cards bofa
26
+ `,
27
+ add: `Usage: ${CLI_NAME} add <card-id> [options]
28
+
29
+ Adds a card you hold to your wallet, creating the wallet if needed. Find the
30
+ id with \`${CLI_NAME} cards\`.
31
+
32
+ ${CARD_OPTIONS}
33
+ `,
34
+ update: `Usage: ${CLI_NAME} update <card-id> [options]
35
+
36
+ Changes a card in your wallet: picks for this period, label, anniversary or
37
+ activations. Picks for the current period are merged with ones already set.
38
+
39
+ ${CARD_OPTIONS}
40
+ `,
41
+ remove: `Usage: ${CLI_NAME} remove <card-id>
42
+
43
+ Removes a card from your wallet, with its tracked cap usage.
44
+ `,
45
+ memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
46
+
47
+ With no arguments, shows your memberships and the ones earnrate knows. With
48
+ names, replaces the list. --none clears it.
49
+ `,
50
+ };
51
+ const HINTS = {
52
+ pick: (id) => `${CLI_NAME} update ${id} --pick <choice>=<option>,...`,
53
+ membership: () => `${CLI_NAME} memberships <membership>...`,
54
+ activation: (id) => `${CLI_NAME} update ${id} --activated <rule>,...`,
55
+ anniversary: (id) => `${CLI_NAME} update ${id} --anniversary MM-DD`,
56
+ };
57
+ function parsePicks(values) {
58
+ const picks = {};
59
+ for (const v of values) {
60
+ const [choice, options] = v.split('=', 2);
61
+ const ids = (options ?? '')
62
+ .split(',')
63
+ .map((o) => o.trim())
64
+ .filter((o) => o !== '');
65
+ if (!choice || ids.length === 0) {
66
+ return `"--pick ${v}" should look like --pick five-percent=fast-food,movie-theaters.`;
67
+ }
68
+ picks[choice] = ids;
69
+ }
70
+ return picks;
71
+ }
72
+ async function heldWallet(io) {
73
+ try {
74
+ return await loadWallet(walletPath(io.env));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function report(result, message, cardId, io) {
81
+ if (!result.ok) {
82
+ io.stderr(`${result.error}\n`);
83
+ return RUNTIME_ERROR;
84
+ }
85
+ const lines = [message];
86
+ if (cardId !== null) {
87
+ lines.push(formatNotes(result.notes));
88
+ const hints = [...new Set(result.notes.map((n) => n.kind))]
89
+ .map((kind) => HINTS[kind]?.(cardId))
90
+ .filter((h) => h !== undefined);
91
+ if (hints.length > 0)
92
+ lines.push(`Set them with:\n${hints.map((h) => ` ${h}`).join('\n')}`);
93
+ }
94
+ io.stdout(`${lines.join('\n')}\n`);
95
+ return 0;
96
+ }
97
+ export async function runWalletCommand(command, argv, io) {
98
+ let parsed;
99
+ try {
100
+ parsed = parseArgs({
101
+ args: argv,
102
+ allowPositionals: true,
103
+ options: {
104
+ label: { type: 'string' },
105
+ pick: { type: 'string', multiple: true },
106
+ anniversary: { type: 'string' },
107
+ activated: { type: 'string' },
108
+ none: { type: 'boolean' },
109
+ help: { type: 'boolean', short: 'h' },
110
+ },
111
+ });
112
+ }
113
+ catch (e) {
114
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\nRun ${CLI_NAME} ${command} --help for usage.\n`);
115
+ return USAGE_ERROR;
116
+ }
117
+ const { values, positionals } = parsed;
118
+ if (values.help) {
119
+ io.stdout(WALLET_HELP[command]);
120
+ return 0;
121
+ }
122
+ const usage = (message) => {
123
+ io.stderr(`${message}\nRun ${CLI_NAME} ${command} --help for usage.\n`);
124
+ return USAGE_ERROR;
125
+ };
126
+ const cardOptionsGiven = values.label !== undefined ||
127
+ values.pick !== undefined ||
128
+ values.anniversary !== undefined ||
129
+ values.activated !== undefined;
130
+ if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
131
+ return usage(`${CLI_NAME} ${command} doesn't take card options.`);
132
+ }
133
+ if (command !== 'memberships' && values.none) {
134
+ return usage('--none is only for memberships.');
135
+ }
136
+ const catalog = await loadCatalog();
137
+ const file = walletPath(io.env);
138
+ const where = displayPath(file);
139
+ if (command === 'cards') {
140
+ const query = positionals.join(' ');
141
+ const result = searchCards(catalog, query, await heldWallet(io), io.today);
142
+ if (result.cards.length === 0) {
143
+ io.stderr(`No card matches "${query}". Run ${CLI_NAME} cards with no words to list all ${String(catalog.cards.length)}.\n`);
144
+ return RUNTIME_ERROR;
145
+ }
146
+ const header = result.exact ? '' : 'No card matches every word. Closest:\n';
147
+ io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
148
+ return 0;
149
+ }
150
+ if (command === 'memberships') {
151
+ if (positionals.length === 0 && !values.none) {
152
+ const held = await heldWallet(io);
153
+ io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
154
+ return 0;
155
+ }
156
+ if (positionals.length > 0 && values.none) {
157
+ return usage('Give memberships or --none, not both.');
158
+ }
159
+ const list = values.none ? [] : positionals;
160
+ return report(await setMemberships(file, catalog, list), list.length > 0
161
+ ? `Memberships set to ${list.join(', ')} in ${where}.`
162
+ : `Memberships cleared in ${where}.`, null, io);
163
+ }
164
+ if (positionals.length !== 1) {
165
+ return usage('Give one card id.');
166
+ }
167
+ const id = positionals[0] ?? '';
168
+ const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
169
+ if (command === 'remove') {
170
+ return report(await removeCard(file, catalog, id), `Removed ${name} from ${where}.`, null, io);
171
+ }
172
+ const input = { id };
173
+ if (values.label !== undefined)
174
+ input.checkoutLabel = values.label;
175
+ if (values.anniversary !== undefined)
176
+ input.anniversary = values.anniversary;
177
+ if (values.activated !== undefined) {
178
+ input.activated = values.activated
179
+ .split(',')
180
+ .map((r) => r.trim())
181
+ .filter((r) => r !== '');
182
+ }
183
+ if (values.pick !== undefined) {
184
+ const picks = parsePicks(values.pick);
185
+ if (typeof picks === 'string')
186
+ return usage(picks);
187
+ input.picks = picks;
188
+ }
189
+ if (command === 'add') {
190
+ const result = await addCard(file, catalog, input, io.today);
191
+ return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
192
+ }
193
+ return report(await updateCard(file, catalog, input, io.today), `Updated ${name} in ${where}.`, id, io);
194
+ }