earnrate 0.1.1 → 0.1.3

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.
@@ -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,8 +12,10 @@ 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 { runBest, runSimulate } from "./explore.js";
15
16
  import { runImport } from "./import.js";
16
17
  import { runOffers } from "./offers.js";
18
+ import { runWalletCommand, WALLET_HELP } from "./wallet.js";
17
19
  import { formatRecommendation, plain } from "./format.js";
18
20
  export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
19
21
 
@@ -24,11 +26,22 @@ Examples:
24
26
  ${CLI_NAME} costco --in-store
25
27
  ${CLI_NAME} delta.com 450 --valuation points-heavy
26
28
 
29
+ Compare every card, no wallet needed:
30
+ ${CLI_NAME} best <merchant or category> Rank all cards for one purchase.
31
+ ${CLI_NAME} simulate groceries=600 dining=300 Best cards and pairs for a year of spending.
32
+
33
+ Set up your wallet:
34
+ ${CLI_NAME} cards [search words] Find cards and their ids.
35
+ ${CLI_NAME} add <card-id> [--pick ...] Add a card you hold.
36
+ ${CLI_NAME} update <card-id> [--pick ...] Change picks, label or activations.
37
+ ${CLI_NAME} remove <card-id> Remove a card.
38
+ ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
39
+
27
40
  Other commands:
28
41
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
29
42
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
30
43
  ${CLI_NAME} mcp Run the MCP server over stdio, for AI agents.
31
- Add --help to import or offers for its options.
44
+ Add --help to any command for its options.
32
45
 
33
46
  Options:
34
47
  --online, --in-store How you're paying. Default: inferred from the merchant.
@@ -66,7 +79,7 @@ export async function readWallet(file, io) {
66
79
  }
67
80
  catch (e) {
68
81
  if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
69
- io.stderr(`No wallet at ${displayPath(file)}. Copy the example there and edit it:\n mkdir -p ${displayPath(path.dirname(file))} && cp ${displayPath(EXAMPLE_WALLET)} ${displayPath(file)}\nOr set ${WALLET_ENV_VAR} to your wallet's path.\n`);
82
+ io.stderr(`No wallet at ${displayPath(file)} yet. Add the cards you hold:\n ${CLI_NAME} cards sapphire find a card's id\n ${CLI_NAME} add chase-sapphire-preferred\nOr start from the example: mkdir -p ${displayPath(path.dirname(file))} && cp ${displayPath(EXAMPLE_WALLET)} ${displayPath(file)}\nOr set ${WALLET_ENV_VAR} to your wallet's path.\n`);
70
83
  }
71
84
  else {
72
85
  io.stderr(`Couldn't read your wallet:\n${e instanceof Error ? e.message : String(e)}\n`);
@@ -79,6 +92,14 @@ export async function run(argv, io) {
79
92
  return runImport(argv.slice(1), io);
80
93
  if (argv[0] === 'offers')
81
94
  return runOffers(argv.slice(1), io);
95
+ if (argv[0] === 'best')
96
+ return runBest(argv.slice(1), io);
97
+ if (argv[0] === 'simulate')
98
+ return runSimulate(argv.slice(1), io);
99
+ const command = argv[0];
100
+ if (command !== undefined && Object.hasOwn(WALLET_HELP, command)) {
101
+ return runWalletCommand(command, argv.slice(1), io);
102
+ }
82
103
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
83
104
  io.stdout(`${CLI_NAME} ${await packageVersion()}\n`);
84
105
  return 0;
@@ -0,0 +1,194 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CLI_NAME } from "../brand.js";
3
+ import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
4
+ import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
5
+ import { knownMemberships, searchCards } from "../engine/setup.js";
6
+ import { formatCardInfo, formatNotes } from "./cards.js";
7
+ import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
8
+ const CARD_OPTIONS = `Options:
9
+ --label <text> How the card appears at checkout, e.g. "Amex Gold".
10
+ --pick <choice>=<options> Bonus categories you picked for this period, e.g.
11
+ --pick five-percent=fast-food,movie-theaters.
12
+ Repeat for each choice.
13
+ --anniversary <MM-DD> Account anniversary, for caps that reset on it.
14
+ --activated <rules> Rules you've activated with the issuer, comma
15
+ separated. Replaces the current list.
16
+ -h, --help Show this help.`;
17
+ export const WALLET_HELP = {
18
+ cards: `Usage: ${CLI_NAME} cards [search words]
19
+
20
+ Finds cards in earnrate's catalog by name or issuer, with the id to add them
21
+ by and anything they need set up. With no words, lists every card.
22
+
23
+ Examples:
24
+ ${CLI_NAME} cards sapphire
25
+ ${CLI_NAME} cards bofa
26
+ `,
27
+ add: `Usage: ${CLI_NAME} add <card-id> [options]
28
+
29
+ Adds a card you hold to your wallet, creating the wallet if needed. Find the
30
+ id with \`${CLI_NAME} cards\`.
31
+
32
+ ${CARD_OPTIONS}
33
+ `,
34
+ update: `Usage: ${CLI_NAME} update <card-id> [options]
35
+
36
+ Changes a card in your wallet: picks for this period, label, anniversary or
37
+ activations. Picks for the current period are merged with ones already set.
38
+
39
+ ${CARD_OPTIONS}
40
+ `,
41
+ remove: `Usage: ${CLI_NAME} remove <card-id>
42
+
43
+ Removes a card from your wallet, with its tracked cap usage.
44
+ `,
45
+ memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
46
+
47
+ With no arguments, shows your memberships and the ones earnrate knows. With
48
+ names, replaces the list. --none clears it.
49
+ `,
50
+ };
51
+ const HINTS = {
52
+ pick: (id) => `${CLI_NAME} update ${id} --pick <choice>=<option>,...`,
53
+ membership: () => `${CLI_NAME} memberships <membership>...`,
54
+ activation: (id) => `${CLI_NAME} update ${id} --activated <rule>,...`,
55
+ anniversary: (id) => `${CLI_NAME} update ${id} --anniversary MM-DD`,
56
+ };
57
+ function parsePicks(values) {
58
+ const picks = {};
59
+ for (const v of values) {
60
+ const [choice, options] = v.split('=', 2);
61
+ const ids = (options ?? '')
62
+ .split(',')
63
+ .map((o) => o.trim())
64
+ .filter((o) => o !== '');
65
+ if (!choice || ids.length === 0) {
66
+ return `"--pick ${v}" should look like --pick five-percent=fast-food,movie-theaters.`;
67
+ }
68
+ picks[choice] = ids;
69
+ }
70
+ return picks;
71
+ }
72
+ async function heldWallet(io) {
73
+ try {
74
+ return await loadWallet(walletPath(io.env));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function report(result, message, cardId, io) {
81
+ if (!result.ok) {
82
+ io.stderr(`${result.error}\n`);
83
+ return RUNTIME_ERROR;
84
+ }
85
+ const lines = [message];
86
+ if (cardId !== null) {
87
+ lines.push(formatNotes(result.notes));
88
+ const hints = [...new Set(result.notes.map((n) => n.kind))]
89
+ .map((kind) => HINTS[kind]?.(cardId))
90
+ .filter((h) => h !== undefined);
91
+ if (hints.length > 0)
92
+ lines.push(`Set them with:\n${hints.map((h) => ` ${h}`).join('\n')}`);
93
+ }
94
+ io.stdout(`${lines.join('\n')}\n`);
95
+ return 0;
96
+ }
97
+ export async function runWalletCommand(command, argv, io) {
98
+ let parsed;
99
+ try {
100
+ parsed = parseArgs({
101
+ args: argv,
102
+ allowPositionals: true,
103
+ options: {
104
+ label: { type: 'string' },
105
+ pick: { type: 'string', multiple: true },
106
+ anniversary: { type: 'string' },
107
+ activated: { type: 'string' },
108
+ none: { type: 'boolean' },
109
+ help: { type: 'boolean', short: 'h' },
110
+ },
111
+ });
112
+ }
113
+ catch (e) {
114
+ io.stderr(`${e instanceof Error ? e.message : String(e)}\nRun ${CLI_NAME} ${command} --help for usage.\n`);
115
+ return USAGE_ERROR;
116
+ }
117
+ const { values, positionals } = parsed;
118
+ if (values.help) {
119
+ io.stdout(WALLET_HELP[command]);
120
+ return 0;
121
+ }
122
+ const usage = (message) => {
123
+ io.stderr(`${message}\nRun ${CLI_NAME} ${command} --help for usage.\n`);
124
+ return USAGE_ERROR;
125
+ };
126
+ const cardOptionsGiven = values.label !== undefined ||
127
+ values.pick !== undefined ||
128
+ values.anniversary !== undefined ||
129
+ values.activated !== undefined;
130
+ if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
131
+ return usage(`${CLI_NAME} ${command} doesn't take card options.`);
132
+ }
133
+ if (command !== 'memberships' && values.none) {
134
+ return usage('--none is only for memberships.');
135
+ }
136
+ const catalog = await loadCatalog();
137
+ const file = walletPath(io.env);
138
+ const where = displayPath(file);
139
+ if (command === 'cards') {
140
+ const query = positionals.join(' ');
141
+ const result = searchCards(catalog, query, await heldWallet(io), io.today);
142
+ if (result.cards.length === 0) {
143
+ io.stderr(`No card matches "${query}". Run ${CLI_NAME} cards with no words to list all ${String(catalog.cards.length)}.\n`);
144
+ return RUNTIME_ERROR;
145
+ }
146
+ const header = result.exact ? '' : 'No card matches every word. Closest:\n';
147
+ io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
148
+ return 0;
149
+ }
150
+ if (command === 'memberships') {
151
+ if (positionals.length === 0 && !values.none) {
152
+ const held = await heldWallet(io);
153
+ io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
154
+ return 0;
155
+ }
156
+ if (positionals.length > 0 && values.none) {
157
+ return usage('Give memberships or --none, not both.');
158
+ }
159
+ const list = values.none ? [] : positionals;
160
+ return report(await setMemberships(file, catalog, list), list.length > 0
161
+ ? `Memberships set to ${list.join(', ')} in ${where}.`
162
+ : `Memberships cleared in ${where}.`, null, io);
163
+ }
164
+ if (positionals.length !== 1) {
165
+ return usage('Give one card id.');
166
+ }
167
+ const id = positionals[0] ?? '';
168
+ const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
169
+ if (command === 'remove') {
170
+ return report(await removeCard(file, catalog, id), `Removed ${name} from ${where}.`, null, io);
171
+ }
172
+ const input = { id };
173
+ if (values.label !== undefined)
174
+ input.checkoutLabel = values.label;
175
+ if (values.anniversary !== undefined)
176
+ input.anniversary = values.anniversary;
177
+ if (values.activated !== undefined) {
178
+ input.activated = values.activated
179
+ .split(',')
180
+ .map((r) => r.trim())
181
+ .filter((r) => r !== '');
182
+ }
183
+ if (values.pick !== undefined) {
184
+ const picks = parsePicks(values.pick);
185
+ if (typeof picks === 'string')
186
+ return usage(picks);
187
+ input.picks = picks;
188
+ }
189
+ if (command === 'add') {
190
+ const result = await addCard(file, catalog, input, io.today);
191
+ return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
192
+ }
193
+ return report(await updateCard(file, catalog, input, io.today), `Updated ${name} in ${where}.`, id, io);
194
+ }
@@ -136,6 +136,20 @@ function cardProblems(card, catalog, merchantIds) {
136
136
  problems.push(`${where} ${context}: unknown cap '${rule.cap}'`);
137
137
  }
138
138
  }
139
+ for (const cap of card.caps.filter((c) => c.rotating)) {
140
+ const rules = card.rules.filter((r) => r.cap === cap.id);
141
+ if (rules.length === 0) {
142
+ problems.push(`${where} cap ${cap.id}: rotating, but no rule uses it`);
143
+ }
144
+ for (const r of rules) {
145
+ if (r.valid_from === undefined || r.valid_to === undefined) {
146
+ problems.push(`${where} rule ${r.id}: rules on rotating cap '${cap.id}' need valid_from and valid_to`);
147
+ }
148
+ }
149
+ if (cap.period === 'cardmember_year') {
150
+ problems.push(`${where} cap ${cap.id}: a rotating cap can't use cardmember years`);
151
+ }
152
+ }
139
153
  return problems;
140
154
  }
141
155
  export function catalogProblems(catalog) {