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.
@@ -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 { periodKey } from "../engine/periods.js";
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
- if (wallet?.cards.some((c) => c.id === card.id)) {
121
- return failed(`${card.id} is already in the wallet. Update it instead.`);
122
- }
123
- const fields = fieldsFor(card, null, input, today);
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 held = wallet.cards.find((c) => c.id === input.id);
145
- const card = catalog.cards.find((c) => c.id === input.id);
146
- if (!held || !card) {
147
- return failed(`${input.id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
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
- if (!wallet.cards.some((c) => c.id === id)) {
174
- return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
175
- }
176
- if (wallet.cards.length === 1) {
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 unique = [...new Set(memberships)];
198
- const expected = { ...wallet, memberships: unique };
199
- await commitWalletText(file, setMembershipsText(text, unique), expected);
200
- return { ok: true, wallet: expected, created: false, notes: [] };
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
+ }