earnrate 0.1.4 → 0.2.1
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 +52 -17
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/categories.yaml +22 -7
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +67 -45
- package/dist/cli/cheatsheet.js +36 -35
- package/dist/cli/explore.js +47 -163
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +14 -56
- package/dist/cli/run.js +90 -20
- package/dist/cli/wallet.js +47 -6
- package/dist/data/file-store.js +95 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +92 -0
- package/dist/data/wallet-edit.js +93 -4
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +59 -4
- package/dist/engine/evaluate.js +34 -10
- package/dist/engine/explore.js +16 -28
- package/dist/engine/offers.js +8 -21
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +17 -53
- package/dist/engine/record.js +34 -4
- package/dist/engine/simulate.js +33 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +26 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +36 -15
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +19 -0
- package/dist/format/explore.js +114 -0
- package/dist/format/offers.js +33 -0
- package/dist/{cli/format.js → format/recommendation.js} +9 -11
- package/dist/format/wallet-yaml.js +45 -0
- package/dist/format/wallet.js +38 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +148 -0
- package/dist/mcp/schemas.js +8 -2
- package/dist/mcp/serve.js +3 -1
- package/dist/mcp/server.js +43 -660
- package/dist/mcp/tools/account.js +71 -0
- package/dist/mcp/tools/answers.js +329 -0
- package/dist/mcp/tools/wallet.js +128 -0
- package/dist/merchants/from-env.js +1 -1
- package/dist/merchants/resolve.js +3 -3
- package/dist/schema/catalog.js +1 -0
- package/package.json +8 -3
- package/dist/data/wallet-setup.js +0 -60
- package/dist/data/wallet-write.js +0 -140
- /package/dist/{cli → format}/cards.js +0 -0
package/dist/cli/explore.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
|
-
import { walletProblems } from "../data/integrity.js";
|
|
4
3
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
5
4
|
import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
|
|
6
5
|
import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
7
|
-
import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, TYPICAL_MONTHLY_SPEND, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
|
|
7
|
+
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
|
|
8
|
+
import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
|
|
9
|
+
import { cardsToUse, RUNTIME_ERROR, 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.
|
|
@@ -15,7 +15,7 @@ const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or point
|
|
|
15
15
|
--member <name> A membership you have, e.g. amazon-prime. Repeatable.
|
|
16
16
|
--valuation <profile> flat (points at 1¢, the default) or points-heavy
|
|
17
17
|
(MR and UR at 1.7¢).`;
|
|
18
|
-
|
|
18
|
+
const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
|
|
19
19
|
|
|
20
20
|
Ranks every card in earnrate's catalog for one purchase, not just yours. Each
|
|
21
21
|
card is set up at its best: the right category pick, activated, cap unused.
|
|
@@ -33,12 +33,12 @@ ${FILTER_OPTIONS}
|
|
|
33
33
|
--json Print the full result as JSON.
|
|
34
34
|
-h, --help Show this help.
|
|
35
35
|
`;
|
|
36
|
-
|
|
36
|
+
const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate [<what>=<dollars a month>...] [options]
|
|
37
37
|
|
|
38
38
|
Spends this much every month for a year and ranks the cards, and the
|
|
39
39
|
two-card pairs, that earn the most after annual fees. Each <what> is a
|
|
40
40
|
category (groceries, dining, gas, travel, streaming, other) or a merchant
|
|
41
|
-
(amazon, costco).
|
|
41
|
+
(amazon, costco). With no amounts, it uses typical ones for a U.S. household.
|
|
42
42
|
|
|
43
43
|
Examples:
|
|
44
44
|
${CLI_NAME} simulate groceries=600 dining=300 gas=150 other=1000
|
|
@@ -47,6 +47,7 @@ Examples:
|
|
|
47
47
|
Options:
|
|
48
48
|
--wallet Start from your wallet and rank the cards that would
|
|
49
49
|
add the most to it, after their fee.
|
|
50
|
+
--cards <ids> The same, for cards you name instead of your wallet.
|
|
50
51
|
${FILTER_OPTIONS}
|
|
51
52
|
--start <YYYY-MM-DD> First month of the year simulated. Default: today.
|
|
52
53
|
--all List more results.
|
|
@@ -69,15 +70,13 @@ const FILTER_FLAGS = {
|
|
|
69
70
|
function settingsFrom(values, catalog) {
|
|
70
71
|
if (values.cash && values.points)
|
|
71
72
|
return 'Pick --cash or --points, not both.';
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
const problem = valuationProblem(values.valuation) ??
|
|
74
|
+
membershipProblem(catalog, values.member ?? []);
|
|
75
|
+
if (problem !== null)
|
|
76
|
+
return problem;
|
|
74
77
|
const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
75
78
|
if (values.issuer !== undefined && !issuers.includes(values.issuer))
|
|
76
79
|
return `Unknown issuer "${values.issuer}". Use one of: ${issuers.join(', ')}.`;
|
|
77
|
-
const known = knownMemberships(catalog);
|
|
78
|
-
const unknown = (values.member ?? []).filter((m) => !known.includes(m));
|
|
79
|
-
if (unknown.length > 0)
|
|
80
|
-
return `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
|
|
81
80
|
return {
|
|
82
81
|
filters: {
|
|
83
82
|
...ALL_PERSONAL_CARDS,
|
|
@@ -93,130 +92,6 @@ function settingsFrom(values, catalog) {
|
|
|
93
92
|
: {},
|
|
94
93
|
};
|
|
95
94
|
}
|
|
96
|
-
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
97
|
-
const wholeUsd = (n) => usd(Math.round(n));
|
|
98
|
-
const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
|
|
99
|
-
function feeText(e) {
|
|
100
|
-
if (e.annualFee === null)
|
|
101
|
-
return 'annual fee not on file';
|
|
102
|
-
if (e.annualFee === 0)
|
|
103
|
-
return 'no annual fee';
|
|
104
|
-
if (e.firstYearFee !== null && e.firstYearFee !== e.annualFee)
|
|
105
|
-
return `${usd(e.annualFee)} annual fee (${e.firstYearFee === 0 ? 'waived' : usd(e.firstYearFee)} the first year)`;
|
|
106
|
-
return `${usd(e.annualFee)} annual fee`;
|
|
107
|
-
}
|
|
108
|
-
const TOP_BEST = 10;
|
|
109
|
-
export function formatExploration(r, all) {
|
|
110
|
-
const m = r.merchant;
|
|
111
|
-
const title = m.source === 'caller'
|
|
112
|
-
? m.resolvedName.toLowerCase()
|
|
113
|
-
: m.source === 'none'
|
|
114
|
-
? `"${m.input}" (not recognized)`
|
|
115
|
-
: m.resolvedName;
|
|
116
|
-
if (r.ranked.length === 0)
|
|
117
|
-
return `No cards match at ${title}.\n`;
|
|
118
|
-
const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
|
|
119
|
-
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
120
|
-
const lines = [`Best cards for ${title}:`, ''];
|
|
121
|
-
shown.forEach((e, i) => {
|
|
122
|
-
const earned = e.earned === null ? '' : ` ${usd(e.earned)}`;
|
|
123
|
-
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
124
|
-
for (const c of e.conditions)
|
|
125
|
-
lines.push(` ${c}`);
|
|
126
|
-
const upgrade = e.withMembership[0];
|
|
127
|
-
if (upgrade)
|
|
128
|
-
lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
129
|
-
for (const w of e.warnings)
|
|
130
|
-
lines.push(` ! ${w}`);
|
|
131
|
-
});
|
|
132
|
-
if (r.ranked.length > shown.length)
|
|
133
|
-
lines.push(` + ${String(r.ranked.length - shown.length)} more (--all lists them)`);
|
|
134
|
-
for (const w of r.warnings)
|
|
135
|
-
lines.push('', `! ${w}`);
|
|
136
|
-
lines.push('', ...r.assumptions);
|
|
137
|
-
return `${lines.join('\n')}\n`;
|
|
138
|
-
}
|
|
139
|
-
function picksText(picks) {
|
|
140
|
-
const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
|
|
141
|
-
return parts.length === 0 ? '' : ` (picks ${parts.join('; ')})`;
|
|
142
|
-
}
|
|
143
|
-
function cardNamer(catalog) {
|
|
144
|
-
return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
145
|
-
}
|
|
146
|
-
function splitText(sim, name) {
|
|
147
|
-
const byCard = new Map();
|
|
148
|
-
for (const line of sim.lines) {
|
|
149
|
-
const first = line.cards[0];
|
|
150
|
-
if (first === undefined)
|
|
151
|
-
continue;
|
|
152
|
-
byCard.set(first, [...(byCard.get(first) ?? []), line.label]);
|
|
153
|
-
}
|
|
154
|
-
return [...byCard].map(([id, labels]) => `${labels.join(', ')} → ${name(id)}`);
|
|
155
|
-
}
|
|
156
|
-
const TOP_SIMULATED = 5;
|
|
157
|
-
export function formatCatalogSimulation(r, catalog, all) {
|
|
158
|
-
const name = cardNamer(catalog);
|
|
159
|
-
const first = r.singles[0];
|
|
160
|
-
if (!first)
|
|
161
|
-
return 'No cards match.\n';
|
|
162
|
-
const top = all ? 20 : TOP_SIMULATED;
|
|
163
|
-
const lines = [
|
|
164
|
-
`A year of this spending: ${wholeUsd(first.spend)}.`,
|
|
165
|
-
'',
|
|
166
|
-
'Best single cards (rewards minus annual fee, per year):',
|
|
167
|
-
];
|
|
168
|
-
r.singles.slice(0, top).forEach((s, i) => {
|
|
169
|
-
const card = s.cards[0];
|
|
170
|
-
if (!card)
|
|
171
|
-
return;
|
|
172
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${picksText(card.picks)}`);
|
|
173
|
-
});
|
|
174
|
-
const pairs = r.pairs.slice(0, top);
|
|
175
|
-
if (pairs.length > 0) {
|
|
176
|
-
lines.push('', 'Best two-card pairs:');
|
|
177
|
-
pairs.forEach((s, i) => {
|
|
178
|
-
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeUsd(s.net)}`);
|
|
179
|
-
if (s.alternatives.length > 0)
|
|
180
|
-
lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
|
|
181
|
-
for (const split of splitText(s, name))
|
|
182
|
-
lines.push(` ${split}`);
|
|
183
|
-
});
|
|
184
|
-
const bestPair = r.pairs[0];
|
|
185
|
-
if (bestPair && bestPair.net <= first.net)
|
|
186
|
-
lines.push('', `One card is enough here: no pair beats ${name(first.cards[0]?.cardId ?? '')}.`);
|
|
187
|
-
}
|
|
188
|
-
const notes = new Set([
|
|
189
|
-
...r.singles.slice(0, top).flatMap((s) => s.notes),
|
|
190
|
-
...pairs.flatMap((s) => s.notes),
|
|
191
|
-
]);
|
|
192
|
-
if (notes.size > 0)
|
|
193
|
-
lines.push('', ...notes);
|
|
194
|
-
lines.push('', "Assumes each card's best picks, activations done each quarter, and no welcome bonuses.");
|
|
195
|
-
return `${lines.join('\n')}\n`;
|
|
196
|
-
}
|
|
197
|
-
export function formatAdditions(r, catalog, all) {
|
|
198
|
-
const name = cardNamer(catalog);
|
|
199
|
-
const lines = [
|
|
200
|
-
`Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees` : ''}.`,
|
|
201
|
-
];
|
|
202
|
-
for (const split of splitText(r.current, name))
|
|
203
|
-
lines.push(` ${split}`);
|
|
204
|
-
const gains = r.additions.filter((a) => a.gain > 0);
|
|
205
|
-
if (gains.length === 0) {
|
|
206
|
-
lines.push('', 'No card would add more than its annual fee costs.');
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
lines.push('', 'Cards that would add the most (after their annual fee):');
|
|
210
|
-
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
211
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee)${picksText(a.picks)}`);
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
const notes = new Set(r.current.notes);
|
|
215
|
-
if (notes.size > 0)
|
|
216
|
-
lines.push('', ...notes);
|
|
217
|
-
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
|
|
218
|
-
return `${lines.join('\n')}\n`;
|
|
219
|
-
}
|
|
220
95
|
export async function runBest(argv, io) {
|
|
221
96
|
let parsed;
|
|
222
97
|
try {
|
|
@@ -240,20 +115,23 @@ export async function runBest(argv, io) {
|
|
|
240
115
|
io.stdout(BEST_HELP);
|
|
241
116
|
return 0;
|
|
242
117
|
}
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
words.pop();
|
|
250
|
-
const text = words.join(' ').trim();
|
|
118
|
+
const split = splitAmount(positionals);
|
|
119
|
+
if (typeof split === 'string') {
|
|
120
|
+
io.stderr(`${split}\n`);
|
|
121
|
+
return USAGE_ERROR;
|
|
122
|
+
}
|
|
123
|
+
const { text, amount } = split;
|
|
251
124
|
if (text === '') {
|
|
252
125
|
io.stderr(`Say what you're buying, e.g. ${CLI_NAME} best groceries.\n`);
|
|
253
126
|
return USAGE_ERROR;
|
|
254
127
|
}
|
|
255
128
|
if (values.online && values['in-store']) {
|
|
256
|
-
io.stderr(
|
|
129
|
+
io.stderr(`${BOTH_CHANNELS}\n`);
|
|
130
|
+
return USAGE_ERROR;
|
|
131
|
+
}
|
|
132
|
+
const dated = dateProblem(values.date);
|
|
133
|
+
if (dated !== null) {
|
|
134
|
+
io.stderr(`${dated}\n`);
|
|
257
135
|
return USAGE_ERROR;
|
|
258
136
|
}
|
|
259
137
|
const catalog = await loadCatalog();
|
|
@@ -267,11 +145,7 @@ export async function runBest(argv, io) {
|
|
|
267
145
|
merchant: category === null ? text : null,
|
|
268
146
|
category,
|
|
269
147
|
amount,
|
|
270
|
-
channel: values
|
|
271
|
-
? 'online'
|
|
272
|
-
: values['in-store']
|
|
273
|
-
? 'in_store'
|
|
274
|
-
: null,
|
|
148
|
+
channel: channelOf(values),
|
|
275
149
|
date: values.date ?? io.today,
|
|
276
150
|
}, settings);
|
|
277
151
|
io.stdout(values.json
|
|
@@ -279,7 +153,7 @@ export async function runBest(argv, io) {
|
|
|
279
153
|
: formatExploration(result, values.all ?? false));
|
|
280
154
|
return 0;
|
|
281
155
|
}
|
|
282
|
-
|
|
156
|
+
function parseSpend(catalog, args) {
|
|
283
157
|
const lines = [];
|
|
284
158
|
for (const arg of args) {
|
|
285
159
|
const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
|
|
@@ -306,6 +180,7 @@ export async function runSimulate(argv, io) {
|
|
|
306
180
|
options: {
|
|
307
181
|
...FILTER_FLAGS,
|
|
308
182
|
wallet: { type: 'boolean' },
|
|
183
|
+
cards: { type: 'string' },
|
|
309
184
|
start: { type: 'string' },
|
|
310
185
|
},
|
|
311
186
|
});
|
|
@@ -325,7 +200,10 @@ export async function runSimulate(argv, io) {
|
|
|
325
200
|
io.stderr(`${settings}\n`);
|
|
326
201
|
return USAGE_ERROR;
|
|
327
202
|
}
|
|
328
|
-
const
|
|
203
|
+
const typical = positionals.length === 0;
|
|
204
|
+
const spend = typical
|
|
205
|
+
? TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly))
|
|
206
|
+
: parseSpend(catalog, positionals);
|
|
329
207
|
if (typeof spend === 'string') {
|
|
330
208
|
io.stderr(`${spend}\n`);
|
|
331
209
|
return USAGE_ERROR;
|
|
@@ -336,16 +214,22 @@ export async function runSimulate(argv, io) {
|
|
|
336
214
|
io.stderr(`Not a category or a merchant earnrate knows: ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Categories include groceries, dining, gas, travel, streaming and other.\n`);
|
|
337
215
|
return USAGE_ERROR;
|
|
338
216
|
}
|
|
217
|
+
const started = dateProblem(values.start);
|
|
218
|
+
if (started !== null) {
|
|
219
|
+
io.stderr(`${started}\n`);
|
|
220
|
+
return USAGE_ERROR;
|
|
221
|
+
}
|
|
339
222
|
const options = { ...settings, start: values.start ?? io.today };
|
|
340
|
-
if (values.wallet) {
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (problems.length > 0) {
|
|
347
|
-
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
223
|
+
if (values.wallet || values.cards !== undefined) {
|
|
224
|
+
const wallet = await cardsToUse(catalog, walletPath(io.env), io, {
|
|
225
|
+
cards: cardList(values.cards),
|
|
226
|
+
members: values.member ?? [],
|
|
227
|
+
});
|
|
228
|
+
if (wallet === null)
|
|
348
229
|
return RUNTIME_ERROR;
|
|
230
|
+
if (wallet === 'none') {
|
|
231
|
+
io.stderr(`No saved wallet yet. Name your cards instead: --cards amex-gold,chase-prime-visa\n`);
|
|
232
|
+
return USAGE_ERROR;
|
|
349
233
|
}
|
|
350
234
|
const valued = values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
351
235
|
? withValuationProfile(wallet, values.valuation)
|
|
@@ -353,12 +237,12 @@ export async function runSimulate(argv, io) {
|
|
|
353
237
|
const result = simulateAdditions(catalog, valued, lines, options);
|
|
354
238
|
io.stdout(values.json
|
|
355
239
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
356
|
-
: formatAdditions(result, catalog, values.all ?? false));
|
|
240
|
+
: `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatAdditions(result, catalog, values.all ?? false)}`);
|
|
357
241
|
return 0;
|
|
358
242
|
}
|
|
359
243
|
const result = simulateCatalog(catalog, lines, options);
|
|
360
244
|
io.stdout(values.json
|
|
361
245
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
362
|
-
: formatCatalogSimulation(result, catalog, values.all ?? false));
|
|
246
|
+
: `${typical ? `${TYPICAL_SPEND_NOTE}\n\n` : ''}${formatCatalogSimulation(result, catalog, values.all ?? false)}`);
|
|
363
247
|
return 0;
|
|
364
248
|
}
|
package/dist/cli/import.js
CHANGED
|
@@ -6,13 +6,13 @@ import { z } from 'zod';
|
|
|
6
6
|
import { CLI_NAME } from "../brand.js";
|
|
7
7
|
import { walletProblems } from "../data/integrity.js";
|
|
8
8
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
9
|
-
import {
|
|
9
|
+
import { writeProgress } from "../data/wallet-edit.js";
|
|
10
10
|
import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
|
|
11
11
|
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
|
-
|
|
15
|
+
const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
|
|
16
16
|
${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
|
|
17
17
|
|
|
18
18
|
Reads card statements (Amex and Chase activity CSVs) and reports:
|
|
@@ -241,12 +241,7 @@ export async function runImport(argv, io) {
|
|
|
241
241
|
: `Nothing was written. Run again with --apply to record ${toRecord.join(' and ')}.\n`);
|
|
242
242
|
return 0;
|
|
243
243
|
}
|
|
244
|
-
|
|
245
|
-
await writeCapBooking(walletFile, booking);
|
|
246
|
-
}
|
|
247
|
-
for (const b of analysis.bonuses) {
|
|
248
|
-
await writeBonusSpent(walletFile, b.cardId, b.after);
|
|
249
|
-
}
|
|
244
|
+
await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses);
|
|
250
245
|
for (const entry of analysis.bookings)
|
|
251
246
|
recorded.add(fingerprint(entry));
|
|
252
247
|
for (const entry of bonusEntries)
|
package/dist/cli/offers.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
|
-
import { walletProblems } from "../data/integrity.js";
|
|
4
3
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
5
|
-
import { compareOffers
|
|
4
|
+
import { compareOffers } from "../engine/offers.js";
|
|
6
5
|
import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
6
|
+
import { formatOffers } from "../format/offers.js";
|
|
7
|
+
import { valuationProblem } from "./args.js";
|
|
8
|
+
import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
9
|
+
const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
|
|
9
10
|
|
|
10
11
|
Ranks the welcome offers on cards you don't have by first-year value: the
|
|
11
12
|
bonus at your point values minus the first year's annual fee.
|
|
@@ -21,48 +22,6 @@ Options:
|
|
|
21
22
|
--json Print the full comparison as JSON.
|
|
22
23
|
-h, --help Show this help.
|
|
23
24
|
`;
|
|
24
|
-
const TOP = 10;
|
|
25
|
-
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
26
|
-
function reward(o, currencyName) {
|
|
27
|
-
if (o.dollars !== null)
|
|
28
|
-
return usd(o.dollars);
|
|
29
|
-
return `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
30
|
-
}
|
|
31
|
-
function feeText(o) {
|
|
32
|
-
if (o.annualFee === null)
|
|
33
|
-
return 'annual fee not on file';
|
|
34
|
-
if (o.annualFee === 0)
|
|
35
|
-
return 'no annual fee';
|
|
36
|
-
if (o.firstYearFee !== null && o.firstYearFee !== o.annualFee)
|
|
37
|
-
return `${usd(o.annualFee)} annual fee (${o.firstYearFee === 0 ? 'waived' : usd(o.firstYearFee)} the first year)`;
|
|
38
|
-
return `${usd(o.annualFee)} annual fee`;
|
|
39
|
-
}
|
|
40
|
-
export function formatOffers(c, currencyName, all) {
|
|
41
|
-
if (c.offers.length === 0) {
|
|
42
|
-
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
43
|
-
}
|
|
44
|
-
const shown = all ? c.offers : c.offers.slice(0, TOP);
|
|
45
|
-
const width = Math.max(...shown.map((o) => o.cardName.length));
|
|
46
|
-
const lines = [];
|
|
47
|
-
shown.forEach((o, i) => {
|
|
48
|
-
const weeks = (o.months * 52) / 12;
|
|
49
|
-
const pace = o.spend > 0 ? ` (about ${usd(Math.round(o.spend / weeks))} a week)` : '';
|
|
50
|
-
const spend = o.spend > 0
|
|
51
|
-
? `after ${usd(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
|
|
52
|
-
: 'with no spending required';
|
|
53
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${usd(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.held ? ' In your wallet.' : ''}`);
|
|
54
|
-
if (o.validTo)
|
|
55
|
-
lines.push(` Offer ends ${o.validTo}.`);
|
|
56
|
-
if (o.note)
|
|
57
|
-
lines.push(` ${o.note}`);
|
|
58
|
-
});
|
|
59
|
-
if (c.offers.length > shown.length)
|
|
60
|
-
lines.push(` + ${String(c.offers.length - shown.length)} more (--all lists them)`);
|
|
61
|
-
if (c.withoutOffer.length > 0)
|
|
62
|
-
lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
|
|
63
|
-
lines.push('', ...c.notes);
|
|
64
|
-
return `${lines.join('\n')}\n`;
|
|
65
|
-
}
|
|
66
25
|
export async function runOffers(argv, io) {
|
|
67
26
|
let parsed;
|
|
68
27
|
try {
|
|
@@ -94,20 +53,19 @@ export async function runOffers(argv, io) {
|
|
|
94
53
|
io.stderr('Pick --cash or --points, not both.\n');
|
|
95
54
|
return USAGE_ERROR;
|
|
96
55
|
}
|
|
97
|
-
|
|
98
|
-
|
|
56
|
+
const problem = valuationProblem(values.valuation);
|
|
57
|
+
if (problem !== null) {
|
|
58
|
+
io.stderr(`${problem}\n`);
|
|
99
59
|
return USAGE_ERROR;
|
|
100
60
|
}
|
|
101
|
-
const walletFile = walletPath(io.env);
|
|
102
|
-
const loaded = await readWallet(walletFile, io);
|
|
103
|
-
if (!loaded)
|
|
104
|
-
return RUNTIME_ERROR;
|
|
105
61
|
const catalog = await loadCatalog();
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
62
|
+
const found = await cardsToUse(catalog, walletPath(io.env), io, {
|
|
63
|
+
cards: null,
|
|
64
|
+
members: [],
|
|
65
|
+
});
|
|
66
|
+
if (found === null)
|
|
109
67
|
return RUNTIME_ERROR;
|
|
110
|
-
}
|
|
68
|
+
const loaded = found === 'none' ? { valuations: {}, memberships: [], cards: [] } : found;
|
|
111
69
|
const wallet = values.valuation && isValuationProfile(values.valuation)
|
|
112
70
|
? withValuationProfile(loaded, values.valuation)
|
|
113
71
|
: loaded;
|
package/dist/cli/run.js
CHANGED
|
@@ -1,26 +1,32 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
1
2
|
import { homedir } from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
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";
|
|
5
6
|
import { walletProblems } from "../data/integrity.js";
|
|
6
7
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
7
|
-
import {
|
|
8
|
+
import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
|
|
9
|
+
import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
|
|
8
10
|
import { recommend } from "../engine/recommend.js";
|
|
11
|
+
import { walletFromCards } from "../engine/transient.js";
|
|
12
|
+
import { formatExploration } from "../format/explore.js";
|
|
13
|
+
import { formatRecommendation, plain, } from "../format/recommendation.js";
|
|
9
14
|
import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
|
|
10
15
|
import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
|
|
11
16
|
import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
12
17
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
13
18
|
import { packageVersion } from "../version.js";
|
|
14
|
-
import { parseCliArgs } from "./args.js";
|
|
19
|
+
import { membershipProblem, parseCliArgs } from "./args.js";
|
|
15
20
|
import { runCheatSheet } from "./cheatsheet.js";
|
|
16
21
|
import { runBest, runSimulate } from "./explore.js";
|
|
17
22
|
import { runImport } from "./import.js";
|
|
18
23
|
import { runOffers } from "./offers.js";
|
|
19
24
|
import { runWalletCommand, WALLET_HELP } from "./wallet.js";
|
|
20
|
-
|
|
21
|
-
export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
|
|
25
|
+
const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
|
|
22
26
|
|
|
23
|
-
Picks the card
|
|
27
|
+
Picks the card that earns the most at a merchant, and says why: from your
|
|
28
|
+
wallet if you've saved one, from cards you name with --cards, or else from
|
|
29
|
+
every card earnrate knows.
|
|
24
30
|
|
|
25
31
|
Examples:
|
|
26
32
|
${CLI_NAME} whole foods 80
|
|
@@ -31,7 +37,7 @@ Compare every card, no wallet needed:
|
|
|
31
37
|
${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
|
|
32
38
|
${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
Save your cards (optional):
|
|
35
41
|
${CLI_NAME} cards [search words] Find cards and their ids.
|
|
36
42
|
${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
|
|
37
43
|
${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
|
|
@@ -39,10 +45,10 @@ Set up your wallet:
|
|
|
39
45
|
${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
|
|
40
46
|
|
|
41
47
|
Other commands:
|
|
42
|
-
${CLI_NAME} cheatsheet
|
|
48
|
+
${CLI_NAME} cheatsheet The best card for each category this quarter.
|
|
43
49
|
${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
|
|
44
50
|
${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
|
|
45
|
-
${CLI_NAME} mcp Run the MCP server over stdio, for AI
|
|
51
|
+
${CLI_NAME} mcp Run the MCP server over stdio, for AI assistants.
|
|
46
52
|
Add --help to any command for its options.
|
|
47
53
|
|
|
48
54
|
Options:
|
|
@@ -53,6 +59,9 @@ Options:
|
|
|
53
59
|
--json Print the full recommendation as JSON.
|
|
54
60
|
--date <YYYY-MM-DD> Purchase date. Default: today.
|
|
55
61
|
--country <code> Where you're paying. Default: US.
|
|
62
|
+
--cards <ids> Answer for these cards instead of your wallet, comma
|
|
63
|
+
separated: --cards amex-gold,chase-prime-visa.
|
|
64
|
+
--member <name> A membership you have, with --cards or no wallet.
|
|
56
65
|
--category <id> For a merchant earnrate doesn't know: its category,
|
|
57
66
|
e.g. grocery.supermarket. Ignored for known merchants.
|
|
58
67
|
--llm, --no-llm Ask Claude to classify merchants earnrate doesn't know.
|
|
@@ -75,6 +84,42 @@ export function displayPath(file) {
|
|
|
75
84
|
const home = homedir();
|
|
76
85
|
return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
|
|
77
86
|
}
|
|
87
|
+
// 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.
|
|
89
|
+
export async function cardsToUse(catalog, file, io, named) {
|
|
90
|
+
const problem = membershipProblem(catalog, named.members);
|
|
91
|
+
if (problem !== null) {
|
|
92
|
+
io.stderr(`${problem}\n`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (named.cards !== null) {
|
|
96
|
+
const wallet = walletFromCards(catalog, {
|
|
97
|
+
cards: named.cards.map((id) => ({ id })),
|
|
98
|
+
memberships: named.members,
|
|
99
|
+
}, io.today);
|
|
100
|
+
if (typeof wallet === 'string') {
|
|
101
|
+
io.stderr(`${wallet}\n`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return wallet;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await access(file);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return 'none';
|
|
111
|
+
}
|
|
112
|
+
const loaded = await readWallet(file, io);
|
|
113
|
+
if (!loaded)
|
|
114
|
+
return null;
|
|
115
|
+
const problems = walletProblems(loaded, catalog);
|
|
116
|
+
if (problems.length > 0) {
|
|
117
|
+
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return loaded;
|
|
121
|
+
}
|
|
122
|
+
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).`;
|
|
78
123
|
export async function readWallet(file, io) {
|
|
79
124
|
try {
|
|
80
125
|
return await loadWallet(file);
|
|
@@ -119,19 +164,10 @@ export async function run(argv, io) {
|
|
|
119
164
|
}
|
|
120
165
|
const { options } = args;
|
|
121
166
|
const llmOn = options.llm ?? llmEnabled(io.env);
|
|
122
|
-
const file = walletPath(io.env);
|
|
123
|
-
const loaded = await readWallet(file, io);
|
|
124
|
-
if (!loaded)
|
|
125
|
-
return RUNTIME_ERROR;
|
|
126
167
|
const catalog = await loadCatalog();
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
129
|
-
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
168
|
+
const loaded = await cardsToUse(catalog, walletPath(io.env), io, options);
|
|
169
|
+
if (loaded === null)
|
|
130
170
|
return RUNTIME_ERROR;
|
|
131
|
-
}
|
|
132
|
-
const wallet = options.valuation
|
|
133
|
-
? withValuationProfile(loaded, options.valuation)
|
|
134
|
-
: loaded;
|
|
135
171
|
const purchase = {
|
|
136
172
|
merchant: options.merchant,
|
|
137
173
|
amount: options.amount,
|
|
@@ -144,7 +180,14 @@ export async function run(argv, io) {
|
|
|
144
180
|
io.stderr(`Unknown category "${options.category}". Use one of: ${catalog.categories.map((c) => c.id).join(', ')}.\n`);
|
|
145
181
|
return USAGE_ERROR;
|
|
146
182
|
}
|
|
147
|
-
|
|
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);
|
|
148
191
|
const notices = [];
|
|
149
192
|
if (llmOn && merchant.source === 'none') {
|
|
150
193
|
const opened = await openLlmSession({
|
|
@@ -161,6 +204,33 @@ export async function run(argv, io) {
|
|
|
161
204
|
merchant = resolved.merchant;
|
|
162
205
|
notices.push(...resolved.notices);
|
|
163
206
|
}
|
|
207
|
+
if (loaded === 'none') {
|
|
208
|
+
const explored = exploreCards(catalog, {
|
|
209
|
+
merchant: purchase.merchant,
|
|
210
|
+
category: null,
|
|
211
|
+
amount: purchase.amount,
|
|
212
|
+
channel: purchase.channel,
|
|
213
|
+
date: purchase.date,
|
|
214
|
+
}, {
|
|
215
|
+
filters: ALL_PERSONAL_CARDS,
|
|
216
|
+
memberships: options.members,
|
|
217
|
+
valuations: options.valuation
|
|
218
|
+
? VALUATION_PROFILES[options.valuation]
|
|
219
|
+
: {},
|
|
220
|
+
}, merchant);
|
|
221
|
+
if (options.json) {
|
|
222
|
+
io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
io.stdout(`${formatExploration(explored, options.all)}\n${NO_WALLET_HINT}\n`);
|
|
226
|
+
}
|
|
227
|
+
for (const n of notices)
|
|
228
|
+
io.stderr(`${n}\n`);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
const wallet = options.valuation
|
|
232
|
+
? withValuationProfile(loaded, options.valuation)
|
|
233
|
+
: loaded;
|
|
164
234
|
const rec = recommend(catalog, wallet, purchase, merchant);
|
|
165
235
|
if (options.json) {
|
|
166
236
|
io.stdout(`${JSON.stringify(rec, null, 2)}\n`);
|