earnrate 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +12 -11
  2. package/data/CHANGELOG.md +35 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +5 -4
  5. package/data/cards/chase-freedom-flex.yaml +1 -1
  6. package/data/cards/chase-southwest-plus.yaml +4 -0
  7. package/data/cards/chase-southwest-premier.yaml +4 -0
  8. package/data/cards/chase-southwest-priority.yaml +4 -0
  9. package/data/cards/citi-aadvantage-globe.yaml +7 -7
  10. package/data/cards/citi-costco-anywhere-visa-business.yaml +15 -1
  11. package/data/cards/citi-costco-anywhere-visa.yaml +15 -1
  12. package/data/merchants/overrides.yaml +8 -0
  13. package/data/merchants/seed.yaml +0 -6
  14. package/dist/cli/cheatsheet.js +1 -1
  15. package/dist/cli/explore.js +1 -1
  16. package/dist/cli/import.js +4 -3
  17. package/dist/cli/run.js +1 -1
  18. package/dist/data/integrity.js +10 -0
  19. package/dist/data/wallet-edit.js +8 -2
  20. package/dist/engine/cheat-sheet.js +44 -2
  21. package/dist/engine/explore.js +14 -5
  22. package/dist/engine/memberships.js +45 -0
  23. package/dist/engine/offers.js +22 -2
  24. package/dist/engine/recommend.js +78 -31
  25. package/dist/engine/setup.js +22 -3
  26. package/dist/engine/simulate.js +32 -10
  27. package/dist/engine/text.js +51 -0
  28. package/dist/engine/wallet-change.js +25 -4
  29. package/dist/engine/wallet-summary.js +62 -34
  30. package/dist/format/card-text.js +7 -13
  31. package/dist/format/cards.js +7 -8
  32. package/dist/format/explore.js +17 -8
  33. package/dist/format/offers.js +5 -9
  34. package/dist/format/recommendation.js +11 -5
  35. package/dist/format/wallet-yaml.js +18 -3
  36. package/dist/format/wallet.js +12 -19
  37. package/dist/mcp/inputs.js +2 -2
  38. package/dist/mcp/schemas.js +13 -0
  39. package/dist/mcp/tools/account.js +27 -9
  40. package/dist/mcp/tools/answers.js +24 -7
  41. package/dist/mcp/tools/wallet.js +49 -7
  42. package/dist/schema/card.js +14 -0
  43. package/dist/schema/wallet.js +4 -0
  44. package/package.json +1 -1
  45. package/wallet.example.yaml +1 -0
package/README.md CHANGED
@@ -65,6 +65,7 @@ Options: `--online` or `--in-store`, `--valuation flat|points-heavy`, `--all`, `
65
65
  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:
66
66
 
67
67
  ```yaml
68
+ version: 1 # the file's format; earnrate writes it
68
69
  valuations:
69
70
  UR: 1.5 # cents per point; leave out to use earnrate's defaults
70
71
  memberships: [amazon-prime] # unlocks Prime Visa's 5%
@@ -81,7 +82,7 @@ cards:
81
82
  two-percent: [gas-ev-charging]
82
83
  ```
83
84
 
84
- Rule IDs for `activated`, the choice slots for `chosen` and the membership IDs that unlock bonuses (such as `bofa-rewards-premier` for a BofA Rewards tier) are in each card's file. Currencies and their default values are in [`data/currencies.yaml`](data/currencies.yaml). Spending caps fill up as you record purchases from an AI assistant or import a statement with `--apply`, and reset with each period.
85
+ 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. Add `costco` if you have a Costco membership: Costco Anywhere needs one, and spending answers count its $65 a year unless you have it, hold the card or spend at Costco. Currencies and their default values are in [`data/currencies.yaml`](data/currencies.yaml). Spending caps fill up as you record purchases from an AI assistant or import a statement with `--apply`, and reset with each period.
85
86
 
86
87
  ### Merchants earnrate doesn't know
87
88
 
@@ -107,7 +108,7 @@ Or tell your AI assistant ("I opened my Sapphire Preferred on October 1"), or ch
107
108
  bonus: { opened: 2026-10-01, spend: 5000, by: 2026-12-31, points: 75000 }
108
109
  ```
109
110
 
110
- While the bonus is open, that card wins wherever it's accepted, and the answer says how much is left, the weekly pace to finish on time, what the bonus is worth at your point values, and what using it costs compared with your best earner. Spend counts up as you record purchases (from an AI assistant or the website) or import a statement with `--apply`; refunds count down. Once it's met or the deadline passes, the card goes back to the normal ranking. With two open bonuses, the one needing more spend per day comes first.
111
+ While the bonus is open and you're behind an even pace to the deadline, that card wins wherever it's accepted, and the answer says how much is left, the weekly pace to finish on time, what the bonus is worth at your point values, and what using it costs compared with your best earner. On pace, it's the pick only where it earns as much as your best card, and it's mentioned where it comes within a point; otherwise your best card wins. Spend counts up as you record purchases from an AI assistant or import a statement with `--apply`; refunds count down. Once it's met or the deadline passes, the card goes back to the normal ranking. With two open bonuses, one behind pace comes first, then the one needing more spend per day.
111
112
 
112
113
  `spend`, `by` and the reward (`points`, or `dollars` for cash back) default to the card's published offer once earnrate has one; set them when your offer was different.
113
114
 
@@ -121,13 +122,13 @@ earnrate simulate groceries=600 dining=300 other=1000 # best cards and pairs for
121
122
  earnrate simulate groceries=600 other=1000 --wallet # what a new card would add to yours
122
123
  ```
123
124
 
124
- `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:
125
+ `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`, and a card that needs a membership to get (Costco Anywhere) says so. `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, counting the cost of a membership a card needs (Costco's, for Costco Anywhere) unless you pass `--member costco` or spend at Costco. Both take `--no-fee`, `--cash` or `--points`, `--issuer`, `--business` and `--valuation`. Welcome bonuses are left out of both; for those:
125
126
 
126
127
  ```sh
127
128
  earnrate offers --cash
128
129
  ```
129
130
 
130
- Ranks the welcome offers on cards you don't have by first-year value: the bonus at your point values minus the first year's annual fee. `--points`, `--no-fee` and `--business` narrow it down. Each offer is the public one on the issuer's page, checked weekly like the earning rules, with the issuer's eligibility rules in its note. Offers can differ by person and by how you apply, and it covers only the cards earnrate knows. There are no affiliate or apply links.
131
+ Ranks the welcome offers on cards you don't have by first-year value: the bonus at your point values minus the first year's annual fee (and a membership the card needs, if you don't have it). An offer the issuer rules out because of a card in your wallet is marked "likely not eligible" and listed last. `--points`, `--no-fee` and `--business` narrow it down. Each offer is the public one on the issuer's page, checked weekly like the earning rules, with the issuer's eligibility rules in its note. Offers can differ by person and by how you apply, and it covers only the cards earnrate knows. There are no affiliate or apply links.
131
132
 
132
133
  ### Import a statement
133
134
 
@@ -140,8 +141,8 @@ earnrate import gold.csv:amex-gold sapphire.csv:chase-sapphire-preferred
140
141
 
141
142
  It totals what each card earned against the best card in your wallet for each purchase, lists the merchants where another card would have earned the most, and flags merchants your issuer codes differently than earnrate assumes. Name several statements, each with its card, to get one report across them. It reads Amex and Chase activity CSVs. Download the CSV rather than a PDF statement: Chase's PDFs print the full card number in the payment coupon, and earnrate never handles card numbers.
142
143
 
143
- - It's a dry run unless you pass `--apply`, which records cap usage for the current period in your wallet. Refunds lower it.
144
- - Only the date, description, amount, category and (for Chase) transaction type columns are read. The statements themselves are never stored: `--apply` writes cap totals plus a hash of each recorded purchase to `~/.earnrate/imported.json`, so importing the same file twice never counts twice.
144
+ - It's a dry run unless you pass `--apply`, which records cap usage for the current period and spending toward an open welcome bonus in your wallet. Refunds lower both.
145
+ - Only the date, description, amount, category and (for Chase) transaction type columns are read. The statements themselves are never stored: `--apply` writes cap and bonus totals plus a hash of each recorded purchase to `~/.earnrate/imported.json`, so importing the same file twice never counts twice.
145
146
  - For a merchant earnrate doesn't know, it uses the statement's category and marks the row, since another issuer may code it differently.
146
147
  - `--valuation points-heavy` values points at transfer rates instead of your wallet's values, which can change which card wins.
147
148
 
@@ -175,7 +176,7 @@ claude mcp add --transport http earnrate https://mcp.earnrate.dev
175
176
 
176
177
  Or install it as a Claude Code plugin, which connects Claude Code and Cowork to the same server: `/plugin marketplace add cwasley/earnrate`, then `/plugin install earnrate@earnrate`.
177
178
 
178
- **Or run it on your computer:** `earnrate mcp` runs earnrate as a local MCP server over stdio, with 13 tools (the connector has the same ones, plus `export_wallet`, `import_wallet` and `delete_wallet` for its saved wallets):
179
+ **Or run it on your computer:** `earnrate mcp` runs earnrate as a local MCP server over stdio, with 13 tools (the connector has the same ones, plus `export_wallet`, `import_wallet`, `delete_wallet` and `switch_wallet` for its saved wallets):
179
180
 
180
181
  - `best_card_for_purchase`: which card to use, with reasons
181
182
  - `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
@@ -224,7 +225,7 @@ merchant text
224
225
  - **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.
225
226
  - **The engine is pure and deterministic.** Given a purchase, a date and a set of cards (yours, or every card), it checks every rule against network acceptance, the date window, caps already used (split when a purchase straddles one), quarterly picks and activations, then values points at your cents-per-point, including pooling (Freedom Unlimited points are worth Sapphire points when you hold both). Ties, the valuation at which the runner-up would win, stale data, and a rotating card whose new quarter isn't in the data yet all show up in the answer.
226
227
  - **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.
227
- - **Drift detection.** A weekly GitHub Action opens each issuer page in a headless browser, checks every quote is still there, and opens an issue when terms move. It stores only hashes of issuer text.
228
+ - **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. A daily one checks earnrate.dev and the hosted server end to end (sign in, ask, save a card, delete the wallet) and opens an issue if anything fails.
228
229
  - **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.
229
230
 
230
231
  The code is TypeScript run directly by Node 24 (`src/`), with the engine in `src/engine/`, merchant resolution in `src/merchants/`, and the CLI and MCP server as thin layers over them. `site/` is earnrate.dev, which runs the same engine in the browser, and `remote/` is the hosted server at mcp.earnrate.dev.
@@ -242,14 +243,14 @@ Rotating categories (Freedom Flex) are added each quarter once the issuer posts
242
243
 
243
244
  ## Privacy Policy
244
245
 
245
- earnrate needs no account. The local version has no telemetry; the hosted server counts requests by tool name only. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
246
+ earnrate needs no account. The local version has no telemetry; the hosted server counts requests by method and tool name only. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
246
247
 
247
- - **The Claude connector:** earnrate's server at mcp.earnrate.dev receives each question Claude asks it (a store or kind of purchase, and any amount, spending or cards you gave) and answers without keeping it; request logging is off. It counts requests by tool name only. A saved wallet is optional and anonymous (a recovery code, no email), holds card IDs and the details you gave, never card numbers, and is encrypted with its own key that only your recovery code or your connected apps can unlock. Delete it any time; it's deleted automatically after 13 months unused, or a week if it's never used.
248
+ - **The Claude connector:** earnrate's server at mcp.earnrate.dev receives each question Claude asks it (a store or kind of purchase, and any amount, spending or cards you gave) and answers without keeping it; request logging is off. It counts requests by method and tool name only. Connecting it starts an anonymous wallet (a recovery code, no email). It holds cards only if you save some: card IDs and the details you gave, never card numbers, and is encrypted with its own key that only your recovery code or your connected apps can unlock. Delete it any time; it's deleted automatically after 13 months unused, or a week if it's never used.
248
249
  - **What it stores locally:** your wallet, a YAML file on your computer (`~/.earnrate/wallet.yaml` unless you set `EARNRATE_WALLET`). It lists cards by product ID, like `amex-gold`, plus your category picks, activations and how much you've spent toward caps. earnrate never asks for or stores card numbers, expiry dates, security codes or bank logins.
249
250
  - **What the local version sends:** nothing, by default. The optional merchant lookup (off unless you turn it on with your own Anthropic API key) sends only the name of a store earnrate doesn't recognize to Anthropic's API, and saves the answer in `~/.earnrate/merchant-cache.json`.
250
251
  - **AI assistants:** when you use earnrate through Claude or another assistant, that assistant's provider sees the conversation, including earnrate's answers, under its own privacy policy.
251
252
  - **earnrate.dev:** everything you enter on the site stays in your browser. The site counts visits with Cloudflare Web Analytics, without cookies.
252
- - **Retention:** locally, your data lives only in files you control, for as long as you keep them. Delete the wallet file, or clear the site's data in your browser, to remove it. A saved wallet on mcp.earnrate.dev lasts until you delete it or leave it unused for 13 months.
253
+ - **Retention:** locally, your data lives only in files you control, for as long as you keep them. Delete the wallet file, or clear the site's data in your browser, to remove it. A saved wallet on mcp.earnrate.dev lasts until you delete it or leave it unused for 13 months (a week, if it's never used); the record that an app is connected goes after 90 days unused.
253
254
  - **Terms:** [earnrate.dev/terms](https://earnrate.dev/terms).
254
255
  - **Contact:** [hello@earnrate.dev](mailto:hello@earnrate.dev).
255
256
 
package/data/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
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.11.01.3
6
+
7
+ Welcome offers can name other cards whose holders the issuer rules out (`welcome_offer.not_with`), so offer comparisons mark them and list them last for a wallet holding one. No rates changed.
8
+
9
+ | Card | Field | Old → new | Source |
10
+ | ----------------------------------------------------------------------- | ---------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
11
+ | amex-blue-cash-everyday | welcome_offer.not_with | amex-blue-cash-preferred | [offer terms](https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/blue-cash-everyday-credit-card/25330-10-0) |
12
+ | chase-southwest-plus, chase-southwest-priority, chase-southwest-premier | welcome_offer.not_with | the other two personal Southwest cards | [Plus](https://creditcards.chase.com/travel-credit-cards/southwest/plus), [Priority](https://creditcards.chase.com/travel-credit-cards/southwest/priority), [Premier](https://creditcards.chase.com/travel-credit-cards/southwest/premier) |
13
+ | amex-blue-cash-everyday | welcome_offer evidence | card page headline now "As High As $200"; same offer | [card page](https://www.americanexpress.com/us/credit-cards/card/blue-cash-everyday/) |
14
+
15
+ ## 2026.11.01.2
16
+
17
+ Chipotle may post as fast food (MCC 5814) instead of a restaurant, so answers there say which card wins either way. No rates changed.
18
+
19
+ | Merchant | Field | Old → new | Source |
20
+ | -------- | --------- | --------------------- | ------ |
21
+ | chipotle | ambiguity | may code as fast food | n/a |
22
+
23
+ ## 2026.11.01.1
24
+
25
+ Freedom Flex's Q4 dining note quotes Chase's "5% total" and says where the 7% figure comes from. No rates changed.
26
+
27
+ | Card | Field | Old → new | Source |
28
+ | ------------------ | ------------------- | -------------------- | ------------------------------------------------------------------------------ |
29
+ | chase-freedom-flex | q4-2026-dining.note | quotes Chase's terms | [card page](https://creditcards.chase.com/cash-back-credit-cards/freedom/flex) |
30
+
31
+ ## 2026.11.01
32
+
33
+ Cards can name a paid membership they need (`requires_membership`). Costco Anywhere needs a Costco membership, $65 a year for Gold Star; spending answers count it unless the person has one or shops at Costco. The AAdvantage Globe card page reworded its earning lines; no rates changed.
34
+
35
+ | Card | Field | Old → new | Source |
36
+ | ------------------------------------------------------------- | ------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
37
+ | citi-costco-anywhere-visa, citi-costco-anywhere-visa-business | requires_membership | Costco membership, $65 a year | [Citi card page](https://www.citi.com/credit-cards/citi-costco-anywhere-visa-credit-card), [Costco membership page](https://www.costco.com/join-costco.html) |
38
+ | citi-aadvantage-globe | evidence | quotes follow Citi's new wording (same rates) | [card page](https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard) |
39
+
5
40
  ## 2026.10.30.3
6
41
 
7
42
  More everyday words find categories, for questions asked without a store name ("coffee shop", "dinner", "train"). Each maps to the category those purchases already fall under. No rates changed.
package/data/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.10.30.3
1
+ 2026.11.01.3
@@ -15,7 +15,8 @@ welcome_offer:
15
15
  spend: 2000
16
16
  months: 6
17
17
  varies: true
18
- note: 'Paid as Reward Dollars, redeemable for a statement credit or at Amazon.com checkout. The card page says "Earn $200"; the offer terms say "as high as $200", with the exact amount shown before you accept. Not available if you have or have had this Card, the Cash Magnet Card, the Blue Cash Preferred Card (including the Morgan Stanley version) or previous versions of these Cards.'
18
+ not_with: [amex-blue-cash-preferred]
19
+ note: 'Paid as Reward Dollars, redeemable for a statement credit or at Amazon.com checkout. The card page and offer terms say "as high as $200", with the exact amount shown before you accept; the card page has also shown "Earn $200". Not available if you have or have had this Card, the Cash Magnet Card, the Blue Cash Preferred Card (including the Morgan Stanley version) or previous versions of these Cards.'
19
20
  rules:
20
21
  - id: us-supermarkets
21
22
  match: { categories: [grocery.supermarket] }
@@ -76,14 +77,14 @@ evidence:
76
77
  - rule: annual_fee
77
78
  url: https://www.americanexpress.com/us/credit-cards/card/blue-cash-everyday/
78
79
  quote: 'No Annual Fee'
79
- - rule: welcome_offer
80
- url: https://www.americanexpress.com/us/credit-cards/card/blue-cash-everyday/
81
- quote: 'Earn $200 Cash Back'
82
80
  - rule: welcome_offer
83
81
  url: https://www.americanexpress.com/us/credit-cards/card/blue-cash-everyday/
84
82
  quote: 'after you spend $2,000 in purchases on your new Card in the first 6 months of Card Membership'
85
83
  - rule: welcome_offer
86
84
  url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/blue-cash-everyday-credit-card/25330-10-0
87
85
  quote: 'As High as $200 Cash Back Welcome Offer'
86
+ - rule: not_with
87
+ url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/blue-cash-everyday-credit-card/25330-10-0
88
+ quote: 'if you have or have had this Card, the Cash Magnet® Card, the Blue Cash Preferred® Card'
88
89
  todo:
89
90
  - 'Amazon is filed as online retail, so it earns 3% here; superstore sites (walmart.com, target.com) and online drugstores may too, but the terms define the category by merchant code and type of goods, so they are not modeled.'
@@ -66,7 +66,7 @@ rules:
66
66
  requires: { activation: true }
67
67
  valid_from: 2026-10-01
68
68
  valid_to: 2026-12-31
69
- note: Chase says activated categories earn 5% total (4% on top of the 1% base) and doesn't say whether that stacks with the 3% dining rate, so earnrate shows 5%, not 7%, until the $1,500 quarterly cap.
69
+ note: Chase's terms say activated categories earn "5% Cash Back rewards total" (4% on top of the 1% base), so earnrate shows 5% until the $1,500 quarterly cap. Some blogs report 7% for dining (the 3% dining rate plus the 4% bonus); Chase's own terms don't say that.
70
70
  - id: q4-2026-american-red-cross
71
71
  match: { merchants: [american-red-cross] }
72
72
  rate: 5
@@ -14,6 +14,7 @@ welcome_offer:
14
14
  points: 80000
15
15
  spend: 1000
16
16
  months: 3
17
+ not_with: [chase-southwest-priority, chase-southwest-premier]
17
18
  note: 'Chase labels it a limited-time offer and states no end date. Not available to current cardmembers of any Southwest Rapid Rewards Credit Card, or previous cardmembers of any Southwest Rapid Rewards Credit Card who received a new Cardmember bonus within the last 24 months (this does not apply to business cards).'
18
19
  caps:
19
20
  - id: gas-and-grocery
@@ -70,3 +71,6 @@ evidence:
70
71
  - rule: lyft
71
72
  url: https://creditcards.chase.com/travel-credit-cards/southwest/plus
72
73
  quote: 'earn 2X Rapid Rewards points on qualifying Lyft products and services purchased through the Lyft mobile application through 12/31/2027'
74
+ - rule: not_with
75
+ url: https://creditcards.chase.com/travel-credit-cards/southwest/plus
76
+ quote: 'not available to either (i) current Cardmembers of any Southwest Rapid Rewards® Credit Card'
@@ -14,6 +14,7 @@ welcome_offer:
14
14
  points: 85000
15
15
  spend: 2000
16
16
  months: 3
17
+ not_with: [chase-southwest-plus, chase-southwest-priority]
17
18
  note: 'Chase labels it a limited-time offer and states no end date. Not available to current cardmembers of any Southwest Rapid Rewards Credit Card, or previous cardmembers of any Southwest Rapid Rewards Credit Card who received a new Cardmember bonus within the last 24 months (this does not apply to business cards).'
18
19
  caps:
19
20
  - id: grocery-and-dining
@@ -70,3 +71,6 @@ evidence:
70
71
  - rule: lyft
71
72
  url: https://creditcards.chase.com/travel-credit-cards/southwest/premier
72
73
  quote: 'earn 3X Rapid Rewards points on qualifying Lyft products and services purchased through the Lyft mobile application through 12/31/2027'
74
+ - rule: not_with
75
+ url: https://creditcards.chase.com/travel-credit-cards/southwest/premier
76
+ quote: 'not available to either (i) current Cardmembers of any Southwest Rapid Rewards® Credit Card'
@@ -13,6 +13,7 @@ welcome_offer:
13
13
  points: 90000
14
14
  spend: 3000
15
15
  months: 3
16
+ not_with: [chase-southwest-plus, chase-southwest-premier]
16
17
  note: 'Chase labels it a limited-time offer and states no end date. Not available to current cardmembers of any Southwest Rapid Rewards Credit Card, or previous cardmembers of any Southwest Rapid Rewards Credit Card who received a new Cardmember bonus within the last 24 months (this does not apply to business cards).'
17
18
  rules:
18
19
  - id: southwest
@@ -56,3 +57,6 @@ evidence:
56
57
  - rule: lyft
57
58
  url: https://creditcards.chase.com/travel-credit-cards/southwest/priority
58
59
  quote: 'earn 5X Rapid Rewards points on qualifying Lyft products and services purchased through the Lyft mobile application through 12/31/2027'
60
+ - rule: not_with
61
+ url: https://creditcards.chase.com/travel-credit-cards/southwest/priority
62
+ quote: 'not available to either (i) current Cardmembers of any Southwest Rapid Rewards® Credit Card'
@@ -3,8 +3,8 @@ name: Citi / AAdvantage Globe Mastercard
3
3
  issuer: citi
4
4
  network: mastercard
5
5
  currency: aadvantage-miles
6
- last_verified: 2026-10-26
7
- verified_by: collin
6
+ last_verified: 2026-11-01
7
+ verified_by: claude
8
8
  source_urls:
9
9
  - https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard
10
10
  base_rate: 1
@@ -35,22 +35,22 @@ rules:
35
35
  evidence:
36
36
  - rule: base_rate
37
37
  url: https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard
38
- quote: 'Earn 1 AAdvantage® mile for every $1 spent on all other purchases.'
38
+ quote: 'Earn 1 AAdvantage® mile for every $1 spent on all other purchases'
39
39
  - rule: aadvantage-hotels
40
40
  url: https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard
41
- quote: 'Earn 6 AAdvantage® miles per $1 spent on eligible AAdvantage Hotels™ bookings'
41
+ quote: 'Earn 6 AAdvantage® miles for every $1 spent on eligible AAdvantage Hotels™ bookings'
42
42
  - rule: american-airlines
43
43
  url: https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard
44
- quote: '3 AAdvantage® miles per $1 spent on eligible American Airlines purchases'
44
+ quote: '3 AAdvantage® miles for every $1 spent on eligible American Airlines purchases'
45
45
  - rule: american-airlines
46
46
  url: https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard
47
47
  quote: 'Eligible American Airlines purchases are items billed by American Airlines as the merchant of record.'
48
48
  - rule: restaurants
49
49
  url: https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard
50
- quote: 'Earn 2 AAdvantage® miles for every $1 spent at restaurants, including takeout and delivery.'
50
+ quote: 'Earn 2 AAdvantage® miles for every $1 spent at restaurants, including takeout and delivery'
51
51
  - rule: rides-and-rails
52
52
  url: https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard
53
- quote: 'Earn 2 AAdvantage® miles for every $1 spent on eligible Rides and Rails™ purchases, including taxis, rideshares and public transit.'
53
+ quote: 'Earn 2 AAdvantage® miles for every $1 spent on eligible Rides and Rails™ purchases, including taxis, rideshares and public transit'
54
54
  - rule: rides-and-rails
55
55
  url: https://www.citi.com/credit-cards/citi-aadvantage-globe-mastercard
56
56
  quote: 'passenger railways, bridge and road tolls, parking lots'
@@ -7,11 +7,19 @@ last_verified: 2026-10-31
7
7
  verified_by: claude
8
8
  source_urls:
9
9
  - https://www.citi.com/credit-cards/costco-anywhere-visa-business-card
10
+ - https://www.costco.com/join-costco.html
10
11
  # Paid once a year as a Costco reward certificate after the February statement,
11
12
  # redeemable for cash or merchandise at a U.S. Costco warehouse.
12
13
  base_rate: 1
13
14
  business: true
14
15
  annual_fee: 0
16
+ # Gold Star, the basic tier. Executive ($130) pays some of its cost back as
17
+ # Costco's own 2% reward, which earnrate doesn't model.
18
+ requires_membership:
19
+ id: costco
20
+ name: a Costco membership
21
+ annual_fee: 65
22
+ members_shop_at: [costco, costco-gas, costco-travel]
15
23
  caps:
16
24
  # Costco gas, other gas and EV charging share one $7,000 cap. Rewards run on
17
25
  # January–December billing statements.
@@ -47,7 +55,7 @@ rules:
47
55
  rate: 2
48
56
  note: All other purchases at Costco warehouses and Costco.com.
49
57
  todo:
50
- - 'The $0 annual fee requires an active, paid Costco membership. The card page shows no welcome offer.'
58
+ - 'The card page shows no welcome offer.'
51
59
  evidence:
52
60
  - rule: base_rate
53
61
  url: https://www.citi.com/credit-cards/costco-anywhere-visa-business-card
@@ -85,3 +93,9 @@ evidence:
85
93
  - rule: annual_fee
86
94
  url: https://www.citi.com/credit-cards/costco-anywhere-visa-business-card
87
95
  quote: '$0 annual fee with active Costco membership.'
96
+ - rule: requires_membership
97
+ url: https://www.citi.com/credit-cards/costco-anywhere-visa-business-card
98
+ quote: 'No annual fee with paid Costco membership.'
99
+ - rule: requires_membership
100
+ url: https://www.costco.com/join-costco.html
101
+ quote: 'Gold Star $65/yr'
@@ -7,10 +7,18 @@ last_verified: 2026-10-26
7
7
  verified_by: collin
8
8
  source_urls:
9
9
  - https://www.citi.com/credit-cards/citi-costco-anywhere-visa-credit-card
10
+ - https://www.costco.com/join-costco.html
10
11
  # Paid once a year as a Costco reward certificate after the February statement,
11
12
  # redeemable for cash or merchandise at a U.S. Costco warehouse.
12
13
  base_rate: 1
13
14
  annual_fee: 0
15
+ # Gold Star, the basic tier. Executive ($130) pays some of its cost back as
16
+ # Costco's own 2% reward, which earnrate doesn't model.
17
+ requires_membership:
18
+ id: costco
19
+ name: a Costco membership
20
+ annual_fee: 65
21
+ members_shop_at: [costco, costco-gas, costco-travel]
14
22
  caps:
15
23
  # Costco gas, other gas and EV charging share one $7,000 cap. Rewards run on
16
24
  # January–December billing statements.
@@ -46,7 +54,7 @@ rules:
46
54
  rate: 2
47
55
  note: All other purchases at Costco warehouses and Costco.com.
48
56
  todo:
49
- - 'The $0 annual fee requires an active, paid Costco membership. The card page shows no welcome offer.'
57
+ - 'The card page shows no welcome offer.'
50
58
  evidence:
51
59
  - rule: base_rate
52
60
  url: https://www.citi.com/credit-cards/citi-costco-anywhere-visa-credit-card
@@ -84,3 +92,9 @@ evidence:
84
92
  - rule: annual_fee
85
93
  url: https://www.citi.com/credit-cards/citi-costco-anywhere-visa-credit-card
86
94
  quote: '$0 annual fee with active Costco membership.'
95
+ - rule: requires_membership
96
+ url: https://www.citi.com/credit-cards/citi-costco-anywhere-visa-credit-card
97
+ quote: 'No annual fee with paid Costco membership.'
98
+ - rule: requires_membership
99
+ url: https://www.costco.com/join-costco.html
100
+ quote: 'Gold Star $65/yr'
@@ -160,6 +160,14 @@
160
160
  may_code_as: [dining, entertainment]
161
161
  note: Restaurants inside a hotel may code as dining, or as lodging if charged to the room. Shows and casino charges code separately.
162
162
 
163
+ - id: chipotle
164
+ name: Chipotle
165
+ aliases: [chipotle mexican grill]
166
+ category: dining
167
+ ambiguity:
168
+ may_code_as: [dining.fast_food]
169
+ note: Fast-casual chains can post as fast food (MCC 5814) instead of a restaurant (5812); a statement shows which.
170
+
163
171
  - id: comcast
164
172
  name: Comcast Xfinity
165
173
  aliases: [comcast, xfinity]
@@ -45,12 +45,6 @@
45
45
  }
46
46
 
47
47
  # Restaurants and fast food
48
- - {
49
- id: chipotle,
50
- name: Chipotle,
51
- aliases: [chipotle mexican grill],
52
- category: dining,
53
- }
54
48
  - { id: sweetgreen, name: Sweetgreen, category: dining }
55
49
  - {
56
50
  id: cheesecake-factory,
@@ -15,7 +15,7 @@ name with --cards, or else from every card earnrate knows.
15
15
 
16
16
  Options:
17
17
  --cards <ids> Use these cards instead of your wallet, comma separated.
18
- --member <name> A membership you have, like amazon-prime. Repeatable.
18
+ --member <name> A membership you have, like amazon-prime or costco. Repeatable.
19
19
  --valuation <profile> flat or points-heavy, instead of your wallet's values.
20
20
  --date <YYYY-MM-DD> The day to check. Default: today.
21
21
  --json Print the cheat sheet as JSON.
@@ -12,7 +12,7 @@ const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or point
12
12
  --issuer <id> One issuer's cards, e.g. chase or amex.
13
13
  --business Business cards instead of personal ones.
14
14
  --closed Include cards the issuer no longer takes applications for.
15
- --member <name> A membership you have, e.g. amazon-prime. Repeatable.
15
+ --member <name> A membership you have, e.g. amazon-prime or costco. Repeatable.
16
16
  --valuation <profile> flat (points at 1¢, the default) or points-heavy
17
17
  (MR and UR at 1.7¢).`;
18
18
  const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
@@ -22,13 +22,14 @@ Reads card statements (Amex and Chase activity CSVs) and reports:
22
22
 
23
23
  Name several statements, each with its card, to see one report across them.
24
24
 
25
- Nothing is written unless you pass --apply, and then only cap totals plus a
26
- fingerprint of each recorded purchase, so importing twice never counts twice.
25
+ Nothing is written unless you pass --apply, and then only cap totals,
26
+ welcome-bonus spending and a fingerprint of each recorded purchase, so
27
+ importing twice never counts twice.
27
28
  The statements themselves are never stored.
28
29
 
29
30
  Options:
30
31
  --card <id> The card a statement is for, as in your wallet.
31
- --apply Record cap usage for the current period.
32
+ --apply Record cap usage and welcome-bonus spending.
32
33
  --valuation <profile> flat or points-heavy, instead of your wallet's values.
33
34
  -h, --help Show this help.
34
35
  `;
package/dist/cli/run.js CHANGED
@@ -42,7 +42,7 @@ Save your cards (optional):
42
42
  ${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
43
43
  ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
44
44
  ${CLI_NAME} remove <card-id> Remove a card.
45
- ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
45
+ ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime, costco).
46
46
 
47
47
  Other commands:
48
48
  ${CLI_NAME} cheatsheet The best card for each category this quarter.
@@ -1,3 +1,4 @@
1
+ import { WALLET_VERSION } from "../schema/wallet.js";
1
2
  function duplicates(ids) {
2
3
  const seen = new Set();
3
4
  const dupes = new Set();
@@ -56,6 +57,11 @@ function cardProblems(card, catalog, merchantIds) {
56
57
  if (!currencies.has(card.currency)) {
57
58
  problems.push(`${where}: unknown currency '${card.currency}'`);
58
59
  }
60
+ for (const id of card.welcome_offer?.not_with ?? [])
61
+ if (!cardIds.has(id))
62
+ problems.push(`${where} welcome_offer.not_with: unknown card '${id}'`);
63
+ for (const m of card.requires_membership?.members_shop_at ?? [])
64
+ checkMerchant(m, 'requires_membership');
59
65
  if (card.pools_into) {
60
66
  if (!currencies.has(card.pools_into.currency)) {
61
67
  problems.push(`${where} pools_into: unknown currency '${card.pools_into.currency}'`);
@@ -79,6 +85,8 @@ function cardProblems(card, catalog, merchantIds) {
79
85
  ...(card.annual_fee !== undefined ? ['annual_fee'] : []),
80
86
  ...(card.first_year_fee !== undefined ? ['first_year_fee'] : []),
81
87
  ...(card.welcome_offer ? ['welcome_offer'] : []),
88
+ ...(card.welcome_offer?.not_with ? ['not_with'] : []),
89
+ ...(card.requires_membership ? ['requires_membership'] : []),
82
90
  ...card.rules.map((r) => r.id),
83
91
  ]);
84
92
  const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
@@ -188,6 +196,8 @@ export function walletProblems(wallet, catalog) {
188
196
  const problems = [];
189
197
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
190
198
  const currencies = new Map(catalog.currencies.map((c) => [c.id, c]));
199
+ if ((wallet.version ?? 1) > WALLET_VERSION)
200
+ problems.push(`wallet: saved by a newer earnrate (format ${String(wallet.version)}); update earnrate to read it`);
191
201
  for (const id of duplicates(wallet.cards.map((c) => c.id))) {
192
202
  problems.push(`wallet: card '${id}' is listed twice`);
193
203
  }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { isMap, isSeq, parseDocument, stringify } from 'yaml';
5
5
  import { applyBonusBooking, applyBooking, } from "../engine/record.js";
6
- import { WalletSchema } from "../schema/wallet.js";
6
+ import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
7
7
  const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
8
8
  # Format: https://github.com/cwasley/earnrate#your-wallet
9
9
 
@@ -57,6 +57,12 @@ function fieldLines(field, f, indent) {
57
57
  `${indent} spent: ${String(b.spent)}\n`,
58
58
  b.spend === undefined ? '' : `${indent} spend: ${String(b.spend)}\n`,
59
59
  b.by === undefined ? '' : `${indent} by: ${scalar(b.by)}\n`,
60
+ b.points === undefined
61
+ ? ''
62
+ : `${indent} points: ${String(b.points)}\n`,
63
+ b.dollars === undefined
64
+ ? ''
65
+ : `${indent} dollars: ${String(b.dollars)}\n`,
60
66
  ].join('');
61
67
  }
62
68
  case 'chosen': {
@@ -100,7 +106,7 @@ const column = (text, at) => at - lineStart(text, at);
100
106
  // Appends a card after the last one, matching the list's indentation.
101
107
  export function addCardText(text, entry) {
102
108
  if (text === null || text.trim() === '') {
103
- return `${NEW_WALLET}cards:\n${cardLines(entry, ' ')}`;
109
+ return `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards:\n${cardLines(entry, ' ')}`;
104
110
  }
105
111
  const { cards } = cardsSeq(text);
106
112
  const first = cards.items[0];
@@ -1,8 +1,10 @@
1
1
  import { resolveMerchantById } from "../merchants/resolve.js";
2
2
  import { categoryResolution, exploreCards, } from "./explore.js";
3
+ import { bonusStatus } from "./bonus.js";
3
4
  import { periodKey } from "./periods.js";
4
5
  import { recommend } from "./recommend.js";
5
6
  import { setupNotes } from "./setup.js";
7
+ import { listed, money, shortDate } from "./text.js";
6
8
  const CHEAT_SHEET_ITEMS = [
7
9
  { label: 'Groceries', category: 'grocery.supermarket' },
8
10
  { label: 'Dining', category: 'dining' },
@@ -17,6 +19,7 @@ const CHEAT_SHEET_ITEMS = [
17
19
  // The best card in the wallet for each everyday kind of purchase on `date`,
18
20
  // with caps, picks and activations as the wallet has them.
19
21
  export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
22
+ const ranked = [];
20
23
  const rows = items.map((item) => {
21
24
  const merchant = 'merchant' in item
22
25
  ? resolveMerchantById(catalog, item.merchant, null)
@@ -30,7 +33,10 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
30
33
  date,
31
34
  country: 'US',
32
35
  }, merchant);
33
- const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId);
36
+ // The card that earns the most, even when a welcome bonus would take the
37
+ // pick for one purchase: the sheet is for the whole quarter.
38
+ const top = rec.decision ? rec.ranked[0] : undefined;
39
+ ranked.push({ label: item.label, cards: rec.ranked });
34
40
  return {
35
41
  label: item.label,
36
42
  cardId: top?.cardId ?? null,
@@ -40,8 +46,44 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
40
46
  note: top?.warnings[0] ?? null,
41
47
  };
42
48
  });
49
+ const bonuses = wallet.cards.flatMap((held) => {
50
+ const status = bonusStatus(catalog, wallet, held, date);
51
+ if (status?.state !== 'open')
52
+ return [];
53
+ const name = held.checkout_label ??
54
+ catalog.cards.find((c) => c.id === held.id)?.name ??
55
+ held.id;
56
+ // Rows where the bonus card matches the row's card, or comes within a
57
+ // point and three quarters of its rate (as single answers mention it).
58
+ const matches = [];
59
+ const near = [];
60
+ for (const { label, cards } of ranked) {
61
+ const best = cards[0]?.effectiveReturnPct;
62
+ const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
63
+ if (best === undefined || own === undefined)
64
+ continue;
65
+ if (best - own < 1e-9)
66
+ matches.push(label.toLowerCase());
67
+ else if (best - own <= 1 && own >= best * 0.75)
68
+ near.push(label.toLowerCase());
69
+ }
70
+ const where = [
71
+ ...(matches.length > 0
72
+ ? [`matches the listed card on ${listed(matches)}`]
73
+ : []),
74
+ ...(near.length > 0 ? [`comes within a point on ${listed(near)}`] : []),
75
+ ];
76
+ return [
77
+ `Working on ${name}'s welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
78
+ ? `It ${where.join(' and ')}, so those are the places to use it.`
79
+ : 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
80
+ ];
81
+ });
43
82
  const notes = [
44
- ...new Set(wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text))),
83
+ ...new Set([
84
+ ...bonuses,
85
+ ...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text)),
86
+ ]),
45
87
  ];
46
88
  const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
47
89
  return {