earnrate 0.2.3 → 0.2.5

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 (71) hide show
  1. package/README.md +15 -14
  2. package/data/CHANGELOG.md +74 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +5 -4
  5. package/data/cards/amex-gold.yaml +4 -0
  6. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
  7. package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
  8. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
  9. package/data/cards/chase-freedom-flex.yaml +1 -1
  10. package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
  11. package/data/cards/chase-ihg-one-rewards.yaml +7 -0
  12. package/data/cards/chase-southwest-plus.yaml +4 -0
  13. package/data/cards/chase-southwest-premier.yaml +4 -0
  14. package/data/cards/chase-southwest-priority.yaml +4 -0
  15. package/data/cards/chase-united-club.yaml +1 -1
  16. package/data/cards/citi-aadvantage-globe.yaml +7 -7
  17. package/data/cards/citi-costco-anywhere-visa-business.yaml +9 -1
  18. package/data/cards/citi-costco-anywhere-visa.yaml +9 -1
  19. package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
  20. package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
  21. package/data/categories.yaml +12 -0
  22. package/data/currencies.yaml +2 -0
  23. package/data/memberships.yaml +33 -0
  24. package/data/merchants/overrides.yaml +33 -0
  25. package/data/merchants/seed.yaml +0 -6
  26. package/dist/cli/cheatsheet.js +3 -3
  27. package/dist/cli/explore.js +12 -11
  28. package/dist/cli/import.js +13 -12
  29. package/dist/cli/offers.js +2 -1
  30. package/dist/cli/run.js +18 -4
  31. package/dist/cli/wallet.js +42 -0
  32. package/dist/data/file-store.js +5 -2
  33. package/dist/data/integrity.js +26 -0
  34. package/dist/data/load.js +10 -2
  35. package/dist/data/store.js +8 -1
  36. package/dist/data/wallet-edit.js +83 -2
  37. package/dist/engine/cheat-sheet.js +53 -14
  38. package/dist/engine/compare.js +8 -0
  39. package/dist/engine/explore.js +18 -20
  40. package/dist/engine/memberships.js +71 -0
  41. package/dist/engine/offers.js +37 -5
  42. package/dist/engine/profiles.js +8 -0
  43. package/dist/engine/recommend.js +78 -44
  44. package/dist/engine/setup.js +22 -5
  45. package/dist/engine/simulate.js +36 -14
  46. package/dist/engine/text.js +67 -2
  47. package/dist/engine/valuation.js +1 -1
  48. package/dist/engine/wallet-change.js +42 -4
  49. package/dist/engine/wallet-summary.js +83 -37
  50. package/dist/format/card-text.js +19 -29
  51. package/dist/format/cards.js +8 -9
  52. package/dist/format/explore.js +22 -14
  53. package/dist/format/offers.js +12 -21
  54. package/dist/format/recommendation.js +16 -10
  55. package/dist/format/wallet-yaml.js +18 -3
  56. package/dist/format/wallet.js +16 -20
  57. package/dist/mcp/context.js +31 -4
  58. package/dist/mcp/inputs.js +17 -8
  59. package/dist/mcp/schemas.js +21 -0
  60. package/dist/mcp/server.js +1 -0
  61. package/dist/mcp/tools/account.js +28 -10
  62. package/dist/mcp/tools/answers.js +51 -18
  63. package/dist/mcp/tools/wallet.js +106 -11
  64. package/dist/schema/card.js +7 -0
  65. package/dist/schema/category.js +4 -0
  66. package/dist/schema/currency.js +2 -0
  67. package/dist/schema/membership.js +15 -0
  68. package/dist/schema/merchant.js +4 -0
  69. package/dist/schema/wallet.js +4 -0
  70. package/package.json +1 -1
  71. package/wallet.example.yaml +1 -0
@@ -58,11 +58,13 @@
58
58
  - id: gas.superstore
59
59
  name: Gas at superstores and warehouse clubs
60
60
  - id: ev_charging
61
+ preposition: on
61
62
  name: EV charging
62
63
  aliases: [ev, charging]
63
64
  mccs: [5552]
64
65
 
65
66
  - id: transit
67
+ preposition: on
66
68
  name: Transit
67
69
  aliases: [public transit, train, trains, bus, commuting]
68
70
  mccs: [4111, 4112, 4131, 4784, 7523]
@@ -72,6 +74,7 @@
72
74
  mccs: [4121]
73
75
 
74
76
  - id: travel
77
+ preposition: on
75
78
  name: Travel
76
79
  mccs: [4722]
77
80
  - id: travel.flights
@@ -92,18 +95,22 @@
92
95
  mccs: [4411]
93
96
 
94
97
  - id: streaming
98
+ preposition: on
95
99
  name: Streaming services
96
100
  aliases: [streaming, subscriptions]
97
101
  mccs: [4899, 5815]
98
102
  - id: tv_internet
103
+ preposition: on
99
104
  name: TV and internet service
100
105
  aliases: [internet, cable]
101
106
  mccs: [4899]
102
107
  - id: phone
108
+ preposition: on
103
109
  name: Cell phone service
104
110
  aliases: [phone, cell phone, wireless]
105
111
  mccs: [4814]
106
112
  - id: utilities
113
+ preposition: on
107
114
  name: Home utilities
108
115
  aliases: [utilities, electric, electricity]
109
116
  mccs: [4900]
@@ -117,6 +124,7 @@
117
124
  aliases: [movies, movie theater]
118
125
  mccs: [7832]
119
126
  - id: entertainment
127
+ preposition: on
120
128
  name: Live entertainment and events
121
129
  aliases: [concerts, events, tickets]
122
130
  mccs: [7922, 7941]
@@ -146,15 +154,19 @@
146
154
  mccs: [5712]
147
155
 
148
156
  - id: online_retail
157
+ preposition: on
149
158
  name: Online retail
150
159
  aliases: [online shopping, shopping online, online]
151
160
  - id: office_supply
152
161
  name: Office supply stores
153
162
  - id: shipping
163
+ preposition: on
154
164
  name: Shipping and couriers
155
165
  - id: online_advertising
166
+ preposition: on
156
167
  name: Social media and search advertising
157
168
 
158
169
  - id: other
170
+ slug: everyday-spending
159
171
  name: Everything else
160
172
  aliases: [everything, everything else, other]
@@ -27,6 +27,8 @@
27
27
  value_cents: 1.0
28
28
  redemptions:
29
29
  sofi-account: 1.0 # SoFi Checking, Savings, Invest, the SoFi Credit Card account, loans, or SoFi Travel
30
+ redemption_names:
31
+ sofi-account: into a SoFi account
30
32
  sources:
31
33
  - https://www.sofi.com/credit-card/unlimited/
32
34
  - https://www.sofi.com/card/rewards?cardtype=c
@@ -0,0 +1,33 @@
1
+ # Memberships that raise a card's rates (a rule's `requires.membership`) or
2
+ # that a card can't be had without (a card's `requires_membership`). The
3
+ # fee for one a card needs is quoted in that card's evidence.
4
+
5
+ - id: amazon-prime
6
+ name: Amazon Prime
7
+ ask: I have Amazon Prime
8
+ - id: costco
9
+ name: Costco membership
10
+ ask: I have a Costco membership
11
+ # Gold Star, the basic tier. Executive ($130) pays some of its cost back
12
+ # as Costco's own 2% reward, which earnrate doesn't model.
13
+ annual_fee: 65
14
+ members_shop_at: [costco, costco-gas, costco-travel]
15
+ - id: bank-of-america-account
16
+ name: Bank of America checking or savings
17
+ ask: I bank with Bank of America
18
+ - id: bank-of-america-small-business-account
19
+ name: Bank of America small business checking or savings
20
+ - id: bofa-rewards-member
21
+ name: BofA Rewards Member
22
+ group: bofa-preferred-rewards
23
+ - id: bofa-rewards-preferred-plus
24
+ name: BofA Rewards Preferred Plus
25
+ group: bofa-preferred-rewards
26
+ - id: bofa-rewards-preferred-honors
27
+ name: BofA Rewards Preferred Honors
28
+ group: bofa-preferred-rewards
29
+ - id: bofa-rewards-premier
30
+ name: BofA Rewards Premier
31
+ group: bofa-preferred-rewards
32
+ - id: nordy-club-ambassador
33
+ name: Nordy Club Ambassador
@@ -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
 
@@ -160,6 +171,14 @@
160
171
  may_code_as: [dining, entertainment]
161
172
  note: Restaurants inside a hotel may code as dining, or as lodging if charged to the room. Shows and casino charges code separately.
162
173
 
174
+ - id: chipotle
175
+ name: Chipotle
176
+ aliases: [chipotle mexican grill]
177
+ category: dining
178
+ ambiguity:
179
+ may_code_as: [dining.fast_food]
180
+ note: Fast-casual chains can post as fast food (MCC 5814) instead of a restaurant (5812); a statement shows which.
181
+
163
182
  - id: comcast
164
183
  name: Comcast Xfinity
165
184
  aliases: [comcast, xfinity]
@@ -169,38 +188,45 @@
169
188
  # Card issuers' travel and ticketing sites (see the Amex Travel note above).
170
189
  - {
171
190
  id: capital-one-travel,
191
+ kind: portal,
172
192
  name: Capital One Travel,
173
193
  aliases: [capital one travel],
174
194
  category: travel,
175
195
  }
176
196
  - {
177
197
  id: capital-one-travel-hotels,
198
+ kind: booking,
178
199
  name: Hotel through Capital One Travel,
179
200
  aliases: [capital one travel hotel],
180
201
  category: travel,
181
202
  }
182
203
  - id: capital-one-travel-vacation-rentals
204
+ kind: booking
183
205
  name: Vacation rental through Capital One Travel
184
206
  aliases: [capital one travel vacation rental]
185
207
  category: travel
186
208
  - {
187
209
  id: capital-one-travel-car-rentals,
210
+ kind: booking,
188
211
  name: Car rental through Capital One Travel,
189
212
  aliases: [capital one travel car rental],
190
213
  category: travel,
191
214
  }
192
215
  - {
193
216
  id: capital-one-travel-flights,
217
+ kind: booking,
194
218
  name: Flight through Capital One Travel,
195
219
  aliases: [capital one travel flight],
196
220
  category: travel,
197
221
  }
198
222
  - id: capital-one-entertainment
223
+ kind: portal
199
224
  name: Capital One Entertainment
200
225
  aliases: [capital one entertainment tickets]
201
226
  category: entertainment
202
227
  - {
203
228
  id: citi-travel,
229
+ kind: portal,
204
230
  name: Citi Travel,
205
231
  aliases: [citi travel],
206
232
  domains: [cititravel.com],
@@ -208,42 +234,49 @@
208
234
  }
209
235
  - {
210
236
  id: citi-travel-hotels,
237
+ kind: booking,
211
238
  name: Hotel through Citi Travel,
212
239
  aliases: [citi travel hotel],
213
240
  category: travel,
214
241
  }
215
242
  - {
216
243
  id: citi-travel-car-rentals,
244
+ kind: booking,
217
245
  name: Car rental through Citi Travel,
218
246
  aliases: [citi travel car rental],
219
247
  category: travel,
220
248
  }
221
249
  - {
222
250
  id: citi-travel-attractions,
251
+ kind: booking,
223
252
  name: Attraction through Citi Travel,
224
253
  aliases: [citi travel attraction],
225
254
  category: travel,
226
255
  }
227
256
  - {
228
257
  id: citi-travel-flights,
258
+ kind: booking,
229
259
  name: Flight through Citi Travel,
230
260
  aliases: [citi travel flight],
231
261
  category: travel,
232
262
  }
233
263
  - {
234
264
  id: bilt-travel,
265
+ kind: portal,
235
266
  name: Bilt Travel,
236
267
  aliases: [bilt travel],
237
268
  category: travel,
238
269
  }
239
270
  - {
240
271
  id: bilt-travel-hotels,
272
+ kind: booking,
241
273
  name: Hotel through Bilt Travel,
242
274
  aliases: [bilt travel hotel],
243
275
  category: travel,
244
276
  }
245
277
  - {
246
278
  id: bilt-travel-flights,
279
+ kind: booking,
247
280
  name: Flight through Bilt Travel,
248
281
  aliases: [bilt travel flight],
249
282
  category: travel,
@@ -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,
@@ -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,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.
@@ -64,7 +64,7 @@ export async function runCheatSheet(argv, io) {
64
64
  memberships: values.member ?? [],
65
65
  valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
66
66
  ? VALUATION_PROFILES[values.valuation]
67
- : {},
67
+ : (await savedSettings(io)).valuations,
68
68
  });
69
69
  io.stdout(values.json
70
70
  ? `${JSON.stringify(sheet, null, 2)}\n`
@@ -2,19 +2,20 @@ import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
5
- import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
5
+ import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
6
6
  import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, 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
- --member <name> A membership you have, e.g. amazon-prime. Repeatable.
16
- --valuation <profile> flat (points at 1¢, the default) or points-heavy
17
- (MR and UR at 1.7¢).`;
15
+ --member <name> A membership you have, e.g. amazon-prime or costco. Repeatable.
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,7 +136,7 @@ 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;
@@ -150,7 +151,7 @@ export async function runBest(argv, io) {
150
151
  }, settings);
151
152
  io.stdout(values.json
152
153
  ? `${JSON.stringify(result, null, 2)}\n`
153
- : formatExploration(result, values.all ?? false));
154
+ : formatExploration(result, catalog, values.all ?? false));
154
155
  return 0;
155
156
  }
156
157
  function parseSpend(catalog, args) {
@@ -195,7 +196,7 @@ export async function runSimulate(argv, io) {
195
196
  return 0;
196
197
  }
197
198
  const catalog = await loadCatalog();
198
- const settings = settingsFrom(values, catalog);
199
+ const settings = settingsFrom(values, catalog, await savedSettings(io));
199
200
  if (typeof settings === 'string') {
200
201
  io.stderr(`${settings}\n`);
201
202
  return USAGE_ERROR;
@@ -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 { 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
 
@@ -22,13 +23,14 @@ Reads card statements (Amex and Chase activity CSVs) and reports:
22
23
 
23
24
  Name several statements, each with its card, to see one report across them.
24
25
 
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.
26
+ Nothing is written unless you pass --apply, and then only cap totals,
27
+ welcome-bonus spending and a fingerprint of each recorded purchase, so
28
+ importing twice never counts twice.
27
29
  The statements themselves are never stored.
28
30
 
29
31
  Options:
30
32
  --card <id> The card a statement is for, as in your wallet.
31
- --apply Record cap usage for the current period.
33
+ --apply Record cap usage and welcome-bonus spending.
32
34
  --valuation <profile> flat or points-heavy, instead of your wallet's values.
33
35
  -h, --help Show this help.
34
36
  `;
@@ -43,7 +45,6 @@ const LedgerSchema = z.strictObject({
43
45
  const fingerprint = ({ cardId, line }, kind = 'cap') => createHash('sha256')
44
46
  .update(`${kind === 'bonus' ? 'bonus|' : ''}${cardId}|${line.date}|${String(line.amount)}|${line.description}`)
45
47
  .digest('hex');
46
- const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
47
48
  // "statement.csv:chase-sapphire-preferred" names the card inline.
48
49
  function splitTarget(arg) {
49
50
  const m = /^(.+):([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(arg);
@@ -76,20 +77,20 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
76
77
  several
77
78
  ? `${String(a.byCard.length)} statements (${names.join(', ')}) · ${a.from} to ${a.to}`
78
79
  : `${names[0] ?? ''} statement · ${a.from} to ${a.to}`,
79
- `${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)})` : ''}`,
80
81
  '',
81
82
  ];
82
83
  if (several) {
83
- 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)}`);
84
85
  const width = Math.max(...names.map((n) => n.length));
85
86
  for (const t of a.byCard) {
86
- 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)}`);
87
88
  }
88
89
  }
89
90
  else {
90
- out.push(`Earned on ${names[0] ?? ''}: about ${usd(a.earned)}`);
91
+ out.push(`Earned on ${names[0] ?? ''}: about ${dollarsAndCents(a.earned)}`);
91
92
  }
92
- 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);
93
94
  if (a.missed.length > 0) {
94
95
  out.push('', 'Where another card would have earned more:');
95
96
  const shown = a.missed.slice(0, 10);
@@ -98,7 +99,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
98
99
  const cardWidth = Math.max(...shown.map((m) => labelOf(m.usedCardId).length));
99
100
  for (const m of shown) {
100
101
  const used = several ? `${labelOf(m.usedCardId).padEnd(cardWidth)} ` : '';
101
- 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)})`);
102
103
  }
103
104
  if (a.missed.length > 10)
104
105
  out.push(` …and ${String(a.missed.length - 10)} more merchants`);
@@ -116,7 +117,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
116
117
  const last = new Map();
117
118
  for (const { cardId, booking } of a.bookings)
118
119
  last.set(several ? `${labelOf(cardId)} ${booking.capKey}` : booking.capKey, booking.after);
119
- 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(', ')}.`);
120
121
  }
121
122
  else if (a.alreadyImported > 0) {
122
123
  out.push(`Caps: nothing new to record. ${String(a.alreadyImported)} purchase(s) were recorded by an earlier import.`);
@@ -128,7 +129,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
128
129
  out.push('Caps: nothing new to record.');
129
130
  }
130
131
  for (const b of a.bonuses) {
131
- 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}` : ''}.`);
132
133
  }
133
134
  return `${out.join('\n')}\n`;
134
135
  }
@@ -9,7 +9,8 @@ import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
9
9
  const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
10
10
 
11
11
  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.
12
+ bonus at your point values minus the first year's annual fee and any
13
+ membership the card needs that you don't have.
13
14
 
14
15
  Options:
15
16
  --cash Cash back cards only.
package/dist/cli/run.js CHANGED
@@ -6,7 +6,7 @@ import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV
6
6
  import { walletProblems } from "../data/integrity.js";
7
7
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
8
8
  import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
9
- import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.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";
@@ -42,7 +42,8 @@ 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
+ ${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.
@@ -120,6 +122,18 @@ export async function cardsToUse(catalog, file, io, named) {
120
122
  return loaded;
121
123
  }
122
124
  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
+ // The point values and memberships saved in the wallet, which every answer
126
+ // uses unless --valuation or --member says otherwise. No wallet, or one that
127
+ // can't be read, has none.
128
+ export async function savedSettings(io) {
129
+ try {
130
+ const { valuations, memberships } = await loadWallet(walletPath(io.env));
131
+ return { valuations, memberships };
132
+ }
133
+ catch {
134
+ return { valuations: {}, memberships: [] };
135
+ }
136
+ }
123
137
  export async function readWallet(file, io) {
124
138
  try {
125
139
  return await loadWallet(file);
@@ -222,7 +236,7 @@ export async function run(argv, io) {
222
236
  io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
223
237
  }
224
238
  else {
225
- io.stdout(`${formatExploration(explored, options.all)}\n${NO_WALLET_HINT}\n`);
239
+ io.stdout(`${formatExploration(explored, catalog, options.all)}\n${NO_WALLET_HINT}\n`);
226
240
  }
227
241
  for (const n of notices)
228
242
  io.stderr(`${n}\n`);
@@ -2,6 +2,7 @@ import { parseArgs } from 'node:util';
2
2
  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
+ import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
5
6
  import { knownMemberships, searchCards } from "../engine/setup.js";
6
7
  import { formatCardInfo, formatNotes } from "../format/cards.js";
7
8
  import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
@@ -53,6 +54,20 @@ Removes a card from your wallet, with its tracked cap usage.
53
54
 
54
55
  With no arguments, shows your memberships and the ones earnrate knows. With
55
56
  names, replaces the list. --none clears it.
57
+ `,
58
+ values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
59
+
60
+ What your points are worth to you, in cents per point, saved in your wallet
61
+ file; every answer then uses them unless you pass --valuation. With no
62
+ arguments, shows them.
63
+
64
+ Examples:
65
+ ${CLI_NAME} values UR=1.5 MR=1.6
66
+ ${CLI_NAME} values UR=default back to earnrate's value
67
+ ${CLI_NAME} values --profile points-heavy
68
+
69
+ Options:
70
+ --profile <name> flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}).
56
71
  `,
57
72
  };
58
73
  const HINTS = {
@@ -118,6 +133,7 @@ export async function runWalletCommand(command, argv, io) {
118
133
  'bonus-by': { type: 'string' },
119
134
  'no-bonus': { type: 'boolean' },
120
135
  none: { type: 'boolean' },
136
+ profile: { type: 'string' },
121
137
  help: { type: 'boolean', short: 'h' },
122
138
  },
123
139
  });
@@ -160,6 +176,32 @@ export async function runWalletCommand(command, argv, io) {
160
176
  io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
161
177
  return 0;
162
178
  }
179
+ if (command === 'values') {
180
+ const changes = {};
181
+ if (values.profile !== undefined) {
182
+ if (!isValuationProfile(values.profile))
183
+ return usage(`Unknown profile "${values.profile}". Use flat or points-heavy.`);
184
+ Object.assign(changes, VALUATION_PROFILES[values.profile]);
185
+ }
186
+ for (const arg of positionals) {
187
+ const [id, value] = arg.split('=');
188
+ const cents = value === 'default' ? null : Number(value);
189
+ if (!id ||
190
+ value === undefined ||
191
+ (cents !== null && !Number.isFinite(cents)))
192
+ return usage(`"${arg}" isn't <currency>=<cents>, like UR=1.5.`);
193
+ changes[id] = cents;
194
+ }
195
+ if (Object.keys(changes).length === 0) {
196
+ const held = await heldWallet(io);
197
+ const set = Object.entries(held?.valuations ?? {});
198
+ io.stdout(`${set.length > 0 ? `Yours: ${set.map(([id, v]) => `${id}=${typeof v === 'number' ? String(v) : JSON.stringify(v)}`).join(' ')}` : "Yours: earnrate's defaults"}\nCurrencies: ${catalog.currencies.map((c) => c.id).join(', ')}\n`);
199
+ return 0;
200
+ }
201
+ return report(await store.setValuations(changes), `Point values updated in ${where}: ${Object.entries(changes)
202
+ .map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
203
+ .join(', ')}.`, null, io);
204
+ }
163
205
  if (command === 'memberships') {
164
206
  if (positionals.length === 0 && !values.none) {
165
207
  const held = await heldWallet(io);
@@ -1,7 +1,7 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
- import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
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, updateCardText, writeProgress, } from "./wallet-edit.js";
4
+ import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
5
5
  const failed = (error) => ({ ok: false, error });
6
6
  const errorText = (e) => (e instanceof Error ? e.message : String(e));
7
7
  const queues = new Map();
@@ -76,6 +76,9 @@ export class FileWalletStore {
76
76
  setMemberships(memberships) {
77
77
  return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
78
78
  }
79
+ setValuations(changes) {
80
+ return this.change(this.existing((w) => withValuations(this.catalog, w, changes)), (text) => setValuationsText(text ?? '', changes));
81
+ }
79
82
  record(purchase, merchant) {
80
83
  return withWalletLock(this.where, async () => {
81
84
  const wallet = await this.load();