earnrate 0.1.2 → 0.1.4
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 +22 -4
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/cards/citi-custom-cash.yaml +4 -3
- package/data/categories.yaml +24 -0
- package/dist/cli/cheatsheet.js +81 -0
- package/dist/cli/explore.js +364 -0
- package/dist/cli/run.js +13 -0
- package/dist/data/wallet-setup.js +23 -165
- package/dist/engine/cheat-sheet.js +48 -0
- package/dist/engine/explore.js +292 -0
- package/dist/engine/recommend.js +7 -7
- package/dist/engine/setup.js +1 -1
- package/dist/engine/simulate.js +309 -0
- package/dist/engine/wallet-change.js +179 -0
- package/dist/mcp/schemas.js +83 -0
- package/dist/mcp/server.js +219 -17
- package/dist/schema/card.js +2 -0
- package/dist/schema/category.js +2 -0
- package/package.json +9 -3
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { CLI_NAME } from "../brand.js";
|
|
3
|
+
import { walletProblems } from "../data/integrity.js";
|
|
4
|
+
import { loadCatalog, walletPath } from "../data/load.js";
|
|
5
|
+
import { ALL_PERSONAL_CARDS, exploreCards, findCategory, } from "../engine/explore.js";
|
|
6
|
+
import { isValuationProfile, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
7
|
+
import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
|
|
8
|
+
import { knownMemberships } from "../engine/setup.js";
|
|
9
|
+
import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
10
|
+
const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
|
|
11
|
+
--no-fee Cards with no annual fee.
|
|
12
|
+
--issuer <id> One issuer's cards, e.g. chase or amex.
|
|
13
|
+
--business Business cards instead of personal ones.
|
|
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¢).`;
|
|
18
|
+
export const BEST_HELP = `Usage: ${CLI_NAME} best <merchant or category> [amount] [options]
|
|
19
|
+
|
|
20
|
+
Ranks every card in earnrate's catalog for one purchase, not just yours. Each
|
|
21
|
+
card is set up at its best: the right category pick, activated, cap unused.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
${CLI_NAME} best groceries
|
|
25
|
+
${CLI_NAME} best amazon 120 --member amazon-prime
|
|
26
|
+
${CLI_NAME} best dining --no-fee
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
${FILTER_OPTIONS}
|
|
30
|
+
--online, --in-store How you're paying. Default: inferred from the merchant.
|
|
31
|
+
--date <YYYY-MM-DD> Purchase date. Default: today.
|
|
32
|
+
--all List every card, not just the top 10.
|
|
33
|
+
--json Print the full result as JSON.
|
|
34
|
+
-h, --help Show this help.
|
|
35
|
+
`;
|
|
36
|
+
export const SIMULATE_HELP = `Usage: ${CLI_NAME} simulate <what>=<dollars a month>... [options]
|
|
37
|
+
|
|
38
|
+
Spends this much every month for a year and ranks the cards, and the
|
|
39
|
+
two-card pairs, that earn the most after annual fees. Each <what> is a
|
|
40
|
+
category (groceries, dining, gas, travel, streaming, other) or a merchant
|
|
41
|
+
(amazon, costco).
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
${CLI_NAME} simulate groceries=600 dining=300 gas=150 other=1000
|
|
45
|
+
${CLI_NAME} simulate groceries=600 amazon=150 other=800 --wallet
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--wallet Start from your wallet and rank the cards that would
|
|
49
|
+
add the most to it, after their fee.
|
|
50
|
+
${FILTER_OPTIONS}
|
|
51
|
+
--start <YYYY-MM-DD> First month of the year simulated. Default: today.
|
|
52
|
+
--all List more results.
|
|
53
|
+
--json Print the full result as JSON.
|
|
54
|
+
-h, --help Show this help.
|
|
55
|
+
`;
|
|
56
|
+
const FILTER_FLAGS = {
|
|
57
|
+
cash: { type: 'boolean' },
|
|
58
|
+
points: { type: 'boolean' },
|
|
59
|
+
'no-fee': { type: 'boolean' },
|
|
60
|
+
issuer: { type: 'string' },
|
|
61
|
+
business: { type: 'boolean' },
|
|
62
|
+
closed: { type: 'boolean' },
|
|
63
|
+
member: { type: 'string', multiple: true },
|
|
64
|
+
valuation: { type: 'string' },
|
|
65
|
+
all: { type: 'boolean' },
|
|
66
|
+
json: { type: 'boolean' },
|
|
67
|
+
help: { type: 'boolean', short: 'h' },
|
|
68
|
+
};
|
|
69
|
+
function settingsFrom(values, catalog) {
|
|
70
|
+
if (values.cash && values.points)
|
|
71
|
+
return 'Pick --cash or --points, not both.';
|
|
72
|
+
if (values.valuation !== undefined && !isValuationProfile(values.valuation))
|
|
73
|
+
return `Unknown valuation "${values.valuation}". Use flat or points-heavy.`;
|
|
74
|
+
const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
75
|
+
if (values.issuer !== undefined && !issuers.includes(values.issuer))
|
|
76
|
+
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
|
+
return {
|
|
82
|
+
filters: {
|
|
83
|
+
...ALL_PERSONAL_CARDS,
|
|
84
|
+
kind: values.cash ? 'cash' : values.points ? 'points' : 'any',
|
|
85
|
+
business: values.business ?? false,
|
|
86
|
+
noAnnualFee: values['no-fee'] ?? false,
|
|
87
|
+
issuer: values.issuer ?? null,
|
|
88
|
+
includeClosed: values.closed ?? false,
|
|
89
|
+
},
|
|
90
|
+
memberships: values.member ?? [],
|
|
91
|
+
valuations: values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
92
|
+
? VALUATION_PROFILES[values.valuation]
|
|
93
|
+
: {},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
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
|
+
export async function runBest(argv, io) {
|
|
221
|
+
let parsed;
|
|
222
|
+
try {
|
|
223
|
+
parsed = parseArgs({
|
|
224
|
+
args: argv,
|
|
225
|
+
allowPositionals: true,
|
|
226
|
+
options: {
|
|
227
|
+
...FILTER_FLAGS,
|
|
228
|
+
online: { type: 'boolean' },
|
|
229
|
+
'in-store': { type: 'boolean' },
|
|
230
|
+
date: { type: 'string' },
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
io.stderr(`${e instanceof Error ? e.message : String(e)}\n${BEST_HELP}`);
|
|
236
|
+
return USAGE_ERROR;
|
|
237
|
+
}
|
|
238
|
+
const { values, positionals } = parsed;
|
|
239
|
+
if (values.help) {
|
|
240
|
+
io.stdout(BEST_HELP);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
const words = [...positionals];
|
|
244
|
+
const last = words.at(-1);
|
|
245
|
+
const amount = words.length > 1 && last !== undefined && /^\$?\d+(\.\d+)?$/.test(last)
|
|
246
|
+
? Number(last.replace('$', ''))
|
|
247
|
+
: null;
|
|
248
|
+
if (amount !== null)
|
|
249
|
+
words.pop();
|
|
250
|
+
const text = words.join(' ').trim();
|
|
251
|
+
if (text === '') {
|
|
252
|
+
io.stderr(`Say what you're buying, e.g. ${CLI_NAME} best groceries.\n`);
|
|
253
|
+
return USAGE_ERROR;
|
|
254
|
+
}
|
|
255
|
+
if (values.online && values['in-store']) {
|
|
256
|
+
io.stderr('Pick --online or --in-store, not both.\n');
|
|
257
|
+
return USAGE_ERROR;
|
|
258
|
+
}
|
|
259
|
+
const catalog = await loadCatalog();
|
|
260
|
+
const settings = settingsFrom(values, catalog);
|
|
261
|
+
if (typeof settings === 'string') {
|
|
262
|
+
io.stderr(`${settings}\n`);
|
|
263
|
+
return USAGE_ERROR;
|
|
264
|
+
}
|
|
265
|
+
const category = findCategory(catalog, text);
|
|
266
|
+
const result = exploreCards(catalog, {
|
|
267
|
+
merchant: category === null ? text : null,
|
|
268
|
+
category,
|
|
269
|
+
amount,
|
|
270
|
+
channel: values.online
|
|
271
|
+
? 'online'
|
|
272
|
+
: values['in-store']
|
|
273
|
+
? 'in_store'
|
|
274
|
+
: null,
|
|
275
|
+
date: values.date ?? io.today,
|
|
276
|
+
}, settings);
|
|
277
|
+
io.stdout(values.json
|
|
278
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
279
|
+
: formatExploration(result, values.all ?? false));
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
export function parseSpend(catalog, args) {
|
|
283
|
+
const lines = [];
|
|
284
|
+
for (const arg of args) {
|
|
285
|
+
const match = /^(.+)=\$?(\d+(?:\.\d+)?)$/.exec(arg);
|
|
286
|
+
if (!match?.[1] || match[2] === undefined)
|
|
287
|
+
return `"${arg}" isn't <what>=<dollars a month>, e.g. groceries=600.`;
|
|
288
|
+
const what = match[1].replace(/[-_]/g, ' ');
|
|
289
|
+
const category = findCategory(catalog, what);
|
|
290
|
+
lines.push({
|
|
291
|
+
merchant: category === null ? what : null,
|
|
292
|
+
category,
|
|
293
|
+
monthly: Number(match[2]),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (lines.length === 0)
|
|
297
|
+
return `Give your monthly spending, e.g. ${CLI_NAME} simulate groceries=600 dining=300 other=1000.`;
|
|
298
|
+
return lines;
|
|
299
|
+
}
|
|
300
|
+
export async function runSimulate(argv, io) {
|
|
301
|
+
let parsed;
|
|
302
|
+
try {
|
|
303
|
+
parsed = parseArgs({
|
|
304
|
+
args: argv,
|
|
305
|
+
allowPositionals: true,
|
|
306
|
+
options: {
|
|
307
|
+
...FILTER_FLAGS,
|
|
308
|
+
wallet: { type: 'boolean' },
|
|
309
|
+
start: { type: 'string' },
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
io.stderr(`${e instanceof Error ? e.message : String(e)}\n${SIMULATE_HELP}`);
|
|
315
|
+
return USAGE_ERROR;
|
|
316
|
+
}
|
|
317
|
+
const { values, positionals } = parsed;
|
|
318
|
+
if (values.help) {
|
|
319
|
+
io.stdout(SIMULATE_HELP);
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
const catalog = await loadCatalog();
|
|
323
|
+
const settings = settingsFrom(values, catalog);
|
|
324
|
+
if (typeof settings === 'string') {
|
|
325
|
+
io.stderr(`${settings}\n`);
|
|
326
|
+
return USAGE_ERROR;
|
|
327
|
+
}
|
|
328
|
+
const spend = parseSpend(catalog, positionals);
|
|
329
|
+
if (typeof spend === 'string') {
|
|
330
|
+
io.stderr(`${spend}\n`);
|
|
331
|
+
return USAGE_ERROR;
|
|
332
|
+
}
|
|
333
|
+
const lines = resolveSpendLines(catalog, spend);
|
|
334
|
+
const unknown = lines.filter((l) => l.merchant.source === 'none');
|
|
335
|
+
if (unknown.length > 0) {
|
|
336
|
+
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
|
+
return USAGE_ERROR;
|
|
338
|
+
}
|
|
339
|
+
const options = { ...settings, start: values.start ?? io.today };
|
|
340
|
+
if (values.wallet) {
|
|
341
|
+
const file = walletPath(io.env);
|
|
342
|
+
const wallet = await readWallet(file, io);
|
|
343
|
+
if (!wallet)
|
|
344
|
+
return RUNTIME_ERROR;
|
|
345
|
+
const problems = walletProblems(wallet, catalog);
|
|
346
|
+
if (problems.length > 0) {
|
|
347
|
+
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
348
|
+
return RUNTIME_ERROR;
|
|
349
|
+
}
|
|
350
|
+
const valued = values.valuation !== undefined && isValuationProfile(values.valuation)
|
|
351
|
+
? withValuationProfile(wallet, values.valuation)
|
|
352
|
+
: wallet;
|
|
353
|
+
const result = simulateAdditions(catalog, valued, lines, options);
|
|
354
|
+
io.stdout(values.json
|
|
355
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
356
|
+
: formatAdditions(result, catalog, values.all ?? false));
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
const result = simulateCatalog(catalog, lines, options);
|
|
360
|
+
io.stdout(values.json
|
|
361
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
362
|
+
: formatCatalogSimulation(result, catalog, values.all ?? false));
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
package/dist/cli/run.js
CHANGED
|
@@ -12,6 +12,8 @@ import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
|
12
12
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
13
13
|
import { packageVersion } from "../version.js";
|
|
14
14
|
import { parseCliArgs } from "./args.js";
|
|
15
|
+
import { runCheatSheet } from "./cheatsheet.js";
|
|
16
|
+
import { runBest, runSimulate } from "./explore.js";
|
|
15
17
|
import { runImport } from "./import.js";
|
|
16
18
|
import { runOffers } from "./offers.js";
|
|
17
19
|
import { runWalletCommand, WALLET_HELP } from "./wallet.js";
|
|
@@ -25,6 +27,10 @@ Examples:
|
|
|
25
27
|
${CLI_NAME} costco --in-store
|
|
26
28
|
${CLI_NAME} delta.com 450 --valuation points-heavy
|
|
27
29
|
|
|
30
|
+
Compare every card, no wallet needed:
|
|
31
|
+
${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
|
|
32
|
+
${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
|
|
33
|
+
|
|
28
34
|
Set up your wallet:
|
|
29
35
|
${CLI_NAME} cards [search words] Find cards and their ids.
|
|
30
36
|
${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
|
|
@@ -33,6 +39,7 @@ Set up your wallet:
|
|
|
33
39
|
${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
|
|
34
40
|
|
|
35
41
|
Other commands:
|
|
42
|
+
${CLI_NAME} cheatsheet Your best card for each category this quarter.
|
|
36
43
|
${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
|
|
37
44
|
${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
|
|
38
45
|
${CLI_NAME} mcp Run the MCP server over stdio, for AI agents.
|
|
@@ -87,6 +94,12 @@ export async function run(argv, io) {
|
|
|
87
94
|
return runImport(argv.slice(1), io);
|
|
88
95
|
if (argv[0] === 'offers')
|
|
89
96
|
return runOffers(argv.slice(1), io);
|
|
97
|
+
if (argv[0] === 'best')
|
|
98
|
+
return runBest(argv.slice(1), io);
|
|
99
|
+
if (argv[0] === 'cheatsheet')
|
|
100
|
+
return runCheatSheet(argv.slice(1), io);
|
|
101
|
+
if (argv[0] === 'simulate')
|
|
102
|
+
return runSimulate(argv.slice(1), io);
|
|
90
103
|
const command = argv[0];
|
|
91
104
|
if (command !== undefined && Object.hasOwn(WALLET_HELP, command)) {
|
|
92
105
|
return runWalletCommand(command, argv.slice(1), io);
|
|
@@ -1,107 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { knownMemberships, searchCards, setupNotes, } from "../engine/setup.js";
|
|
3
|
-
import { WalletCardSchema, WalletSchema, } from "../schema/wallet.js";
|
|
1
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
4
2
|
import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, } from "./wallet-edit.js";
|
|
5
3
|
const failed = (error) => ({ ok: false, error });
|
|
6
|
-
const list = (ids) => ids.join(', ');
|
|
7
|
-
function findCatalogCard(catalog, id, today) {
|
|
8
|
-
const card = catalog.cards.find((c) => c.id === id);
|
|
9
|
-
if (card)
|
|
10
|
-
return card;
|
|
11
|
-
const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
|
|
12
|
-
return near.length > 0
|
|
13
|
-
? `No card '${id}' in earnrate. Closest: ${list(near)}.`
|
|
14
|
-
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
15
|
-
}
|
|
16
|
-
function isAnniversary(text) {
|
|
17
|
-
const m = /^(\d{2})-(\d{2})$/.exec(text);
|
|
18
|
-
if (!m)
|
|
19
|
-
return false;
|
|
20
|
-
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
21
|
-
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
22
|
-
}
|
|
23
|
-
// The period key picks are filed under: the card's choice period, or for
|
|
24
|
-
// picks that carry over, the current year (it's never compared).
|
|
25
|
-
function choicePeriod(card, today) {
|
|
26
|
-
const periods = [
|
|
27
|
-
...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
|
|
28
|
-
];
|
|
29
|
-
if (periods.length > 1)
|
|
30
|
-
return null;
|
|
31
|
-
return periodKey(periods[0] ?? 'calendar_year', today, null);
|
|
32
|
-
}
|
|
33
|
-
function fieldsFor(card, held, input, today) {
|
|
34
|
-
const problems = [];
|
|
35
|
-
const fields = {};
|
|
36
|
-
if (input.checkoutLabel !== undefined) {
|
|
37
|
-
if (input.checkoutLabel.trim() === '')
|
|
38
|
-
problems.push('The checkout label is empty.');
|
|
39
|
-
// earnrate never stores card numbers; a last four is fine.
|
|
40
|
-
if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
|
|
41
|
-
problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
|
|
42
|
-
fields.checkout_label = input.checkoutLabel.trim();
|
|
43
|
-
}
|
|
44
|
-
if (input.anniversary !== undefined) {
|
|
45
|
-
if (!isAnniversary(input.anniversary)) {
|
|
46
|
-
problems.push(`"${input.anniversary}" isn't an anniversary. Use MM-DD, like 03-15.`);
|
|
47
|
-
}
|
|
48
|
-
fields.anniversary = input.anniversary;
|
|
49
|
-
}
|
|
50
|
-
if (input.activated !== undefined) {
|
|
51
|
-
const needing = card.rules
|
|
52
|
-
.filter((r) => r.requires?.activation)
|
|
53
|
-
.map((r) => r.id);
|
|
54
|
-
for (const id of input.activated) {
|
|
55
|
-
if (!needing.includes(id)) {
|
|
56
|
-
problems.push(needing.length > 0
|
|
57
|
-
? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
|
|
58
|
-
: `${card.id} has no rules that need activating.`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
fields.activated = [...new Set(input.activated)];
|
|
62
|
-
}
|
|
63
|
-
if (input.picks !== undefined) {
|
|
64
|
-
for (const [choiceId, picks] of Object.entries(input.picks)) {
|
|
65
|
-
const choice = card.choices.find((c) => c.id === choiceId);
|
|
66
|
-
if (!choice) {
|
|
67
|
-
problems.push(card.choices.length > 0
|
|
68
|
-
? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
|
|
69
|
-
: `${card.id} has no categories to pick.`);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const options = Object.keys(choice.options);
|
|
73
|
-
for (const p of picks) {
|
|
74
|
-
if (!options.includes(p)) {
|
|
75
|
-
problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (picks.length === 0 || picks.length > choice.pick) {
|
|
79
|
-
problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
const period = choicePeriod(card, today);
|
|
83
|
-
if (period === null) {
|
|
84
|
-
problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
|
|
85
|
-
}
|
|
86
|
-
else {
|
|
87
|
-
const kept = held?.chosen?.period === period ? held.chosen.picks : {};
|
|
88
|
-
fields.chosen = {
|
|
89
|
-
period,
|
|
90
|
-
picks: { ...kept, ...input.picks },
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return problems.length > 0 ? problems.join('\n') : fields;
|
|
95
|
-
}
|
|
96
|
-
const applyFields = (held, f) => WalletCardSchema.parse({
|
|
97
|
-
...held,
|
|
98
|
-
...(f.checkout_label === undefined
|
|
99
|
-
? {}
|
|
100
|
-
: { checkout_label: f.checkout_label }),
|
|
101
|
-
...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
|
|
102
|
-
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
103
|
-
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
104
|
-
});
|
|
105
4
|
async function attempt(task) {
|
|
106
5
|
try {
|
|
107
6
|
return await task();
|
|
@@ -111,29 +10,19 @@ async function attempt(task) {
|
|
|
111
10
|
}
|
|
112
11
|
}
|
|
113
12
|
const noWallet = (file) => `No wallet at ${file} yet. Add a card first to create it.`;
|
|
13
|
+
// The change is worked out on the parsed wallet, then spliced into the file
|
|
14
|
+
// text, which must re-parse to exactly that wallet before it's written.
|
|
15
|
+
async function commit(file, edited, change, created) {
|
|
16
|
+
await commitWalletText(file, edited, change.wallet);
|
|
17
|
+
return { ok: true, wallet: change.wallet, created, notes: change.notes };
|
|
18
|
+
}
|
|
114
19
|
export function addCard(file, catalog, input, today) {
|
|
115
20
|
return attempt(async () => {
|
|
116
|
-
const card = findCatalogCard(catalog, input.id, today);
|
|
117
|
-
if (typeof card === 'string')
|
|
118
|
-
return failed(card);
|
|
119
21
|
const { text, wallet } = await readWalletText(file);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (typeof fields === 'string')
|
|
125
|
-
return failed(fields);
|
|
126
|
-
const held = applyFields(WalletCardSchema.parse({ id: card.id }), fields);
|
|
127
|
-
const expected = wallet
|
|
128
|
-
? { ...wallet, cards: [...wallet.cards, held] }
|
|
129
|
-
: WalletSchema.parse({ cards: [held] });
|
|
130
|
-
await commitWalletText(file, addCardText(text, { id: card.id, ...fields }), expected);
|
|
131
|
-
return {
|
|
132
|
-
ok: true,
|
|
133
|
-
wallet: expected,
|
|
134
|
-
created: text === null,
|
|
135
|
-
notes: setupNotes(catalog, expected, held, today),
|
|
136
|
-
};
|
|
22
|
+
const change = addToWallet(catalog, wallet, input, today);
|
|
23
|
+
if (!change.ok)
|
|
24
|
+
return change;
|
|
25
|
+
return commit(file, addCardText(text, { id: input.id, ...change.fields }), change, text === null);
|
|
137
26
|
});
|
|
138
27
|
}
|
|
139
28
|
export function updateCard(file, catalog, input, today) {
|
|
@@ -141,28 +30,10 @@ export function updateCard(file, catalog, input, today) {
|
|
|
141
30
|
const { text, wallet } = await readWalletText(file);
|
|
142
31
|
if (text === null || wallet === null)
|
|
143
32
|
return failed(noWallet(file));
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
const fields = fieldsFor(card, held, input, today);
|
|
150
|
-
if (typeof fields === 'string')
|
|
151
|
-
return failed(fields);
|
|
152
|
-
if (Object.keys(fields).length === 0)
|
|
153
|
-
return failed('Nothing to change.');
|
|
154
|
-
const updated = applyFields(held, fields);
|
|
155
|
-
const expected = {
|
|
156
|
-
...wallet,
|
|
157
|
-
cards: wallet.cards.map((c) => (c.id === held.id ? updated : c)),
|
|
158
|
-
};
|
|
159
|
-
await commitWalletText(file, updateCardText(text, held.id, fields), expected);
|
|
160
|
-
return {
|
|
161
|
-
ok: true,
|
|
162
|
-
wallet: expected,
|
|
163
|
-
created: false,
|
|
164
|
-
notes: setupNotes(catalog, expected, updated, today),
|
|
165
|
-
};
|
|
33
|
+
const change = updateInWallet(catalog, wallet, input, today);
|
|
34
|
+
if (!change.ok)
|
|
35
|
+
return change;
|
|
36
|
+
return commit(file, updateCardText(text, input.id, change.fields), change, false);
|
|
166
37
|
});
|
|
167
38
|
}
|
|
168
39
|
export function removeCard(file, _catalog, id) {
|
|
@@ -170,33 +41,20 @@ export function removeCard(file, _catalog, id) {
|
|
|
170
41
|
const { text, wallet } = await readWalletText(file);
|
|
171
42
|
if (text === null || wallet === null)
|
|
172
43
|
return failed(noWallet(file));
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
|
|
178
|
-
}
|
|
179
|
-
const expected = {
|
|
180
|
-
...wallet,
|
|
181
|
-
cards: wallet.cards.filter((c) => c.id !== id),
|
|
182
|
-
};
|
|
183
|
-
await commitWalletText(file, removeCardText(text, id), expected);
|
|
184
|
-
return { ok: true, wallet: expected, created: false, notes: [] };
|
|
44
|
+
const change = removeFromWallet(wallet, id);
|
|
45
|
+
if (!change.ok)
|
|
46
|
+
return change;
|
|
47
|
+
return commit(file, removeCardText(text, id), change, false);
|
|
185
48
|
});
|
|
186
49
|
}
|
|
187
50
|
export function setMemberships(file, catalog, memberships) {
|
|
188
51
|
return attempt(async () => {
|
|
189
|
-
const known = knownMemberships(catalog);
|
|
190
|
-
const unknown = memberships.filter((m) => !known.includes(m));
|
|
191
|
-
if (unknown.length > 0) {
|
|
192
|
-
return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
|
|
193
|
-
}
|
|
194
52
|
const { text, wallet } = await readWalletText(file);
|
|
195
53
|
if (text === null || wallet === null)
|
|
196
54
|
return failed(noWallet(file));
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return
|
|
55
|
+
const change = withMemberships(catalog, wallet, memberships);
|
|
56
|
+
if (!change.ok)
|
|
57
|
+
return change;
|
|
58
|
+
return commit(file, setMembershipsText(text, change.wallet.memberships), change, false);
|
|
201
59
|
});
|
|
202
60
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { resolveMerchantById } from "../merchants/resolve.js";
|
|
2
|
+
import { categoryResolution } from "./explore.js";
|
|
3
|
+
import { periodKey } from "./periods.js";
|
|
4
|
+
import { recommend } from "./recommend.js";
|
|
5
|
+
import { setupNotes } from "./setup.js";
|
|
6
|
+
export const CHEAT_SHEET_ITEMS = [
|
|
7
|
+
{ label: 'Groceries', category: 'grocery.supermarket' },
|
|
8
|
+
{ label: 'Dining', category: 'dining' },
|
|
9
|
+
{ label: 'Gas', category: 'gas' },
|
|
10
|
+
{ label: 'Amazon', merchant: 'amazon' },
|
|
11
|
+
{ label: 'Costco', merchant: 'costco' },
|
|
12
|
+
{ label: 'Travel', category: 'travel' },
|
|
13
|
+
{ label: 'Streaming', category: 'streaming' },
|
|
14
|
+
{ label: 'Drugstores', category: 'drugstore' },
|
|
15
|
+
{ label: 'Everything else', category: 'other' },
|
|
16
|
+
];
|
|
17
|
+
// The best card in the wallet for each everyday kind of purchase on `date`,
|
|
18
|
+
// with caps, picks and activations as the wallet has them.
|
|
19
|
+
export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
20
|
+
const rows = items.map((item) => {
|
|
21
|
+
const merchant = 'merchant' in item
|
|
22
|
+
? resolveMerchantById(catalog, item.merchant, null)
|
|
23
|
+
: categoryResolution(catalog, item.category, null);
|
|
24
|
+
if (!merchant)
|
|
25
|
+
throw new Error(`unknown merchant '${item.label}'`);
|
|
26
|
+
const rec = recommend(catalog, wallet, {
|
|
27
|
+
merchant: merchant.input,
|
|
28
|
+
amount: null,
|
|
29
|
+
channel: null,
|
|
30
|
+
date,
|
|
31
|
+
country: 'US',
|
|
32
|
+
}, merchant);
|
|
33
|
+
const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId);
|
|
34
|
+
return {
|
|
35
|
+
label: item.label,
|
|
36
|
+
cardId: top?.cardId ?? null,
|
|
37
|
+
cardName: top ? (top.checkoutLabel ?? top.cardName) : null,
|
|
38
|
+
effectiveReturnPct: top?.effectiveReturnPct ?? null,
|
|
39
|
+
matchedRule: top?.matchedRule ?? null,
|
|
40
|
+
note: top?.warnings[0] ?? null,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
const notes = [
|
|
44
|
+
...new Set(wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text))),
|
|
45
|
+
];
|
|
46
|
+
const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
|
|
47
|
+
return { quarter: `${q ?? ''} ${year ?? ''}`, date, rows, notes };
|
|
48
|
+
}
|