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 +16 -10
- package/data/CHANGELOG.md +17 -0
- package/data/VERSION +1 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +210 -0
- package/data/cards/capital-one-savor.yaml +1 -1
- package/data/cards/capital-one-venture-x.yaml +1 -1
- package/data/cards/capital-one-venture.yaml +1 -1
- package/data/cards/chase-freedom-flex.yaml +1 -0
- package/dist/cli/cards.js +34 -0
- package/dist/cli/run.js +14 -2
- package/dist/cli/wallet.js +194 -0
- package/dist/data/integrity.js +18 -1
- package/dist/data/wallet-edit.js +162 -0
- package/dist/data/wallet-setup.js +202 -0
- package/dist/engine/evaluate.js +7 -1
- package/dist/engine/recommend.js +9 -0
- package/dist/engine/rotation.js +16 -0
- package/dist/engine/setup.js +168 -0
- package/dist/engine/wallet-summary.js +2 -0
- package/dist/mcp/schemas.js +46 -0
- package/dist/mcp/server.js +159 -3
- package/dist/schema/card.js +30 -5
- package/package.json +2 -2
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
|
|
19
|
+
Needs Node 22 or later.
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
22
|
npm install -g earnrate
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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-venture
|
|
2
2
|
name: Capital One Venture Rewards Credit Card
|
|
3
|
-
issuer:
|
|
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
|
|
@@ -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
|
|
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)}.
|
|
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
|
+
}
|