earnrate 0.2.4 → 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 (116) hide show
  1. package/README.md +18 -15
  2. package/data/CHANGELOG.md +79 -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 +23 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
  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 +19 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +15 -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 +17 -4
  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 +6 -9
  47. package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
  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 +16 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +23 -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/categories.yaml +12 -0
  61. package/data/currencies.yaml +2 -0
  62. package/data/issuers.yaml +32 -0
  63. package/data/memberships.yaml +33 -0
  64. package/data/merchants/overrides.yaml +25 -0
  65. package/data/merchants/seed.yaml +7 -0
  66. package/dist/cli/bin.js +10 -1
  67. package/dist/cli/cheatsheet.js +11 -6
  68. package/dist/cli/explore.js +17 -23
  69. package/dist/cli/import.js +10 -10
  70. package/dist/cli/offers.js +19 -8
  71. package/dist/cli/run.js +31 -20
  72. package/dist/cli/wallet.js +50 -5
  73. package/dist/data/file-store.js +9 -7
  74. package/dist/data/integrity.js +22 -2
  75. package/dist/data/load.js +13 -2
  76. package/dist/data/store.js +13 -22
  77. package/dist/data/wallet-edit.js +108 -8
  78. package/dist/engine/cheat-sheet.js +15 -19
  79. package/dist/engine/compare.js +10 -0
  80. package/dist/engine/evaluate.js +2 -4
  81. package/dist/engine/explore.js +13 -56
  82. package/dist/engine/memberships.js +41 -15
  83. package/dist/engine/offers.js +18 -5
  84. package/dist/engine/periods.js +18 -0
  85. package/dist/engine/profiles.js +8 -0
  86. package/dist/engine/recommend.js +23 -37
  87. package/dist/engine/setup.js +28 -40
  88. package/dist/engine/simulate.js +11 -10
  89. package/dist/engine/text.js +49 -23
  90. package/dist/engine/valuation.js +1 -1
  91. package/dist/engine/wallet-change.js +26 -11
  92. package/dist/engine/wallet-summary.js +85 -31
  93. package/dist/format/card-text.js +19 -36
  94. package/dist/format/cards.js +3 -3
  95. package/dist/format/explore.js +15 -16
  96. package/dist/format/offers.js +10 -15
  97. package/dist/format/recommendation.js +6 -6
  98. package/dist/format/wallet.js +5 -2
  99. package/dist/import/analyze.js +2 -1
  100. package/dist/mcp/context.js +61 -13
  101. package/dist/mcp/inputs.js +16 -7
  102. package/dist/mcp/schemas.js +9 -0
  103. package/dist/mcp/server.js +8 -1
  104. package/dist/mcp/tools/account.js +45 -17
  105. package/dist/mcp/tools/answers.js +84 -29
  106. package/dist/mcp/tools/wallet.js +109 -22
  107. package/dist/merchants/cache.js +0 -10
  108. package/dist/merchants/resolve.js +81 -4
  109. package/dist/schema/card.js +8 -12
  110. package/dist/schema/category.js +4 -0
  111. package/dist/schema/currency.js +2 -0
  112. package/dist/schema/issuer.js +8 -0
  113. package/dist/schema/membership.js +15 -0
  114. package/dist/schema/merchant.js +4 -0
  115. package/dist/schema/wallet.js +1 -1
  116. package/package.json +1 -1
@@ -2,15 +2,18 @@
2
2
 
3
3
  # Issuer travel portals. Each issuer's own bonuses match them by id.
4
4
  - id: chase-travel
5
+ kind: portal
5
6
  name: Chase Travel
6
7
  domains: [travelsecure.chase.com]
7
8
  category: travel
8
9
  - id: renowned-hotels-chase-travel
10
+ kind: booking
9
11
  name: Renowned Hotels and Resorts (Chase Travel)
10
12
  aliases: [renowned hotels, renowned hotels and resorts]
11
13
  category: travel
12
14
  note: Prepaid stays in the Renowned Hotels and Resorts collection for United cardmembers on Chase Travel.
13
15
  - id: costco-travel
16
+ kind: portal
14
17
  name: Costco Travel
15
18
  domains: [costcotravel.com]
16
19
  category: travel
@@ -25,6 +28,7 @@
25
28
  domains: [aadvantagecars.com]
26
29
  category: travel
27
30
  - id: amex-travel
31
+ kind: portal
28
32
  name: American Express Travel
29
33
  aliases: [amex travel]
30
34
  domains: [amextravel.com]
@@ -42,15 +46,18 @@
42
46
  # bank's own bonuses match these merchants by id. Bonuses that depend on what's
43
47
  # booked get one merchant per booking type.
44
48
  - id: amex-travel-hotels
49
+ kind: booking
45
50
  name: Hotel through Amex Travel
46
51
  aliases: [amex travel hotel, amex travel hotels, hotel amex travel]
47
52
  category: travel
48
53
  note: Prepaid hotels only for the Amex bonuses.
49
54
  - id: amex-travel-flights
55
+ kind: booking
50
56
  name: Flight through Amex Travel
51
57
  aliases: [amex travel flight, amex travel flights, flight amex travel]
52
58
  category: travel
53
59
  - id: airbnb-amex-travel
60
+ kind: booking
54
61
  name: Airbnb through Amex Travel
55
62
  aliases:
56
63
  [
@@ -62,18 +69,22 @@
62
69
  category: travel
63
70
  note: Prepaid Airbnb stays booked in AmexTravel.com or the Amex Travel app, with your Airbnb account linked to Amex.
64
71
  - id: amex-travel-car-rentals
72
+ kind: booking
65
73
  name: Car rental through Amex Travel
66
74
  aliases: [amex travel car rental, amex travel car]
67
75
  category: travel
68
76
  - id: amex-travel-cruises
77
+ kind: booking
69
78
  name: Cruise through Amex Travel
70
79
  aliases: [amex travel cruise]
71
80
  category: travel
72
81
  - id: usbank-travel-center
82
+ kind: portal
73
83
  name: U.S. Bank Travel Center
74
84
  aliases: [us bank travel center]
75
85
  category: travel
76
86
  - id: sofi-travel
87
+ kind: portal
77
88
  name: SoFi Travel
78
89
  category: travel
79
90
 
@@ -177,38 +188,45 @@
177
188
  # Card issuers' travel and ticketing sites (see the Amex Travel note above).
178
189
  - {
179
190
  id: capital-one-travel,
191
+ kind: portal,
180
192
  name: Capital One Travel,
181
193
  aliases: [capital one travel],
182
194
  category: travel,
183
195
  }
184
196
  - {
185
197
  id: capital-one-travel-hotels,
198
+ kind: booking,
186
199
  name: Hotel through Capital One Travel,
187
200
  aliases: [capital one travel hotel],
188
201
  category: travel,
189
202
  }
190
203
  - id: capital-one-travel-vacation-rentals
204
+ kind: booking
191
205
  name: Vacation rental through Capital One Travel
192
206
  aliases: [capital one travel vacation rental]
193
207
  category: travel
194
208
  - {
195
209
  id: capital-one-travel-car-rentals,
210
+ kind: booking,
196
211
  name: Car rental through Capital One Travel,
197
212
  aliases: [capital one travel car rental],
198
213
  category: travel,
199
214
  }
200
215
  - {
201
216
  id: capital-one-travel-flights,
217
+ kind: booking,
202
218
  name: Flight through Capital One Travel,
203
219
  aliases: [capital one travel flight],
204
220
  category: travel,
205
221
  }
206
222
  - id: capital-one-entertainment
223
+ kind: portal
207
224
  name: Capital One Entertainment
208
225
  aliases: [capital one entertainment tickets]
209
226
  category: entertainment
210
227
  - {
211
228
  id: citi-travel,
229
+ kind: portal,
212
230
  name: Citi Travel,
213
231
  aliases: [citi travel],
214
232
  domains: [cititravel.com],
@@ -216,42 +234,49 @@
216
234
  }
217
235
  - {
218
236
  id: citi-travel-hotels,
237
+ kind: booking,
219
238
  name: Hotel through Citi Travel,
220
239
  aliases: [citi travel hotel],
221
240
  category: travel,
222
241
  }
223
242
  - {
224
243
  id: citi-travel-car-rentals,
244
+ kind: booking,
225
245
  name: Car rental through Citi Travel,
226
246
  aliases: [citi travel car rental],
227
247
  category: travel,
228
248
  }
229
249
  - {
230
250
  id: citi-travel-attractions,
251
+ kind: booking,
231
252
  name: Attraction through Citi Travel,
232
253
  aliases: [citi travel attraction],
233
254
  category: travel,
234
255
  }
235
256
  - {
236
257
  id: citi-travel-flights,
258
+ kind: booking,
237
259
  name: Flight through Citi Travel,
238
260
  aliases: [citi travel flight],
239
261
  category: travel,
240
262
  }
241
263
  - {
242
264
  id: bilt-travel,
265
+ kind: portal,
243
266
  name: Bilt Travel,
244
267
  aliases: [bilt travel],
245
268
  category: travel,
246
269
  }
247
270
  - {
248
271
  id: bilt-travel-hotels,
272
+ kind: booking,
249
273
  name: Hotel through Bilt Travel,
250
274
  aliases: [bilt travel hotel],
251
275
  category: travel,
252
276
  }
253
277
  - {
254
278
  id: bilt-travel-flights,
279
+ kind: booking,
255
280
  name: Flight through Bilt Travel,
256
281
  aliases: [bilt travel flight],
257
282
  category: travel,
@@ -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
  }
@@ -6,7 +6,7 @@ import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
6
6
  import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
7
7
  import { formatCheatSheet } from "../format/cheat-sheet.js";
8
8
  import { cardList, dateProblem, valuationProblem } from "./args.js";
9
- import { cardsToUse, NO_WALLET_HINT, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
9
+ import { cardsToUse, NO_WALLET_HINT, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
10
10
  const CHEATSHEET_HELP = `Usage: ${CLI_NAME} cheatsheet [options]
11
11
 
12
12
  The card to use for each everyday kind of purchase this quarter: from your
@@ -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
- : {},
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,20 +1,21 @@
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";
5
- import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
6
- import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
4
+ import { ALL_PERSONAL_CARDS, exploreCards, } from "../engine/explore.js";
5
+ import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.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
- import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
9
+ import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
10
10
  const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
11
11
  --no-fee Cards with no annual fee.
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
15
  --member <name> A membership you have, e.g. amazon-prime or costco. Repeatable.
16
- --valuation <profile> flat (points at 1¢, the default) or points-heavy
17
- (MR and UR at 1.7¢).`;
16
+ --valuation <profile> flat (${profileText('flat')}) or points-heavy
17
+ (${profileText('points-heavy')}), instead of your
18
+ saved point values.`;
18
19
  const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
19
20
 
20
21
  Ranks every card in earnrate's catalog for one purchase, not just yours. Each
@@ -67,7 +68,7 @@ const FILTER_FLAGS = {
67
68
  json: { type: 'boolean' },
68
69
  help: { type: 'boolean', short: 'h' },
69
70
  };
70
- function settingsFrom(values, catalog) {
71
+ function settingsFrom(values, catalog, saved) {
71
72
  if (values.cash && values.points)
72
73
  return 'Pick --cash or --points, not both.';
73
74
  const problem = valuationProblem(values.valuation) ??
@@ -86,10 +87,10 @@ function settingsFrom(values, catalog) {
86
87
  issuer: values.issuer ?? null,
87
88
  includeClosed: values.closed ?? false,
88
89
  },
89
- memberships: values.member ?? [],
90
+ memberships: values.member ?? saved.memberships,
90
91
  valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
91
92
  ? VALUATION_PROFILES[values.valuation]
92
- : {},
93
+ : saved.valuations,
93
94
  };
94
95
  }
95
96
  export async function runBest(argv, io) {
@@ -135,22 +136,21 @@ export async function runBest(argv, io) {
135
136
  return USAGE_ERROR;
136
137
  }
137
138
  const catalog = await loadCatalog();
138
- const settings = settingsFrom(values, catalog);
139
+ const settings = settingsFrom(values, catalog, await savedSettings(io));
139
140
  if (typeof settings === 'string') {
140
141
  io.stderr(`${settings}\n`);
141
142
  return USAGE_ERROR;
142
143
  }
143
- const category = findCategory(catalog, text);
144
144
  const result = exploreCards(catalog, {
145
- merchant: category === null ? text : null,
146
- category,
145
+ merchant: text,
146
+ category: null,
147
147
  amount,
148
148
  channel: channelOf(values),
149
149
  date: values.date ?? io.today,
150
150
  }, settings);
151
151
  io.stdout(values.json
152
152
  ? `${JSON.stringify(result, null, 2)}\n`
153
- : formatExploration(result, values.all ?? false));
153
+ : formatExploration(result, catalog, values.all ?? false));
154
154
  return 0;
155
155
  }
156
156
  function parseSpend(catalog, args) {
@@ -159,13 +159,7 @@ function parseSpend(catalog, args) {
159
159
  const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
160
160
  if (!match?.[1] || match[2] === undefined)
161
161
  return `"${arg}" isn't <what>=<dollars a month>, e.g. groceries=600.`;
162
- const what = match[1].replace(/[-_]/g, ' ');
163
- const category = findCategory(catalog, what);
164
- lines.push({
165
- merchant: category === null ? what : null,
166
- category,
167
- monthly: Number(match[2]),
168
- });
162
+ lines.push(spendLineFor(catalog, match[1].replace(/[-_]/g, ' '), Number(match[2])));
169
163
  }
170
164
  if (lines.length === 0)
171
165
  return `Give your monthly spending, e.g. ${CLI_NAME} simulate groceries=600 dining=300 other=1000.`;
@@ -195,14 +189,14 @@ export async function runSimulate(argv, io) {
195
189
  return 0;
196
190
  }
197
191
  const catalog = await loadCatalog();
198
- const settings = settingsFrom(values, catalog);
192
+ const settings = settingsFrom(values, catalog, await savedSettings(io));
199
193
  if (typeof settings === 'string') {
200
194
  io.stderr(`${settings}\n`);
201
195
  return USAGE_ERROR;
202
196
  }
203
197
  const typical = positionals.length === 0;
204
198
  const spend = typical
205
- ? TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly))
199
+ ? typicalSpend(catalog)
206
200
  : parseSpend(catalog, positionals);
207
201
  if (typeof spend === 'string') {
208
202
  io.stderr(`${spend}\n`);
@@ -12,6 +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 { currencyName, dollarsAndCents } from "../engine/text.js";
15
16
  const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
16
17
  ${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
17
18
 
@@ -44,7 +45,6 @@ const LedgerSchema = z.strictObject({
44
45
  const fingerprint = ({ cardId, line }, kind = 'cap') => createHash('sha256')
45
46
  .update(`${kind === 'bonus' ? 'bonus|' : ''}${cardId}|${line.date}|${String(line.amount)}|${line.description}`)
46
47
  .digest('hex');
47
- const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
48
48
  // "statement.csv:chase-sapphire-preferred" names the card inline.
49
49
  function splitTarget(arg) {
50
50
  const m = /^(.+):([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(arg);
@@ -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] ?? '');
@@ -77,20 +77,20 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
77
77
  several
78
78
  ? `${String(a.byCard.length)} statements (${names.join(', ')}) · ${a.from} to ${a.to}`
79
79
  : `${names[0] ?? ''} statement · ${a.from} to ${a.to}`,
80
- `${String(a.purchases)} purchases, ${usd(a.spend)}${a.refunds > 0 ? ` (refunds and credits: ${usd(a.refunds)})` : ''}`,
80
+ `${String(a.purchases)} purchases, ${dollarsAndCents(a.spend)}${a.refunds > 0 ? ` (refunds and credits: ${dollarsAndCents(a.refunds)})` : ''}`,
81
81
  '',
82
82
  ];
83
83
  if (several) {
84
- out.push(`Earned on the cards you used: about ${usd(a.earned)}`);
84
+ out.push(`Earned on the cards you used: about ${dollarsAndCents(a.earned)}`);
85
85
  const width = Math.max(...names.map((n) => n.length));
86
86
  for (const t of a.byCard) {
87
- out.push(` ${labelOf(t.cardId).padEnd(width)} ${String(t.purchases).padStart(4)} × ${usd(t.spend).padStart(11)} ${usd(t.earned)}`);
87
+ out.push(` ${labelOf(t.cardId).padEnd(width)} ${String(t.purchases).padStart(4)} × ${dollarsAndCents(t.spend).padStart(11)} ${dollarsAndCents(t.earned)}`);
88
88
  }
89
89
  }
90
90
  else {
91
- out.push(`Earned on ${names[0] ?? ''}: about ${usd(a.earned)}`);
91
+ out.push(`Earned on ${names[0] ?? ''}: about ${dollarsAndCents(a.earned)}`);
92
92
  }
93
- out.push(`With earnrate's picks: about ${usd(a.best)} (+${usd(a.best - a.earned)})`, valuation);
93
+ out.push(`With earnrate's picks: about ${dollarsAndCents(a.best)} (+${dollarsAndCents(a.best - a.earned)})`, valuation);
94
94
  if (a.missed.length > 0) {
95
95
  out.push('', 'Where another card would have earned more:');
96
96
  const shown = a.missed.slice(0, 10);
@@ -99,7 +99,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
99
99
  const cardWidth = Math.max(...shown.map((m) => labelOf(m.usedCardId).length));
100
100
  for (const m of shown) {
101
101
  const used = several ? `${labelOf(m.usedCardId).padEnd(cardWidth)} ` : '';
102
- out.push(` ${name(m).slice(0, width).padEnd(width)} ${used}${String(m.count).padStart(3)} × ${usd(m.spend).padStart(10)} ${usd(m.earned)} → ${m.bestCard} ${usd(m.best)} (+${usd(m.best - m.earned)})`);
102
+ out.push(` ${name(m).slice(0, width).padEnd(width)} ${used}${String(m.count).padStart(3)} × ${dollarsAndCents(m.spend).padStart(10)} ${dollarsAndCents(m.earned)} → ${m.bestCard} ${dollarsAndCents(m.best)} (+${dollarsAndCents(m.best - m.earned)})`);
103
103
  }
104
104
  if (a.missed.length > 10)
105
105
  out.push(` …and ${String(a.missed.length - 10)} more merchants`);
@@ -117,7 +117,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
117
117
  const last = new Map();
118
118
  for (const { cardId, booking } of a.bookings)
119
119
  last.set(several ? `${labelOf(cardId)} ${booking.capKey}` : booking.capKey, booking.after);
120
- out.push(`Cap usage for the current period: ${[...last].map(([k, v]) => `${k} ${usd(v)}`).join(', ')}.`);
120
+ out.push(`Cap usage for the current period: ${[...last].map(([k, v]) => `${k} ${dollarsAndCents(v)}`).join(', ')}.`);
121
121
  }
122
122
  else if (a.alreadyImported > 0) {
123
123
  out.push(`Caps: nothing new to record. ${String(a.alreadyImported)} purchase(s) were recorded by an earlier import.`);
@@ -129,7 +129,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
129
129
  out.push('Caps: nothing new to record.');
130
130
  }
131
131
  for (const b of a.bonuses) {
132
- out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${usd(b.before)} → ${usd(b.after)} of ${usd(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
132
+ out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
133
133
  }
134
134
  return `${out.join('\n')}\n`;
135
135
  }
@@ -3,13 +3,15 @@ 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
12
- bonus at your point values minus the first year's annual fee.
13
+ bonus at your point values minus the first year's annual fee and any
14
+ membership the card needs that you don't have.
13
15
 
14
16
  Options:
15
17
  --cash Cash back cards only.
@@ -17,6 +19,8 @@ Options:
17
19
  --no-fee Cards with no annual fee.
18
20
  --business Business cards instead of personal ones.
19
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.
20
24
  --valuation <profile> flat or points-heavy, instead of your wallet's values.
21
25
  --all List every offer, not just the top 10.
22
26
  --json Print the full comparison as JSON.
@@ -33,6 +37,8 @@ export async function runOffers(argv, io) {
33
37
  'no-fee': { type: 'boolean' },
34
38
  business: { type: 'boolean' },
35
39
  held: { type: 'boolean' },
40
+ cards: { type: 'string' },
41
+ member: { type: 'string', multiple: true },
36
42
  valuation: { type: 'string' },
37
43
  all: { type: 'boolean' },
38
44
  json: { type: 'boolean' },
@@ -60,12 +66,18 @@ export async function runOffers(argv, io) {
60
66
  }
61
67
  const catalog = await loadCatalog();
62
68
  const found = await cardsToUse(catalog, walletPath(io.env), io, {
63
- cards: null,
64
- members: [],
69
+ cards: cardList(values.cards),
70
+ members: values.member ?? [],
65
71
  });
66
72
  if (found === null)
67
73
  return RUNTIME_ERROR;
68
- 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;
69
81
  const wallet = values.valuation && isValuationProfile(values.valuation)
70
82
  ? withValuationProfile(loaded, values.valuation)
71
83
  : loaded;
@@ -79,7 +91,6 @@ export async function runOffers(argv, io) {
79
91
  io.stdout(`${JSON.stringify(comparison, null, 2)}\n`);
80
92
  return 0;
81
93
  }
82
- const currencyName = (id) => catalog.currencies.find((c) => c.id === id)?.name ?? id;
83
- io.stdout(formatOffers(comparison, currencyName, values.all ?? false));
94
+ io.stdout(formatOffers(comparison, (id) => currencyName(catalog, id), values.all ?? false));
84
95
  return 0;
85
96
  }
package/dist/cli/run.js CHANGED
@@ -5,8 +5,8 @@ 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";
9
- import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
8
+ import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
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";
12
12
  import { formatExploration } from "../format/explore.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";
@@ -43,6 +43,7 @@ Save your cards (optional):
43
43
  ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
44
44
  ${CLI_NAME} remove <card-id> Remove a card.
45
45
  ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime, costco).
46
+ ${CLI_NAME} values [UR=1.5 ...] Show or set what your points are worth.
46
47
 
47
48
  Other commands:
48
49
  ${CLI_NAME} cheatsheet The best card for each category this quarter.
@@ -53,7 +54,8 @@ Other commands:
53
54
 
54
55
  Options:
55
56
  --online, --in-store How you're paying. Default: inferred from the merchant.
56
- --valuation <profile> flat (points at 1¢) or points-heavy (MR and UR at 1.7¢).
57
+ --valuation <profile> flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}),
58
+ instead of your saved point values.
57
59
  Default: the values in your wallet.
58
60
  --all List every card, not just the top three.
59
61
  --json Print the full recommendation as JSON.
@@ -61,7 +63,7 @@ Options:
61
63
  --country <code> Where you're paying. Default: US.
62
64
  --cards <ids> Answer for these cards instead of your wallet, comma
63
65
  separated: --cards amex-gold,chase-prime-visa.
64
- --member <name> A membership you have, with --cards or no wallet.
66
+ --member <name> A membership you have, in place of the saved ones.
65
67
  --category <id> For a merchant earnrate doesn't know: its category,
66
68
  e.g. grocery.supermarket. Ignored for known merchants.
67
69
  --llm, --no-llm Ask Claude to classify merchants earnrate doesn't know.
@@ -85,7 +87,9 @@ export function displayPath(file) {
85
87
  return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
86
88
  }
87
89
  // The cards to answer for: those named with --cards, else the wallet file.
88
- // '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.
89
93
  export async function cardsToUse(catalog, file, io, named) {
90
94
  const problem = membershipProblem(catalog, named.members);
91
95
  if (problem !== null) {
@@ -93,15 +97,16 @@ export async function cardsToUse(catalog, file, io, named) {
93
97
  return null;
94
98
  }
95
99
  if (named.cards !== null) {
100
+ const saved = await savedSettings(io);
96
101
  const wallet = walletFromCards(catalog, {
97
102
  cards: named.cards.map((id) => ({ id })),
98
- memberships: named.members,
103
+ memberships: named.members.length > 0 ? named.members : saved.memberships,
99
104
  }, io.today);
100
105
  if (typeof wallet === 'string') {
101
106
  io.stderr(`${wallet}\n`);
102
107
  return null;
103
108
  }
104
- return wallet;
109
+ return { ...wallet, valuations: saved.valuations };
105
110
  }
106
111
  try {
107
112
  await access(file);
@@ -117,9 +122,21 @@ export async function cardsToUse(catalog, file, io, named) {
117
122
  io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
118
123
  return null;
119
124
  }
120
- return loaded;
125
+ return loaded.cards.length === 0 ? 'none' : loaded;
121
126
  }
122
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).`;
128
+ // The point values and memberships saved in the wallet, which every answer
129
+ // uses unless --valuation or --member says otherwise. No wallet, or one that
130
+ // can't be read, has none.
131
+ export async function savedSettings(io) {
132
+ try {
133
+ const { valuations, memberships } = await loadWallet(walletPath(io.env));
134
+ return { valuations, memberships };
135
+ }
136
+ catch {
137
+ return { valuations: {}, memberships: [] };
138
+ }
139
+ }
123
140
  export async function readWallet(file, io) {
124
141
  try {
125
142
  return await loadWallet(file);
@@ -180,14 +197,7 @@ export async function run(argv, io) {
180
197
  io.stderr(`Unknown category "${options.category}". Use one of: ${catalog.categories.map((c) => c.id).join(', ')}.\n`);
181
198
  return USAGE_ERROR;
182
199
  }
183
- // Plain category words ("restaurants", "groceries") work like stores.
184
- const resolvedName = resolveMerchant(catalog, purchase.merchant, purchase.channel);
185
- const named = resolvedName.source === 'none' && options.category === null
186
- ? findCategory(catalog, purchase.merchant)
187
- : null;
188
- let merchant = named === null
189
- ? withCallerCategory(resolvedName, options.category)
190
- : categoryResolution(catalog, named, purchase.channel);
200
+ let merchant = withCallerCategory(resolvePlace(catalog, purchase.merchant, purchase.channel), options.category);
191
201
  const notices = [];
192
202
  if (llmOn && merchant.source === 'none') {
193
203
  const opened = await openLlmSession({
@@ -205,6 +215,7 @@ export async function run(argv, io) {
205
215
  notices.push(...resolved.notices);
206
216
  }
207
217
  if (loaded === 'none') {
218
+ const saved = await savedSettings(io);
208
219
  const explored = exploreCards(catalog, {
209
220
  merchant: purchase.merchant,
210
221
  category: null,
@@ -213,16 +224,16 @@ export async function run(argv, io) {
213
224
  date: purchase.date,
214
225
  }, {
215
226
  filters: ALL_PERSONAL_CARDS,
216
- memberships: options.members,
227
+ memberships: options.members.length > 0 ? options.members : saved.memberships,
217
228
  valuations: options.valuation
218
229
  ? VALUATION_PROFILES[options.valuation]
219
- : {},
230
+ : saved.valuations,
220
231
  }, merchant);
221
232
  if (options.json) {
222
233
  io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
223
234
  }
224
235
  else {
225
- io.stdout(`${formatExploration(explored, options.all)}\n${NO_WALLET_HINT}\n`);
236
+ io.stdout(`${formatExploration(explored, catalog, options.all)}\n${NO_WALLET_HINT}\n`);
226
237
  }
227
238
  for (const n of notices)
228
239
  io.stderr(`${n}\n`);