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.
- package/README.md +15 -14
- package/data/CHANGELOG.md +74 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +5 -4
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards.yaml +7 -0
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier.yaml +4 -0
- package/data/cards/chase-southwest-priority.yaml +4 -0
- package/data/cards/chase-united-club.yaml +1 -1
- package/data/cards/citi-aadvantage-globe.yaml +7 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +9 -1
- package/data/cards/citi-costco-anywhere-visa.yaml +9 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +33 -0
- package/data/merchants/seed.yaml +0 -6
- package/dist/cli/cheatsheet.js +3 -3
- package/dist/cli/explore.js +12 -11
- package/dist/cli/import.js +13 -12
- package/dist/cli/offers.js +2 -1
- package/dist/cli/run.js +18 -4
- package/dist/cli/wallet.js +42 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +26 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +8 -1
- package/dist/data/wallet-edit.js +83 -2
- package/dist/engine/cheat-sheet.js +53 -14
- package/dist/engine/compare.js +8 -0
- package/dist/engine/explore.js +18 -20
- package/dist/engine/memberships.js +71 -0
- package/dist/engine/offers.js +37 -5
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +78 -44
- package/dist/engine/setup.js +22 -5
- package/dist/engine/simulate.js +36 -14
- package/dist/engine/text.js +67 -2
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +42 -4
- package/dist/engine/wallet-summary.js +83 -37
- package/dist/format/card-text.js +19 -29
- package/dist/format/cards.js +8 -9
- package/dist/format/explore.js +22 -14
- package/dist/format/offers.js +12 -21
- package/dist/format/recommendation.js +16 -10
- package/dist/format/wallet-yaml.js +18 -3
- package/dist/format/wallet.js +16 -20
- package/dist/mcp/context.js +31 -4
- package/dist/mcp/inputs.js +17 -8
- package/dist/mcp/schemas.js +21 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/account.js +28 -10
- package/dist/mcp/tools/answers.js +51 -18
- package/dist/mcp/tools/wallet.js +106 -11
- package/dist/schema/card.js +7 -0
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +4 -0
- package/package.json +1 -1
- package/wallet.example.yaml +1 -0
package/data/categories.yaml
CHANGED
|
@@ -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]
|
package/data/currencies.yaml
CHANGED
|
@@ -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,
|
package/data/merchants/seed.yaml
CHANGED
package/dist/cli/cheatsheet.js
CHANGED
|
@@ -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`
|
package/dist/cli/explore.js
CHANGED
|
@@ -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 (
|
|
17
|
-
(
|
|
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;
|
package/dist/cli/import.js
CHANGED
|
@@ -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
|
|
26
|
-
fingerprint of each recorded purchase, so
|
|
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
|
|
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, ${
|
|
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 ${
|
|
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)} × ${
|
|
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 ${
|
|
91
|
+
out.push(`Earned on ${names[0] ?? ''}: about ${dollarsAndCents(a.earned)}`);
|
|
91
92
|
}
|
|
92
|
-
out.push(`With earnrate's picks: about ${
|
|
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)} × ${
|
|
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} ${
|
|
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)}: ${
|
|
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
|
}
|
package/dist/cli/offers.js
CHANGED
|
@@ -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 (
|
|
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`);
|
package/dist/cli/wallet.js
CHANGED
|
@@ -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);
|
package/dist/data/file-store.js
CHANGED
|
@@ -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();
|