earnrate 0.2.5 → 0.2.6

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 (105) hide show
  1. package/README.md +14 -11
  2. package/data/CHANGELOG.md +40 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +14 -6
  5. package/data/cards/amex-blue-cash-everyday.yaml +12 -3
  6. package/data/cards/amex-blue-cash-preferred.yaml +17 -3
  7. package/data/cards/amex-gold.yaml +19 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
  13. package/data/cards/bilt-obsidian.yaml +4 -3
  14. package/data/cards/capital-one-kohls-card.yaml +11 -2
  15. package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
  16. package/data/cards/capital-one-savor.yaml +32 -10
  17. package/data/cards/capital-one-venture-x.yaml +19 -4
  18. package/data/cards/capital-one-venture.yaml +19 -5
  19. package/data/cards/chase-aeroplan.yaml +15 -3
  20. package/data/cards/chase-freedom-flex.yaml +50 -10
  21. package/data/cards/chase-freedom-unlimited.yaml +6 -2
  22. package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
  23. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
  24. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +8 -2
  26. package/data/cards/chase-ink-business-cash.yaml +7 -3
  27. package/data/cards/chase-ink-business-preferred.yaml +9 -2
  28. package/data/cards/chase-prime-visa.yaml +24 -5
  29. package/data/cards/chase-sapphire-preferred.yaml +59 -6
  30. package/data/cards/chase-sapphire-reserve.yaml +12 -2
  31. package/data/cards/chase-southwest-performance-business.yaml +22 -4
  32. package/data/cards/chase-southwest-plus.yaml +13 -3
  33. package/data/cards/chase-southwest-premier-business.yaml +22 -5
  34. package/data/cards/chase-southwest-premier.yaml +13 -3
  35. package/data/cards/chase-southwest-priority.yaml +18 -2
  36. package/data/cards/chase-united-business.yaml +5 -2
  37. package/data/cards/chase-united-club-business.yaml +2 -2
  38. package/data/cards/chase-united-club.yaml +16 -3
  39. package/data/cards/chase-united-explorer.yaml +5 -2
  40. package/data/cards/chase-united-gateway.yaml +2 -2
  41. package/data/cards/chase-united-quest.yaml +12 -3
  42. package/data/cards/citi-aadvantage-business.yaml +10 -4
  43. package/data/cards/citi-aadvantage-executive.yaml +8 -8
  44. package/data/cards/citi-aadvantage-mileup.yaml +7 -4
  45. package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
  46. package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
  47. package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
  48. package/data/cards/citi-custom-cash.yaml +43 -12
  49. package/data/cards/citi-double-cash.yaml +9 -3
  50. package/data/cards/citi-my-best-buy-visa.yaml +6 -3
  51. package/data/cards/citi-strata-elite.yaml +12 -2
  52. package/data/cards/citi-strata-premier.yaml +24 -2
  53. package/data/cards/sofi-unlimited-2.yaml +17 -3
  54. package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
  56. package/data/cards/td-bank-target-circle-card.yaml +13 -4
  57. package/data/cards/usbank-cash-plus.yaml +29 -7
  58. package/data/cards/wells-fargo-active-cash.yaml +9 -2
  59. package/data/cards/wells-fargo-autograph.yaml +31 -7
  60. package/data/issuers.yaml +32 -0
  61. package/data/merchants/seed.yaml +7 -0
  62. package/dist/cli/bin.js +10 -1
  63. package/dist/cli/cheatsheet.js +10 -5
  64. package/dist/cli/explore.js +6 -13
  65. package/dist/cli/import.js +2 -2
  66. package/dist/cli/offers.js +17 -7
  67. package/dist/cli/run.js +14 -17
  68. package/dist/cli/wallet.js +8 -5
  69. package/dist/data/file-store.js +6 -7
  70. package/dist/data/integrity.js +4 -0
  71. package/dist/data/load.js +4 -1
  72. package/dist/data/store.js +6 -22
  73. package/dist/data/wallet-edit.js +38 -13
  74. package/dist/engine/cheat-sheet.js +4 -5
  75. package/dist/engine/compare.js +2 -0
  76. package/dist/engine/evaluate.js +2 -4
  77. package/dist/engine/explore.js +4 -36
  78. package/dist/engine/memberships.js +1 -1
  79. package/dist/engine/offers.js +2 -1
  80. package/dist/engine/periods.js +18 -0
  81. package/dist/engine/recommend.js +14 -15
  82. package/dist/engine/setup.js +14 -24
  83. package/dist/engine/simulate.js +7 -6
  84. package/dist/engine/text.js +13 -1
  85. package/dist/engine/wallet-change.js +10 -12
  86. package/dist/engine/wallet-summary.js +64 -28
  87. package/dist/format/card-text.js +7 -20
  88. package/dist/format/explore.js +2 -2
  89. package/dist/format/offers.js +2 -2
  90. package/dist/format/recommendation.js +2 -2
  91. package/dist/format/wallet.js +1 -1
  92. package/dist/import/analyze.js +2 -1
  93. package/dist/mcp/context.js +30 -9
  94. package/dist/mcp/inputs.js +1 -1
  95. package/dist/mcp/schemas.js +1 -0
  96. package/dist/mcp/server.js +7 -1
  97. package/dist/mcp/tools/account.js +44 -16
  98. package/dist/mcp/tools/answers.js +58 -19
  99. package/dist/mcp/tools/wallet.js +58 -24
  100. package/dist/merchants/cache.js +0 -10
  101. package/dist/merchants/resolve.js +81 -4
  102. package/dist/schema/card.js +4 -1
  103. package/dist/schema/issuer.js +8 -0
  104. package/dist/schema/wallet.js +1 -1
  105. package/package.json +1 -1
@@ -3,8 +3,8 @@ name: U.S. Bank Cash+ Visa Signature Card
3
3
  issuer: usbank
4
4
  network: visa
5
5
  currency: cash
6
- last_verified: 2026-10-24
7
- verified_by: collin
6
+ last_verified: 2026-11-01
7
+ verified_by: claude
8
8
  source_urls:
9
9
  - https://www.usbank.com/credit-cards/cash-plus-visa-signature-credit-card.html
10
10
  - https://cashplus.usbank.com/cash-plus/samplemerchants
@@ -64,14 +64,16 @@ choices:
64
64
  rules:
65
65
  - id: five-percent
66
66
  match: { choice: five-percent }
67
+ # Ground transportation leaves out parking and tolls (also cruises and
68
+ # tickets bought through travel agencies); no other 5% option covers them.
69
+ exclude: { mccs: [7523, 4784] }
67
70
  rate: 5
68
71
  cap: five-percent-combined
69
72
  - id: two-percent
70
73
  match: { choice: two-percent }
71
- # Gas and grocery exclude discount stores/supercenters and wholesale clubs.
72
- # Grocery-store gas stations may still count as gas, per U.S. Bank's
73
- # sample merchants (Kroger Fuel Center is listed).
74
- exclude: { categories: [gas.superstore] }
74
+ # Fast food is its own 5% option, not part of Restaurants. Gas at grocery
75
+ # stores is left in until the todo below is settled.
76
+ exclude: { categories: [gas.superstore, dining.fast_food] }
75
77
  rate: 2
76
78
  - id: travel-center
77
79
  match: { merchants: [usbank-travel-center] }
@@ -94,9 +96,29 @@ evidence:
94
96
  - rule: five-percent
95
97
  url: https://cashplus.usbank.com/cash-plus/samplemerchants
96
98
  quote: 'This category includes only the merchants listed below'
99
+ - rule: five-percent
100
+ url: https://cashplus.usbank.com/cash-plus/samplemerchants
101
+ quote: 'steam ships, cruise lines, freight trains, parking meters, parking garages and toll stations'
102
+ - rule: five-percent
103
+ url: https://www.usbank.com/credit-cards/cash-plus-visa-signature-credit-card.html
104
+ quote: 'Purchases over $2,000 in the 5% categories will earn 1% cash back.'
105
+ - rule: two-percent
106
+ url: https://www.usbank.com/credit-cards/cash-plus-visa-signature-credit-card.html
107
+ quote: 'Gas station and electric vehicle charging station purchases at discount stores/supercenters, wholesale clubs and grocery stores/supermarkets'
108
+ - rule: two-percent
109
+ url: https://www.usbank.com/credit-cards/cash-plus-visa-signature-credit-card.html
110
+ quote: 'Excludes discount stores/supercenters and wholesale clubs'
111
+ - rule: two-percent
112
+ url: https://cashplus.usbank.com/cash-plus/samplemerchants
113
+ quote: "Excludes most fast food restaurants like McDonald's"
114
+ - rule: travel-center
115
+ url: https://www.usbank.com/credit-cards/cash-plus-visa-signature-credit-card.html
116
+ quote: 'additional 4% cash back for every $1 in eligible Net Purchases spent on prepaid air, car and hotel reservations'
97
117
  - rule: annual_fee
98
118
  url: https://www.usbank.com/credit-cards/cash-plus-visa-signature-credit-card.html
99
- quote: 'ANNUAL FEE $0 Annual fee'
119
+ quote: '$0 Annual fee'
100
120
  - rule: welcome_offer
101
121
  url: https://www.usbank.com/credit-cards/cash-plus-visa-signature-credit-card.html
102
122
  quote: 'Earn a $200 rewards bonus after you spend $1,000 in eligible purchases within the first 90 days of account opening'
123
+ todo:
124
+ - "Gas at grocery stores (Kroger, Hy-Vee, Meijer) is modeled at 2%, as U.S. Bank's sample merchants page says it may qualify, varying by location; the card page's footnote says 1%, so it may earn 1% at some stations."
@@ -3,10 +3,11 @@ name: Wells Fargo Active Cash Card
3
3
  issuer: wells-fargo
4
4
  network: visa
5
5
  currency: cash
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://creditcards.wellsfargo.com/active-cash-credit-card/
10
+ - https://www.wellsfargo.com/credit-cards/active-cash/terms/
10
11
  base_rate: 2
11
12
  annual_fee: 0
12
13
  welcome_offer:
@@ -23,6 +24,9 @@ evidence:
23
24
  - rule: base_rate
24
25
  url: https://creditcards.wellsfargo.com/active-cash-credit-card/
25
26
  quote: '2% cash rewards are earned for every $1 spent in net purchases'
27
+ - rule: base_rate
28
+ url: https://www.wellsfargo.com/credit-cards/active-cash/terms/
29
+ quote: 'For every $1.00 you spend with this Credit Card for net purchases, you earn two cents ($0.02) in Cash Rewards.'
26
30
  - rule: annual_fee
27
31
  url: https://creditcards.wellsfargo.com/active-cash-credit-card/
28
32
  quote: '$0 annual fee'
@@ -32,3 +36,6 @@ evidence:
32
36
  - rule: welcome_offer
33
37
  url: https://creditcards.wellsfargo.com/active-cash-credit-card/
34
38
  quote: 'To qualify for the $100 cash rewards bonus, a total of at least $500 in net purchases'
39
+ - rule: welcome_offer
40
+ url: https://www.wellsfargo.com/credit-cards/active-cash/terms/
41
+ quote: 'opened one within the last 48 months from the date of this application'
@@ -3,11 +3,12 @@ name: Wells Fargo Autograph Card
3
3
  issuer: wells-fargo
4
4
  network: visa
5
5
  currency: WFR
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://creditcards.wellsfargo.com/autograph-visa-credit-card/
10
10
  - https://www.wellsfargo.com/credit-cards/autograph-visa/streaming/
11
+ - https://www.wellsfargo.com/credit-cards/autograph-visa/terms/
11
12
  base_rate: 1
12
13
  annual_fee: 0
13
14
  welcome_offer:
@@ -18,8 +19,9 @@ welcome_offer:
18
19
  rules:
19
20
  - id: dining
20
21
  match: { categories: [dining] }
22
+ exclude: { merchants: [doordash, uber-eats] }
21
23
  rate: 3
22
- note: Restaurants, bars (drinking places), fast food and caterers, by Visa merchant code.
24
+ note: Restaurants, bars (drinking places), fast food and caterers, by Visa merchant code. Wells Fargo says bakeries, food at grocery stores and third-party delivery services may not earn 3X.
23
25
  - id: travel
24
26
  match: { categories: [travel] }
25
27
  rate: 3
@@ -31,43 +33,65 @@ rules:
31
33
  note: Passenger rail, taxis, limousines, ferries, tolls and parking. Intercity bus lines aren't on Wells Fargo's list.
32
34
  - id: gas
33
35
  match: { categories: [gas, ev_charging] }
36
+ exclude: { categories: [gas.superstore, gas.grocery_store] }
34
37
  rate: 3
35
- note: Gas stations, automated fuel dispensers and EV charging stations, by Visa merchant code.
38
+ note: Gas stations, automated fuel dispensers and EV charging stations, by Visa merchant code. Fuel or charging at superstores, warehouse clubs, grocery stores, car washes and auto repair shops may not earn 3X.
36
39
  - id: streaming
37
40
  match: { categories: [streaming, tv_internet] }
38
41
  rate: 3
39
- note: Cable and pay TV, digital goods (books, movies, music) and subscription services. Subscriptions bundled with another product or billed by a third party (an app store, a car) may not count. An internet-only bill may not code as pay TV.
42
+ note: Cable and pay TV, digital goods (books, movies, music) and subscription services. Subscriptions bundled with another product or billed by a third party (an app store, a cable, phone or internet provider, an online retailer, a car) may not count. An internet-only bill may not code as pay TV.
40
43
  - id: phone
41
44
  match: { categories: [phone] }
42
45
  rate: 3
43
- note: Landline and cell phone providers.
46
+ note: Landline and cell phone providers. Phones, accessories, add-ons like insurance or extra data, and plans bundled or billed by a third party (an internet provider, a car) may not count.
44
47
  # Not modeled: cell phone protection.
45
48
  # Issuer text backing each rule; `yarn check-sources` checks it weekly.
46
49
  evidence:
47
50
  - rule: base_rate
48
51
  url: https://creditcards.wellsfargo.com/autograph-visa-credit-card/
49
52
  quote: '1 rewards point will be earned per $1 spent on other net purchases'
53
+ - rule: travel
54
+ url: https://www.wellsfargo.com/credit-cards/autograph-visa/terms/
55
+ quote: 'Airlines Hotel/motels Campgrounds Timeshares Cruise lines Travel agencies Discount travel sites Vehicle/auto rentals'
50
56
  - rule: dining
51
57
  url: https://creditcards.wellsfargo.com/autograph-visa-credit-card/
52
58
  quote: 'Dining: eating places and restaurants, drinking places, fast food restaurants'
59
+ - rule: dining
60
+ url: https://www.wellsfargo.com/credit-cards/autograph-visa/terms/
61
+ quote: 'Grocery stores and other miscellaneous places that serve food or operate restaurants on site Third-party delivery services'
53
62
  - rule: travel
54
63
  url: https://creditcards.wellsfargo.com/autograph-visa-credit-card/
55
64
  quote: 'Travel: airline, hotel/motel, timeshare, or vehicle/auto rental, cruise lines'
56
65
  - rule: transit
57
66
  url: https://creditcards.wellsfargo.com/autograph-visa-credit-card/
58
- quote: 'Transit: passenger railway, taxis, limousines, ferries, toll bridges and highways'
67
+ quote: 'Transit: passenger railway, taxis, limousines, ferries, toll bridges and highways, parking lots and garages'
68
+ - rule: transit
69
+ url: https://www.wellsfargo.com/credit-cards/autograph-visa/terms/
70
+ quote: 'Ferries Parking lots and garages Limousines Taxis Passenger railway Toll bridges and highways'
59
71
  - rule: gas
60
72
  url: https://creditcards.wellsfargo.com/autograph-visa-credit-card/
61
73
  quote: 'Gas: gas stations, automated fuel dispensers, and electric vehicle charging stations'
74
+ - rule: gas
75
+ url: https://www.wellsfargo.com/credit-cards/autograph-visa/terms/
76
+ quote: 'Gas or electric vehicle charging purchases made at the following locations may not earn bonus Points'
77
+ - rule: gas
78
+ url: https://www.wellsfargo.com/credit-cards/autograph-visa/terms/
79
+ quote: 'Auto repair stores Superstores Car washes Warehouse/membership clubs Grocery stores'
62
80
  - rule: streaming
63
81
  url: https://creditcards.wellsfargo.com/autograph-visa-credit-card/
64
82
  quote: 'Popular streaming services: cable and other pay television, digital goods, books'
65
83
  - rule: streaming
66
84
  url: https://www.wellsfargo.com/credit-cards/autograph-visa/streaming/
67
85
  quote: 'You may earn 3X on these streaming services'
86
+ - rule: streaming
87
+ url: https://www.wellsfargo.com/credit-cards/autograph-visa/terms/
88
+ quote: 'Streaming services that a third party bills, such as a digital platform; a cable, telecommunications, or internet provider'
68
89
  - rule: phone
69
90
  url: https://creditcards.wellsfargo.com/autograph-visa-credit-card/
70
91
  quote: 'Phone plans: landline and cell phone providers considered telecommunication services'
92
+ - rule: phone
93
+ url: https://www.wellsfargo.com/credit-cards/autograph-visa/terms/
94
+ quote: 'Phone-plan services that a third party bundles or bills, such as an internet provider or in-vehicle service'
71
95
  - rule: annual_fee
72
96
  url: https://creditcards.wellsfargo.com/autograph-visa-credit-card/
73
97
  quote: '$0 Annual Fee'
@@ -0,0 +1,32 @@
1
+ # The banks behind the cards: the name shown for each, and other words people
2
+ # search with ("bofa", "amex"). Every card's `issuer` must be one of these.
3
+
4
+ - id: amex
5
+ name: American Express
6
+ search: [amex, american express]
7
+ - id: bank-of-america
8
+ name: Bank of America
9
+ search: [bofa, boa, bankofamerica]
10
+ - id: capital-one
11
+ name: Capital One
12
+ search: [capitalone]
13
+ - id: chase
14
+ name: Chase
15
+ - id: citi
16
+ name: Citi
17
+ search: [citibank]
18
+ - id: column
19
+ # Column N.A. issues the Bilt card; people know it as Bilt.
20
+ name: Bilt
21
+ search: [bilt]
22
+ - id: sofi
23
+ name: SoFi
24
+ - id: td-bank
25
+ name: TD Bank
26
+ search: [td]
27
+ - id: usbank
28
+ name: U.S. Bank
29
+ search: [us bank]
30
+ - id: wells-fargo
31
+ name: Wells Fargo
32
+ search: [wf, wellsfargo]
@@ -104,6 +104,13 @@
104
104
  category: streaming,
105
105
  }
106
106
  - { id: max, name: Max, aliases: [hbo max], category: streaming }
107
+ - { id: peacock, name: Peacock, aliases: [peacock tv], category: streaming }
108
+ - {
109
+ id: showtime,
110
+ name: Showtime,
111
+ aliases: [paramount plus with showtime],
112
+ category: streaming,
113
+ }
107
114
  - { id: spotify, name: Spotify, category: streaming }
108
115
  - {
109
116
  id: apple-tv,
package/dist/cli/bin.js CHANGED
@@ -1,8 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { styleText } from 'node:util';
3
+ import { CLI_NAME, WALLET_ENV_VAR } from "../brand.js";
3
4
  import { localToday } from "../engine/periods.js";
4
5
  import { run } from "./run.js";
5
- if (process.argv[2] === 'mcp') {
6
+ if (process.argv[2] === 'mcp' && /^(-h|--help)$/.test(process.argv[3] ?? '')) {
7
+ process.stdout.write(`Usage: ${CLI_NAME} mcp
8
+
9
+ Runs earnrate as an MCP server over stdio, for an AI assistant to start. It
10
+ uses the same wallet file as the command line (${WALLET_ENV_VAR}, or
11
+ ~/.earnrate/wallet.yaml).
12
+ `);
13
+ }
14
+ else if (process.argv[2] === 'mcp') {
6
15
  const { serveMcp } = await import("../mcp/serve.js");
7
16
  await serveMcp(process.env);
8
17
  }
@@ -15,6 +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
+ --every-card Pick from every card earnrate knows, even with a wallet.
18
19
  --member <name> A membership you have, like amazon-prime or costco. Repeatable.
19
20
  --valuation <profile> flat or points-heavy, instead of your wallet's values.
20
21
  --date <YYYY-MM-DD> The day to check. Default: today.
@@ -30,6 +31,7 @@ export async function runCheatSheet(argv, io) {
30
31
  valuation: { type: 'string' },
31
32
  date: { type: 'string' },
32
33
  cards: { type: 'string' },
34
+ 'every-card': { type: 'boolean' },
33
35
  member: { type: 'string', multiple: true },
34
36
  json: { type: 'boolean' },
35
37
  help: { type: 'boolean', short: 'h' },
@@ -45,7 +47,9 @@ export async function runCheatSheet(argv, io) {
45
47
  io.stdout(CHEATSHEET_HELP);
46
48
  return 0;
47
49
  }
48
- const problem = valuationProblem(values.valuation) ?? dateProblem(values.date);
50
+ const problem = values['every-card'] && values.cards !== undefined
51
+ ? 'Pick --cards or --every-card, not both.'
52
+ : (valuationProblem(values.valuation) ?? dateProblem(values.date));
49
53
  if (problem !== null) {
50
54
  io.stderr(`${problem}\n`);
51
55
  return USAGE_ERROR;
@@ -58,17 +62,18 @@ export async function runCheatSheet(argv, io) {
58
62
  if (loaded === null)
59
63
  return RUNTIME_ERROR;
60
64
  const date = values.date ?? io.today;
61
- if (loaded === 'none') {
65
+ if (loaded === 'none' || values['every-card']) {
66
+ const saved = await savedSettings(io);
62
67
  const sheet = catalogCheatSheet(catalog, date, {
63
68
  filters: ALL_PERSONAL_CARDS,
64
- memberships: values.member ?? [],
69
+ memberships: values.member ?? saved.memberships,
65
70
  valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
66
71
  ? VALUATION_PROFILES[values.valuation]
67
- : (await savedSettings(io)).valuations,
72
+ : saved.valuations,
68
73
  });
69
74
  io.stdout(values.json
70
75
  ? `${JSON.stringify(sheet, null, 2)}\n`
71
- : `${formatCheatSheet(sheet)}\n${NO_WALLET_HINT}\n`);
76
+ : `${formatCheatSheet(sheet)}${loaded === 'none' ? `\n${NO_WALLET_HINT}\n` : ''}`);
72
77
  return 0;
73
78
  }
74
79
  const wallet = values.valuation !== undefined && isValuationProfile(values.valuation)
@@ -1,9 +1,9 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
- import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
4
+ import { ALL_PERSONAL_CARDS, exploreCards, } from "../engine/explore.js";
5
5
  import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
6
- import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
6
+ import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, typicalSpend, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
7
7
  import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
8
8
  import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
9
9
  import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
@@ -141,10 +141,9 @@ export async function runBest(argv, io) {
141
141
  io.stderr(`${settings}\n`);
142
142
  return USAGE_ERROR;
143
143
  }
144
- const category = findCategory(catalog, text);
145
144
  const result = exploreCards(catalog, {
146
- merchant: category === null ? text : null,
147
- category,
145
+ merchant: text,
146
+ category: null,
148
147
  amount,
149
148
  channel: channelOf(values),
150
149
  date: values.date ?? io.today,
@@ -160,13 +159,7 @@ function parseSpend(catalog, args) {
160
159
  const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
161
160
  if (!match?.[1] || match[2] === undefined)
162
161
  return `"${arg}" isn't <what>=<dollars a month>, e.g. groceries=600.`;
163
- const what = match[1].replace(/[-_]/g, ' ');
164
- const category = findCategory(catalog, what);
165
- lines.push({
166
- merchant: category === null ? what : null,
167
- category,
168
- monthly: Number(match[2]),
169
- });
162
+ lines.push(spendLineFor(catalog, match[1].replace(/[-_]/g, ' '), Number(match[2])));
170
163
  }
171
164
  if (lines.length === 0)
172
165
  return `Give your monthly spending, e.g. ${CLI_NAME} simulate groceries=600 dining=300 other=1000.`;
@@ -203,7 +196,7 @@ export async function runSimulate(argv, io) {
203
196
  }
204
197
  const typical = positionals.length === 0;
205
198
  const spend = typical
206
- ? TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly))
199
+ ? typicalSpend(catalog)
207
200
  : parseSpend(catalog, positionals);
208
201
  if (typeof spend === 'string') {
209
202
  io.stderr(`${spend}\n`);
@@ -12,7 +12,7 @@ import { cardValuation } from "../engine/valuation.js";
12
12
  import { analyzeStatement, } from "../import/analyze.js";
13
13
  import { parseStatement } from "../import/statement.js";
14
14
  import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
15
- import { dollarsAndCents } from "../engine/text.js";
15
+ import { currencyName, dollarsAndCents } from "../engine/text.js";
16
16
  const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
17
17
  ${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
18
18
 
@@ -64,7 +64,7 @@ function valuationNote(catalog, wallet, cardIds, profile) {
64
64
  }
65
65
  if (values.size === 0)
66
66
  return 'Cash back counted as cash.';
67
- const parts = [...values].map(([id, cents]) => `${String(cents)}¢ for ${catalog.currencies.find((c) => c.id === id)?.name ?? id}`);
67
+ const parts = [...values].map(([id, cents]) => `${String(cents)}¢ for ${currencyName(catalog, id)}`);
68
68
  const list = parts.length > 1
69
69
  ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`
70
70
  : (parts[0] ?? '');
@@ -3,9 +3,10 @@ import { CLI_NAME } from "../brand.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { compareOffers } from "../engine/offers.js";
5
5
  import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
6
+ import { currencyName } from "../engine/text.js";
6
7
  import { formatOffers } from "../format/offers.js";
7
- import { valuationProblem } from "./args.js";
8
- import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
8
+ import { cardList, valuationProblem } from "./args.js";
9
+ import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
9
10
  const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
10
11
 
11
12
  Ranks the welcome offers on cards you don't have by first-year value: the
@@ -18,6 +19,8 @@ Options:
18
19
  --no-fee Cards with no annual fee.
19
20
  --business Business cards instead of personal ones.
20
21
  --held Include cards already in your wallet.
22
+ --cards <ids> Cards you have, instead of your wallet, comma separated.
23
+ --member <name> A membership you have, in place of the saved ones.
21
24
  --valuation <profile> flat or points-heavy, instead of your wallet's values.
22
25
  --all List every offer, not just the top 10.
23
26
  --json Print the full comparison as JSON.
@@ -34,6 +37,8 @@ export async function runOffers(argv, io) {
34
37
  'no-fee': { type: 'boolean' },
35
38
  business: { type: 'boolean' },
36
39
  held: { type: 'boolean' },
40
+ cards: { type: 'string' },
41
+ member: { type: 'string', multiple: true },
37
42
  valuation: { type: 'string' },
38
43
  all: { type: 'boolean' },
39
44
  json: { type: 'boolean' },
@@ -61,12 +66,18 @@ export async function runOffers(argv, io) {
61
66
  }
62
67
  const catalog = await loadCatalog();
63
68
  const found = await cardsToUse(catalog, walletPath(io.env), io, {
64
- cards: null,
65
- members: [],
69
+ cards: cardList(values.cards),
70
+ members: values.member ?? [],
66
71
  });
67
72
  if (found === null)
68
73
  return RUNTIME_ERROR;
69
- const loaded = found === 'none' ? { valuations: {}, memberships: [], cards: [] } : found;
74
+ const loaded = found === 'none'
75
+ ? await savedSettings(io).then((saved) => ({
76
+ valuations: saved.valuations,
77
+ memberships: values.member ?? saved.memberships,
78
+ cards: [],
79
+ }))
80
+ : found;
70
81
  const wallet = values.valuation && isValuationProfile(values.valuation)
71
82
  ? withValuationProfile(loaded, values.valuation)
72
83
  : loaded;
@@ -80,7 +91,6 @@ export async function runOffers(argv, io) {
80
91
  io.stdout(`${JSON.stringify(comparison, null, 2)}\n`);
81
92
  return 0;
82
93
  }
83
- const currencyName = (id) => catalog.currencies.find((c) => c.id === id)?.name ?? id;
84
- io.stdout(formatOffers(comparison, currencyName, values.all ?? false));
94
+ io.stdout(formatOffers(comparison, (id) => currencyName(catalog, id), values.all ?? false));
85
95
  return 0;
86
96
  }
package/dist/cli/run.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV_VAR, MERCHANT_CACHE_ENV_VAR, WALLET_ENV_VAR, } from "../brand.js";
6
6
  import { walletProblems } from "../data/integrity.js";
7
7
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
8
- import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
8
+ import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
9
9
  import { profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
10
10
  import { recommend } from "../engine/recommend.js";
11
11
  import { walletFromCards } from "../engine/transient.js";
@@ -14,7 +14,7 @@ import { formatRecommendation, plain, } from "../format/recommendation.js";
14
14
  import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
15
15
  import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
16
16
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
17
- import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
17
+ import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
18
18
  import { packageVersion } from "../version.js";
19
19
  import { membershipProblem, parseCliArgs } from "./args.js";
20
20
  import { runCheatSheet } from "./cheatsheet.js";
@@ -63,7 +63,7 @@ Options:
63
63
  --country <code> Where you're paying. Default: US.
64
64
  --cards <ids> Answer for these cards instead of your wallet, comma
65
65
  separated: --cards amex-gold,chase-prime-visa.
66
- --member <name> A membership you have, with --cards or no wallet.
66
+ --member <name> A membership you have, in place of the saved ones.
67
67
  --category <id> For a merchant earnrate doesn't know: its category,
68
68
  e.g. grocery.supermarket. Ignored for known merchants.
69
69
  --llm, --no-llm Ask Claude to classify merchants earnrate doesn't know.
@@ -87,7 +87,9 @@ export function displayPath(file) {
87
87
  return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
88
88
  }
89
89
  // The cards to answer for: those named with --cards, else the wallet file.
90
- // 'none' means no wallet and no --cards, which answers from every card.
90
+ // 'none' means no cards saved or named, which answers from every card.
91
+ // Named cards use the saved memberships (unless --member names some) and the
92
+ // saved point values, as the MCP tools do.
91
93
  export async function cardsToUse(catalog, file, io, named) {
92
94
  const problem = membershipProblem(catalog, named.members);
93
95
  if (problem !== null) {
@@ -95,15 +97,16 @@ export async function cardsToUse(catalog, file, io, named) {
95
97
  return null;
96
98
  }
97
99
  if (named.cards !== null) {
100
+ const saved = await savedSettings(io);
98
101
  const wallet = walletFromCards(catalog, {
99
102
  cards: named.cards.map((id) => ({ id })),
100
- memberships: named.members,
103
+ memberships: named.members.length > 0 ? named.members : saved.memberships,
101
104
  }, io.today);
102
105
  if (typeof wallet === 'string') {
103
106
  io.stderr(`${wallet}\n`);
104
107
  return null;
105
108
  }
106
- return wallet;
109
+ return { ...wallet, valuations: saved.valuations };
107
110
  }
108
111
  try {
109
112
  await access(file);
@@ -119,7 +122,7 @@ export async function cardsToUse(catalog, file, io, named) {
119
122
  io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
120
123
  return null;
121
124
  }
122
- return loaded;
125
+ return loaded.cards.length === 0 ? 'none' : loaded;
123
126
  }
124
127
  export const NO_WALLET_HINT = `These are every card earnrate knows. For the best of your own cards, name them with --cards amex-gold,chase-prime-visa, or save them: ${CLI_NAME} add <card-id> (${CLI_NAME} cards finds ids).`;
125
128
  // The point values and memberships saved in the wallet, which every answer
@@ -194,14 +197,7 @@ export async function run(argv, io) {
194
197
  io.stderr(`Unknown category "${options.category}". Use one of: ${catalog.categories.map((c) => c.id).join(', ')}.\n`);
195
198
  return USAGE_ERROR;
196
199
  }
197
- // Plain category words ("restaurants", "groceries") work like stores.
198
- const resolvedName = resolveMerchant(catalog, purchase.merchant, purchase.channel);
199
- const named = resolvedName.source === 'none' && options.category === null
200
- ? findCategory(catalog, purchase.merchant)
201
- : null;
202
- let merchant = named === null
203
- ? withCallerCategory(resolvedName, options.category)
204
- : categoryResolution(catalog, named, purchase.channel);
200
+ let merchant = withCallerCategory(resolvePlace(catalog, purchase.merchant, purchase.channel), options.category);
205
201
  const notices = [];
206
202
  if (llmOn && merchant.source === 'none') {
207
203
  const opened = await openLlmSession({
@@ -219,6 +215,7 @@ export async function run(argv, io) {
219
215
  notices.push(...resolved.notices);
220
216
  }
221
217
  if (loaded === 'none') {
218
+ const saved = await savedSettings(io);
222
219
  const explored = exploreCards(catalog, {
223
220
  merchant: purchase.merchant,
224
221
  category: null,
@@ -227,10 +224,10 @@ export async function run(argv, io) {
227
224
  date: purchase.date,
228
225
  }, {
229
226
  filters: ALL_PERSONAL_CARDS,
230
- memberships: options.members,
227
+ memberships: options.members.length > 0 ? options.members : saved.memberships,
231
228
  valuations: options.valuation
232
229
  ? VALUATION_PROFILES[options.valuation]
233
- : {},
230
+ : saved.valuations,
234
231
  }, merchant);
235
232
  if (options.json) {
236
233
  io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
@@ -3,6 +3,7 @@ import { CLI_NAME } from "../brand.js";
3
3
  import { FileWalletStore } from "../data/file-store.js";
4
4
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
5
5
  import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
6
+ import { membershipName } from "../engine/memberships.js";
6
7
  import { knownMemberships, searchCards } from "../engine/setup.js";
7
8
  import { formatCardInfo, formatNotes } from "../format/cards.js";
8
9
  import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
@@ -41,19 +42,21 @@ ${CARD_OPTIONS}
41
42
  `,
42
43
  update: `Usage: ${CLI_NAME} update <card-id> [options]
43
44
 
44
- Changes a card in your wallet: picks for this period, label, anniversary or
45
- activations. Picks for the current period are merged with ones already set.
45
+ Changes a card in your wallet: picks for this period, label, anniversary,
46
+ activations or welcome bonus. Picks for the current period are merged with
47
+ ones already set.
46
48
 
47
49
  ${CARD_OPTIONS}
48
50
  `,
49
51
  remove: `Usage: ${CLI_NAME} remove <card-id>
50
52
 
51
- Removes a card from your wallet, with its tracked cap usage.
53
+ Removes a card from your wallet, with its tracked cap usage and bonus
54
+ progress. Your memberships and point values stay.
52
55
  `,
53
56
  memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
54
57
 
55
58
  With no arguments, shows your memberships and the ones earnrate knows. With
56
- names, replaces the list. --none clears it.
59
+ names, replaces the list. --none clears it. No cards need to be saved first.
57
60
  `,
58
61
  values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
59
62
 
@@ -213,7 +216,7 @@ export async function runWalletCommand(command, argv, io) {
213
216
  }
214
217
  const list = values.none ? [] : positionals;
215
218
  return report(await store.setMemberships(list), list.length > 0
216
- ? `Memberships set to ${list.join(', ')} in ${where}.`
219
+ ? `Memberships set to ${list.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')} in ${where}.`
217
220
  : `Memberships cleared in ${where}.`, null, io);
218
221
  }
219
222
  if (positionals.length !== 1) {
@@ -1,7 +1,8 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
2
  import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
3
3
  import { loadWallet } from "./load.js";
4
- import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
4
+ import { emptyWallet, } from "./store.js";
5
+ import { addCardText, commitWalletText, EMPTY_WALLET_TEXT, readWalletText, removeCardText, setMembershipsText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
5
6
  const failed = (error) => ({ ok: false, error });
6
7
  const errorText = (e) => (e instanceof Error ? e.message : String(e));
7
8
  const queues = new Map();
@@ -56,7 +57,7 @@ export class FileWalletStore {
56
57
  }
57
58
  });
58
59
  }
59
- // Changes other than adding a card need a wallet file to change.
60
+ // Changing or removing a card needs a wallet file to change.
60
61
  existing(apply) {
61
62
  return (wallet) => wallet === null
62
63
  ? `No wallet at ${this.where} yet. Add a card first to create it.`
@@ -69,15 +70,13 @@ export class FileWalletStore {
69
70
  return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
70
71
  }
71
72
  removeCard(cardId) {
72
- return this.change(this.existing((w) => w.cards.length === 1 && w.cards[0]?.id === cardId
73
- ? failed(`${cardId} is the last card in ${this.where}. To start over, delete that file; to swap cards, add the new one first.`)
74
- : removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
73
+ return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
75
74
  }
76
75
  setMemberships(memberships) {
77
- return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
76
+ return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships), (text, change) => setMembershipsText(text ?? EMPTY_WALLET_TEXT, change.wallet.memberships));
78
77
  }
79
78
  setValuations(changes) {
80
- return this.change(this.existing((w) => withValuations(this.catalog, w, changes)), (text) => setValuationsText(text ?? '', changes));
79
+ return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes), (text) => setValuationsText(text ?? EMPTY_WALLET_TEXT, changes));
81
80
  }
82
81
  record(purchase, merchant) {
83
82
  return withWalletLock(this.where, async () => {
@@ -179,6 +179,7 @@ export function catalogProblems(catalog) {
179
179
  ['currency', catalog.currencies.map((c) => c.id)],
180
180
  ['card', catalog.cards.map((c) => c.id)],
181
181
  ['membership', catalog.memberships.map((m) => m.id)],
182
+ ['issuer', catalog.issuers.map((i) => i.id)],
182
183
  ]) {
183
184
  for (const id of duplicates(ids))
184
185
  problems.push(`duplicate ${kind} id '${id}'`);
@@ -203,8 +204,11 @@ export function catalogProblems(catalog) {
203
204
  for (const id of m.members_shop_at)
204
205
  if (!merchantIds.has(id))
205
206
  problems.push(`membership ${m.id}: members_shop_at '${id}' is not a merchant`);
207
+ const issuers = new Set(catalog.issuers.map((i) => i.id));
206
208
  for (const card of catalog.cards) {
207
209
  problems.push(...cardProblems(card, catalog, merchantIds));
210
+ if (!issuers.has(card.issuer))
211
+ problems.push(`${card.id}: unknown issuer '${card.issuer}' (data/issuers.yaml)`);
208
212
  }
209
213
  return problems;
210
214
  }