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.
Files changed (53) hide show
  1. package/README.md +52 -17
  2. package/data/CHANGELOG.md +8 -0
  3. package/data/VERSION +1 -1
  4. package/data/categories.yaml +22 -7
  5. package/dist/brand.js +2 -3
  6. package/dist/cli/args.js +67 -45
  7. package/dist/cli/cheatsheet.js +36 -35
  8. package/dist/cli/explore.js +47 -163
  9. package/dist/cli/import.js +3 -8
  10. package/dist/cli/offers.js +14 -56
  11. package/dist/cli/run.js +90 -20
  12. package/dist/cli/wallet.js +47 -6
  13. package/dist/data/file-store.js +95 -0
  14. package/dist/data/load.js +10 -2
  15. package/dist/data/store.js +92 -0
  16. package/dist/data/wallet-edit.js +93 -4
  17. package/dist/engine/bonus.js +15 -12
  18. package/dist/engine/cheat-sheet.js +59 -4
  19. package/dist/engine/evaluate.js +34 -10
  20. package/dist/engine/explore.js +16 -28
  21. package/dist/engine/offers.js +8 -21
  22. package/dist/engine/periods.js +9 -0
  23. package/dist/engine/recommend.js +17 -53
  24. package/dist/engine/record.js +34 -4
  25. package/dist/engine/simulate.js +33 -19
  26. package/dist/engine/text.js +53 -0
  27. package/dist/engine/transient.js +26 -0
  28. package/dist/engine/valuation.js +1 -1
  29. package/dist/engine/wallet-change.js +36 -15
  30. package/dist/engine/wallet-summary.js +4 -8
  31. package/dist/format/card-text.js +79 -0
  32. package/dist/format/cheat-sheet.js +19 -0
  33. package/dist/format/explore.js +114 -0
  34. package/dist/format/offers.js +33 -0
  35. package/dist/{cli/format.js → format/recommendation.js} +9 -11
  36. package/dist/format/wallet-yaml.js +45 -0
  37. package/dist/format/wallet.js +38 -0
  38. package/dist/import/analyze.js +17 -19
  39. package/dist/mcp/context.js +92 -0
  40. package/dist/mcp/inputs.js +148 -0
  41. package/dist/mcp/schemas.js +8 -2
  42. package/dist/mcp/serve.js +3 -1
  43. package/dist/mcp/server.js +43 -660
  44. package/dist/mcp/tools/account.js +71 -0
  45. package/dist/mcp/tools/answers.js +329 -0
  46. package/dist/mcp/tools/wallet.js +128 -0
  47. package/dist/merchants/from-env.js +1 -1
  48. package/dist/merchants/resolve.js +3 -3
  49. package/dist/schema/catalog.js +1 -0
  50. package/package.json +8 -3
  51. package/dist/data/wallet-setup.js +0 -60
  52. package/dist/data/wallet-write.js +0 -140
  53. /package/dist/{cli → format}/cards.js +0 -0
@@ -1,9 +1,9 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { CLI_NAME } from "../brand.js";
3
+ import { FileWalletStore } from "../data/file-store.js";
3
4
  import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
4
- import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
5
5
  import { knownMemberships, searchCards } from "../engine/setup.js";
6
- import { formatCardInfo, formatNotes } from "./cards.js";
6
+ import { formatCardInfo, formatNotes } from "../format/cards.js";
7
7
  import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
8
8
  const CARD_OPTIONS = `Options:
9
9
  --label <text> How the card appears at checkout, e.g. "Amex Gold".
@@ -13,6 +13,13 @@ const CARD_OPTIONS = `Options:
13
13
  --anniversary <MM-DD> Account anniversary, for caps that reset on it.
14
14
  --activated <rules> Rules you've activated with the issuer, comma
15
15
  separated. Replaces the current list.
16
+ --bonus-opened <date> Track a welcome bonus: the day the account opened
17
+ (YYYY-MM-DD). Spending needed and the deadline
18
+ default to the card's published offer.
19
+ --bonus-spent <dollars> Spending toward it so far. Default 0.
20
+ --bonus-spend <dollars> The spending needed, if your offer differs.
21
+ --bonus-by <date> The deadline, if your offer differs.
22
+ --no-bonus Stop tracking the welcome bonus.
16
23
  -h, --help Show this help.`;
17
24
  export const WALLET_HELP = {
18
25
  cards: `Usage: ${CLI_NAME} cards [search words]
@@ -105,6 +112,11 @@ export async function runWalletCommand(command, argv, io) {
105
112
  pick: { type: 'string', multiple: true },
106
113
  anniversary: { type: 'string' },
107
114
  activated: { type: 'string' },
115
+ 'bonus-opened': { type: 'string' },
116
+ 'bonus-spent': { type: 'string' },
117
+ 'bonus-spend': { type: 'string' },
118
+ 'bonus-by': { type: 'string' },
119
+ 'no-bonus': { type: 'boolean' },
108
120
  none: { type: 'boolean' },
109
121
  help: { type: 'boolean', short: 'h' },
110
122
  },
@@ -136,6 +148,7 @@ export async function runWalletCommand(command, argv, io) {
136
148
  const catalog = await loadCatalog();
137
149
  const file = walletPath(io.env);
138
150
  const where = displayPath(file);
151
+ const store = new FileWalletStore(catalog, file);
139
152
  if (command === 'cards') {
140
153
  const query = positionals.join(' ');
141
154
  const result = searchCards(catalog, query, await heldWallet(io), io.today);
@@ -157,7 +170,7 @@ export async function runWalletCommand(command, argv, io) {
157
170
  return usage('Give memberships or --none, not both.');
158
171
  }
159
172
  const list = values.none ? [] : positionals;
160
- return report(await setMemberships(file, catalog, list), list.length > 0
173
+ return report(await store.setMemberships(list), list.length > 0
161
174
  ? `Memberships set to ${list.join(', ')} in ${where}.`
162
175
  : `Memberships cleared in ${where}.`, null, io);
163
176
  }
@@ -167,7 +180,7 @@ export async function runWalletCommand(command, argv, io) {
167
180
  const id = positionals[0] ?? '';
168
181
  const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
169
182
  if (command === 'remove') {
170
- return report(await removeCard(file, catalog, id), `Removed ${name} from ${where}.`, null, io);
183
+ return report(await store.removeCard(id), `Removed ${name} from ${where}.`, null, io);
171
184
  }
172
185
  const input = { id };
173
186
  if (values.label !== undefined)
@@ -186,9 +199,37 @@ export async function runWalletCommand(command, argv, io) {
186
199
  return usage(picks);
187
200
  input.picks = picks;
188
201
  }
202
+ const dollarsFlag = (flag, text) => {
203
+ if (text === undefined)
204
+ return undefined;
205
+ const n = Number(text.replace(/[$,]/g, ''));
206
+ return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
207
+ };
208
+ if (values['no-bonus']) {
209
+ input.bonus = null;
210
+ }
211
+ else if (values['bonus-opened'] !== undefined) {
212
+ const spent = dollarsFlag('bonus-spent', values['bonus-spent']);
213
+ const spend = dollarsFlag('bonus-spend', values['bonus-spend']);
214
+ if (typeof spent === 'string')
215
+ return usage(spent);
216
+ if (typeof spend === 'string')
217
+ return usage(spend);
218
+ input.bonus = {
219
+ opened: values['bonus-opened'],
220
+ ...(spent === undefined ? {} : { spent }),
221
+ ...(spend === undefined ? {} : { spend }),
222
+ ...(values['bonus-by'] === undefined ? {} : { by: values['bonus-by'] }),
223
+ };
224
+ }
225
+ else if (values['bonus-spent'] !== undefined ||
226
+ values['bonus-spend'] !== undefined ||
227
+ values['bonus-by'] !== undefined) {
228
+ return usage('Give --bonus-opened with the other --bonus options.');
229
+ }
189
230
  if (command === 'add') {
190
- const result = await addCard(file, catalog, input, io.today);
231
+ const result = await store.addCard(input, io.today);
191
232
  return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
192
233
  }
193
- return report(await updateCard(file, catalog, input, io.today), `Updated ${name} in ${where}.`, id, io);
234
+ return report(await store.updateCard(input, io.today), `Updated ${name} in ${where}.`, id, io);
194
235
  }
@@ -0,0 +1,95 @@
1
+ import { bookPurchase } from "../engine/record.js";
2
+ import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
3
+ import { loadWallet } from "./load.js";
4
+ import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, writeProgress, } from "./wallet-edit.js";
5
+ const failed = (error) => ({ ok: false, error });
6
+ const errorText = (e) => (e instanceof Error ? e.message : String(e));
7
+ const queues = new Map();
8
+ // Runs `task` after every earlier task for the same file has settled, so a
9
+ // read-modify-write of the wallet never interleaves with another.
10
+ function withWalletLock(file, task) {
11
+ const previous = queues.get(file) ?? Promise.resolve();
12
+ const run = previous.then(task, task);
13
+ queues.set(file, run.catch(() => undefined));
14
+ return run;
15
+ }
16
+ // The wallet as a YAML file (the CLI, the local MCP server and the .mcpb).
17
+ // Each change is worked out on the parsed wallet, then spliced into the text,
18
+ // which must re-parse to exactly that wallet before it's written, so the
19
+ // user's comments survive.
20
+ export class FileWalletStore {
21
+ where;
22
+ catalog;
23
+ constructor(catalog, file) {
24
+ this.catalog = catalog;
25
+ this.where = file;
26
+ }
27
+ async load() {
28
+ try {
29
+ return await loadWallet(this.where);
30
+ }
31
+ catch (e) {
32
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT')
33
+ return null;
34
+ throw e;
35
+ }
36
+ }
37
+ change(plan, edit) {
38
+ return withWalletLock(this.where, async () => {
39
+ try {
40
+ const { text, wallet } = await readWalletText(this.where);
41
+ const change = plan(wallet);
42
+ if (typeof change === 'string')
43
+ return failed(change);
44
+ if (!change.ok)
45
+ return change;
46
+ await commitWalletText(this.where, edit(text, change), change.wallet);
47
+ return {
48
+ ok: true,
49
+ wallet: change.wallet,
50
+ created: text === null,
51
+ notes: change.notes,
52
+ };
53
+ }
54
+ catch (e) {
55
+ return failed(errorText(e));
56
+ }
57
+ });
58
+ }
59
+ // Changes other than adding a card need a wallet file to change.
60
+ existing(apply) {
61
+ return (wallet) => wallet === null
62
+ ? `No wallet at ${this.where} yet. Add a card first to create it.`
63
+ : apply(wallet);
64
+ }
65
+ addCard(input, today) {
66
+ return this.change((wallet) => addToWallet(this.catalog, wallet, input, today), (text, change) => addCardText(text, { id: input.id, ...change.fields }));
67
+ }
68
+ updateCard(input, today) {
69
+ return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
70
+ }
71
+ removeCard(cardId) {
72
+ return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
73
+ }
74
+ setMemberships(memberships) {
75
+ return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
76
+ }
77
+ record(purchase, merchant) {
78
+ return withWalletLock(this.where, async () => {
79
+ const wallet = await this.load();
80
+ if (!wallet)
81
+ return failed("There's no saved wallet to record it in.");
82
+ if (!wallet.cards.some((c) => c.id === purchase.cardId))
83
+ return failed(`${purchase.cardId} isn't in the wallet.`);
84
+ const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
85
+ try {
86
+ if (booked.cap || booked.bonus)
87
+ await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : []);
88
+ }
89
+ catch (e) {
90
+ return failed(errorText(e));
91
+ }
92
+ return { ok: true, ...booked };
93
+ });
94
+ }
95
+ }
package/dist/data/load.js CHANGED
@@ -11,7 +11,7 @@ import { CurrenciesFileSchema } from "../schema/currency.js";
11
11
  import { MerchantsFileSchema } from "../schema/merchant.js";
12
12
  import { WalletSchema } from "../schema/wallet.js";
13
13
  export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
14
- export async function readYaml(file, schema) {
14
+ async function readYaml(file, schema) {
15
15
  const raw = parse(await readFile(file, 'utf8'));
16
16
  const result = schema.safeParse(raw);
17
17
  if (!result.success) {
@@ -39,8 +39,16 @@ export async function loadCatalog(dir = DATA_DIR) {
39
39
  ]);
40
40
  return { categories, currencies, cards, merchants: { seed, overrides } };
41
41
  }
42
+ // MCP clients pass config values through without a shell, so "~" or
43
+ // "${HOME}" can arrive as literal text; expand them here.
42
44
  export function walletPath(env = process.env) {
43
- return env[WALLET_ENV_VAR] ?? path.join(homedir(), CONFIG_DIR, 'wallet.yaml');
45
+ const given = env[WALLET_ENV_VAR]?.trim();
46
+ if (!given)
47
+ return path.join(homedir(), CONFIG_DIR, 'wallet.yaml');
48
+ const expanded = given
49
+ .replace(/^~(?=$|[/\\])/, homedir())
50
+ .replace(/\$\{HOME\}|\$HOME\b/g, homedir());
51
+ return path.resolve(expanded);
44
52
  }
45
53
  export function loadWallet(file = walletPath()) {
46
54
  return readYaml(file, WalletSchema);
@@ -0,0 +1,92 @@
1
+ import { bookPurchase } from "../engine/record.js";
2
+ import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
3
+ import { WalletSchema } from "../schema/wallet.js";
4
+ const failed = (error) => ({ ok: false, error });
5
+ // A wallet stored as JSON text. Writes are serialized within this instance;
6
+ // a backend shared across processes must serialize them itself (one Durable
7
+ // Object per wallet does).
8
+ export class JsonWalletStore {
9
+ where;
10
+ catalog;
11
+ backend;
12
+ queue = Promise.resolve();
13
+ constructor(catalog, backend, where) {
14
+ this.catalog = catalog;
15
+ this.backend = backend;
16
+ this.where = where;
17
+ }
18
+ async load() {
19
+ const text = await this.backend.read();
20
+ return text === null ? null : WalletSchema.parse(JSON.parse(text));
21
+ }
22
+ serialized(task) {
23
+ const run = this.queue.then(task, task);
24
+ this.queue = run.catch(() => undefined);
25
+ return run;
26
+ }
27
+ change(apply) {
28
+ return this.serialized(async () => {
29
+ const before = await this.load();
30
+ const change = apply(before);
31
+ if (!change.ok)
32
+ return failed(change.error);
33
+ await this.backend.write(JSON.stringify(change.wallet));
34
+ return {
35
+ ok: true,
36
+ wallet: change.wallet,
37
+ created: before === null,
38
+ notes: change.notes,
39
+ };
40
+ });
41
+ }
42
+ needsWallet(apply) {
43
+ return (wallet) => wallet === null
44
+ ? failed("There's no saved wallet yet. Add a card first to start one.")
45
+ : apply(wallet);
46
+ }
47
+ addCard(input, today) {
48
+ return this.change((w) => addToWallet(this.catalog, w, input, today));
49
+ }
50
+ updateCard(input, today) {
51
+ return this.change(this.needsWallet((w) => updateInWallet(this.catalog, w, input, today)));
52
+ }
53
+ removeCard(cardId) {
54
+ return this.change(this.needsWallet((w) => removeFromWallet(w, cardId)));
55
+ }
56
+ setMemberships(memberships) {
57
+ return this.change(this.needsWallet((w) => withMemberships(this.catalog, w, memberships)));
58
+ }
59
+ replace(wallet) {
60
+ return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
61
+ }
62
+ erase() {
63
+ return this.serialized(() => this.backend.remove());
64
+ }
65
+ record(purchase, merchant) {
66
+ return this.serialized(async () => {
67
+ const wallet = await this.load();
68
+ if (!wallet)
69
+ return failed("There's no saved wallet to record it in.");
70
+ if (!wallet.cards.some((c) => c.id === purchase.cardId))
71
+ return failed(`${purchase.cardId} isn't in the wallet.`);
72
+ const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
73
+ if (booked.cap || booked.bonus)
74
+ await this.backend.write(JSON.stringify(booked.wallet));
75
+ return { ok: true, ...booked };
76
+ });
77
+ }
78
+ }
79
+ export function memoryBackend(initial = null) {
80
+ let text = initial;
81
+ return {
82
+ read: () => Promise.resolve(text),
83
+ write: (next) => {
84
+ text = next;
85
+ return Promise.resolve();
86
+ },
87
+ remove: () => {
88
+ text = null;
89
+ return Promise.resolve();
90
+ },
91
+ };
92
+ }
@@ -2,6 +2,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { isMap, isSeq, parseDocument, stringify } from 'yaml';
5
+ import { applyBonusBooking, applyBooking, } from "../engine/record.js";
5
6
  import { WalletSchema } from "../schema/wallet.js";
6
7
  const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
7
8
  # Format: https://github.com/cwasley/earnrate#your-wallet
@@ -38,6 +39,18 @@ function fieldLines(field, f, indent) {
38
39
  return f.activated === undefined
39
40
  ? ''
40
41
  : `${indent}activated: ${flowList(f.activated)}\n`;
42
+ case 'bonus': {
43
+ if (!f.bonus)
44
+ return '';
45
+ const b = f.bonus;
46
+ return [
47
+ `${indent}bonus:\n`,
48
+ `${indent} opened: ${scalar(b.opened)}\n`,
49
+ `${indent} spent: ${String(b.spent)}\n`,
50
+ b.spend === undefined ? '' : `${indent} spend: ${String(b.spend)}\n`,
51
+ b.by === undefined ? '' : `${indent} by: ${scalar(b.by)}\n`,
52
+ ].join('');
53
+ }
41
54
  case 'chosen': {
42
55
  if (f.chosen === undefined)
43
56
  return '';
@@ -48,7 +61,13 @@ function fieldLines(field, f, indent) {
48
61
  }
49
62
  }
50
63
  }
51
- const FIELDS = ['checkout_label', 'anniversary', 'chosen', 'activated'];
64
+ const FIELDS = [
65
+ 'checkout_label',
66
+ 'anniversary',
67
+ 'chosen',
68
+ 'activated',
69
+ 'bonus',
70
+ ];
52
71
  function cardsSeq(text) {
53
72
  const doc = parseDocument(text);
54
73
  if (doc.errors.length > 0)
@@ -105,7 +124,12 @@ export function updateCardText(text, cardId, fields) {
105
124
  const indent = ' '.repeat(column(out, range(card)[0]));
106
125
  const lines = fieldLines(field, fields, indent);
107
126
  const pair = card.items.find((p) => keyOf(p) === field);
108
- if (pair) {
127
+ if (pair && lines === '') {
128
+ const start = lineStart(out, range(pair.key)[0]);
129
+ const end = afterLine(out, range(pair.value)[1] - 1);
130
+ out = out.slice(0, start) + out.slice(end);
131
+ }
132
+ else if (pair) {
109
133
  const start = range(pair.key)[0];
110
134
  const end = afterLine(out, range(pair.value)[1] - 1);
111
135
  out = out.slice(0, start) + lines.trimStart() + out.slice(end);
@@ -131,6 +155,71 @@ export function setMembershipsText(text, memberships) {
131
155
  const cards = root.items.find((p) => keyOf(p) === 'cards');
132
156
  return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
133
157
  }
158
+ function flowUsage(booking) {
159
+ const period = /^\d+$/.test(booking.period)
160
+ ? `"${booking.period}"`
161
+ : booking.period;
162
+ return `{ period: ${period}, spent: ${String(booking.after)} }`;
163
+ }
164
+ export function capUsageText(text, booking) {
165
+ const card = findCard(text, booking.cardId);
166
+ const value = flowUsage(booking);
167
+ const usage = card.get('cap_usage', true);
168
+ if (isMap(usage) && !usage.flow && usage.items.length > 0) {
169
+ const existing = usage.items.find((p) => keyOf(p) === booking.capKey);
170
+ if (existing) {
171
+ const [start, end] = range(existing.value);
172
+ return text.slice(0, start) + value + text.slice(end);
173
+ }
174
+ const indent = ' '.repeat(column(text, range(usage.items[0]?.key)[0]));
175
+ const at = afterLine(text, range(usage)[1] - 1);
176
+ return insertLines(text, at, `${indent}${booking.capKey}: ${value}\n`);
177
+ }
178
+ if (usage !== undefined)
179
+ throw new Error(`card '${booking.cardId}' has a cap_usage that isn't a block map; ${BY_HAND}`);
180
+ const indent = ' '.repeat(column(text, range(card.items[0]?.key)[0]));
181
+ const at = afterLine(text, range(card)[1] - 1);
182
+ return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
183
+ }
184
+ export function bonusSpentText(text, cardId, spent) {
185
+ const bonus = findCard(text, cardId).get('bonus', true);
186
+ if (!isMap(bonus))
187
+ throw new Error(`card '${cardId}' has no bonus`);
188
+ const value = String(spent);
189
+ const existing = bonus.items.find((p) => keyOf(p) === 'spent');
190
+ if (existing) {
191
+ const [start, end] = range(existing.value);
192
+ return text.slice(0, start) + value + text.slice(end);
193
+ }
194
+ if (bonus.flow) {
195
+ const close = text.lastIndexOf('}', range(bonus)[1]);
196
+ return `${text.slice(0, close).trimEnd()}, spent: ${value} ${text.slice(close)}`;
197
+ }
198
+ const indent = ' '.repeat(column(text, range(bonus.items[0]?.key)[0]));
199
+ const at = afterLine(text, range(bonus)[1] - 1);
200
+ return insertLines(text, at, `${indent}spent: ${value}\n`);
201
+ }
202
+ // Writes spending toward caps and welcome bonuses in one checked write.
203
+ export async function writeProgress(file, caps, bonuses) {
204
+ const { text, wallet } = await readWalletText(file);
205
+ if (text === null || wallet === null)
206
+ throw new Error(`No wallet at ${file}.`);
207
+ let after = text;
208
+ let expected = wallet;
209
+ for (const b of caps) {
210
+ after = capUsageText(after, b);
211
+ expected = applyBooking(expected, b);
212
+ }
213
+ for (const b of bonuses) {
214
+ after = bonusSpentText(after, b.cardId, b.after);
215
+ expected = applyBonusBooking(expected, b);
216
+ }
217
+ const byHand = [
218
+ ...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b)}`),
219
+ ...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
220
+ ].join('; ');
221
+ await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
222
+ }
134
223
  async function readText(file) {
135
224
  try {
136
225
  return await readFile(file, 'utf8');
@@ -150,10 +239,10 @@ export async function readWalletText(file) {
150
239
  }
151
240
  // Writes `after` only if it parses to exactly `expected`, so a splice that
152
241
  // went wrong can never change anything it wasn't meant to.
153
- export async function commitWalletText(file, after, expected) {
242
+ export async function commitWalletText(file, after, expected, fallback = BY_HAND) {
154
243
  const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
155
244
  if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
156
- throw new Error(`couldn't update ${file} safely; ${BY_HAND}`);
245
+ throw new Error(`couldn't update ${file} safely; ${fallback}`);
157
246
  }
158
247
  await mkdir(path.dirname(file), { recursive: true });
159
248
  const tmp = `${file}.${String(process.pid)}.tmp`;
@@ -1,4 +1,5 @@
1
1
  import { daysBetween } from "./periods.js";
2
+ import { roundCents } from "./text.js";
2
3
  import { cardValuation } from "./valuation.js";
3
4
  // "Within 3 months of opening": the same day of the month, three months on,
4
5
  // or the month's last day when that day doesn't exist (Nov 30 + 3 → Feb 28).
@@ -11,7 +12,17 @@ export function addMonths(date, months) {
11
12
  const day = Math.min(d, last);
12
13
  return `${String(year)}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
13
14
  }
14
- const round = (n) => Math.round(n * 100) / 100;
15
+ // A welcome bonus in dollars at the given point value; null when it has
16
+ // neither points nor dollars.
17
+ export function bonusDollars(card, centsPerUnit, points, dollars) {
18
+ if (points !== undefined)
19
+ return (points * centsPerUnit) / 100;
20
+ if (dollars === undefined)
21
+ return null;
22
+ // A cash back card's bonus is paid like its rewards, so it pools when they
23
+ // do; a points card's dollar bonus is a statement credit.
24
+ return card.currency === 'cash' ? dollars * centsPerUnit : dollars;
25
+ }
15
26
  export function bonusStatus(catalog, wallet, held, today) {
16
27
  const bonus = held.bonus;
17
28
  if (!bonus)
@@ -25,22 +36,14 @@ export function bonusStatus(catalog, wallet, held, today) {
25
36
  const valuation = cardValuation(catalog, wallet, card);
26
37
  const points = bonus.points ?? (bonus.dollars === undefined ? offer?.points : undefined);
27
38
  const dollars = bonus.dollars ?? (bonus.points === undefined ? offer?.dollars : undefined);
28
- let value = null;
29
- if (points !== undefined)
30
- value = (points * valuation.centsPerUnit) / 100;
31
- else if (dollars !== undefined) {
32
- // A cash back card's bonus is paid like its rewards, so it pools when they
33
- // do; a points card's dollar bonus is a statement credit.
34
- value =
35
- card.currency === 'cash' ? dollars * valuation.centsPerUnit : dollars;
36
- }
39
+ const value = bonusDollars(card, valuation.centsPerUnit, points, dollars);
37
40
  const base = {
38
41
  cardId: held.id,
39
42
  opened: bonus.opened,
40
43
  spent: bonus.spent,
41
44
  spend,
42
45
  deadline,
43
- value: value === null ? null : round(value),
46
+ value: value === null ? null : roundCents(value),
44
47
  };
45
48
  if (spend === null || deadline === null) {
46
49
  return {
@@ -50,7 +53,7 @@ export function bonusStatus(catalog, wallet, held, today) {
50
53
  state: 'incomplete',
51
54
  };
52
55
  }
53
- const remaining = round(Math.max(0, spend - bonus.spent));
56
+ const remaining = roundCents(Math.max(0, spend - bonus.spent));
54
57
  const daysLeft = daysBetween(today, deadline);
55
58
  return {
56
59
  ...base,
@@ -1,9 +1,9 @@
1
1
  import { resolveMerchantById } from "../merchants/resolve.js";
2
- import { categoryResolution } from "./explore.js";
2
+ import { categoryResolution, exploreCards, } from "./explore.js";
3
3
  import { periodKey } from "./periods.js";
4
4
  import { recommend } from "./recommend.js";
5
5
  import { setupNotes } from "./setup.js";
6
- export const CHEAT_SHEET_ITEMS = [
6
+ const CHEAT_SHEET_ITEMS = [
7
7
  { label: 'Groceries', category: 'grocery.supermarket' },
8
8
  { label: 'Dining', category: 'dining' },
9
9
  { label: 'Gas', category: 'gas' },
@@ -22,7 +22,7 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
22
22
  ? resolveMerchantById(catalog, item.merchant, null)
23
23
  : categoryResolution(catalog, item.category, null);
24
24
  if (!merchant)
25
- throw new Error(`unknown merchant '${item.label}'`);
25
+ throw new Error(`unknown merchant '${'merchant' in item ? item.merchant : item.category}' for ${item.label}`);
26
26
  const rec = recommend(catalog, wallet, {
27
27
  merchant: merchant.input,
28
28
  amount: null,
@@ -44,5 +44,60 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
44
44
  ...new Set(wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text))),
45
45
  ];
46
46
  const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
47
- return { quarter: `${q ?? ''} ${year ?? ''}`, date, rows, notes };
47
+ return {
48
+ scope: 'wallet',
49
+ quarter: `${q ?? ''} ${year ?? ''}`,
50
+ date,
51
+ rows,
52
+ notes,
53
+ };
54
+ }
55
+ // The best card earnrate knows for each everyday kind of purchase, for
56
+ // someone who hasn't named any cards. Each row's note says what the card
57
+ // costs or needs.
58
+ export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_ITEMS) {
59
+ const rows = items.map((item) => {
60
+ const top = exploreCards(catalog, 'merchant' in item
61
+ ? {
62
+ merchant: catalog.merchants.seed.find((m) => m.id === item.merchant)
63
+ ?.name ??
64
+ catalog.merchants.overrides.find((m) => m.id === item.merchant)
65
+ ?.name ??
66
+ item.label,
67
+ category: null,
68
+ amount: null,
69
+ channel: null,
70
+ date,
71
+ }
72
+ : {
73
+ merchant: null,
74
+ category: item.category,
75
+ amount: null,
76
+ channel: null,
77
+ date,
78
+ }, options).ranked[0];
79
+ const fee = top?.annualFee === null || top?.annualFee === undefined
80
+ ? null
81
+ : top.annualFee === 0
82
+ ? 'No annual fee.'
83
+ : `$${String(top.annualFee)} annual fee.`;
84
+ return {
85
+ label: item.label,
86
+ cardId: top?.cardId ?? null,
87
+ cardName: top?.cardName ?? null,
88
+ effectiveReturnPct: top?.effectiveReturnPct ?? null,
89
+ matchedRule: top?.matchedRule ?? null,
90
+ note: top
91
+ ? [fee, ...top.conditions].filter((n) => n !== null).join(' ')
92
+ : null,
93
+ };
94
+ });
95
+ const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
96
+ return {
97
+ scope: 'all cards',
98
+ quarter: `${q ?? ''} ${year ?? ''}`,
99
+ date,
100
+ rows,
101
+ notes: [],
102
+ };
48
103
  }
@@ -15,10 +15,19 @@ export function purchaseContext(catalog, wallet, merchant, category, purchase) {
15
15
  }
16
16
  // A category with one typical MCC (travel agencies are 4722) implies it, the
17
17
  // same assumption explanations state.
18
- function singleMcc(catalog, category) {
18
+ export function singleMcc(catalog, category) {
19
19
  const mccs = catalog.categories.find((c) => c.id === category)?.mccs ?? [];
20
20
  return mccs.length === 1 ? (mccs[0] ?? null) : null;
21
21
  }
22
+ // A store card works only at its own stores; any other card wherever the
23
+ // merchant takes its network, which counts as yes when unknown.
24
+ export function usableAt(card, merchant) {
25
+ if (card.only_at)
26
+ return (merchant.merchantId !== null && card.only_at.includes(merchant.merchantId));
27
+ return (card.network === 'store' ||
28
+ merchant.accepts === null ||
29
+ merchant.accepts.includes(card.network));
30
+ }
22
31
  export function categoryMatches(ruleCategory, category) {
23
32
  return category === ruleCategory || category.startsWith(`${ruleCategory}.`);
24
33
  }
@@ -93,20 +102,35 @@ function applicability(rule, ctx, held, picked) {
93
102
  return 'needs-activation';
94
103
  return 'applies';
95
104
  }
105
+ // A cap's key in the wallet's cap_usage: the shared cap's id, or the rule's
106
+ // id for a cap written on the rule.
107
+ export function capByKey(card, key) {
108
+ const shared = card.caps.find((c) => c.id === key);
109
+ if (shared)
110
+ return shared;
111
+ const inline = card.rules.find((r) => r.id === key)?.cap;
112
+ return typeof inline === 'object' ? inline : undefined;
113
+ }
114
+ // How much of a cap has been spent in the period `date` falls in.
115
+ export function capUsed(cap, key, held, date) {
116
+ const period = periodKey(cap.period, date, held.anniversary ?? null);
117
+ const usage = held.cap_usage[key];
118
+ return {
119
+ period,
120
+ spent: period !== null && usage?.period === period ? usage.spent : 0,
121
+ };
122
+ }
96
123
  function capState(rule, card, held, date) {
97
124
  if (rule.cap === undefined)
98
125
  return null;
99
- const capId = typeof rule.cap === 'string' ? rule.cap : null;
100
- const cap = capId === null ? rule.cap : card.caps.find((c) => c.id === capId);
101
- if (cap === undefined || typeof cap === 'string')
126
+ const key = typeof rule.cap === 'string' ? rule.cap : rule.id;
127
+ const cap = capByKey(card, key);
128
+ if (cap === undefined)
102
129
  return null;
103
- const key = capId ?? rule.id;
104
- const period = periodKey(cap.period, date, held.anniversary ?? null);
105
- const usage = held.cap_usage[key];
106
- const used = period !== null && usage?.period === period ? usage.spent : 0;
107
- return { key, cap, period, remaining: Math.max(0, cap.amount - used) };
130
+ const { period, spent } = capUsed(cap, key, held, date);
131
+ return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
108
132
  }
109
- export function rateAfterCap(rate, cap, amount) {
133
+ function rateAfterCap(rate, cap, amount) {
110
134
  if (!cap)
111
135
  return rate;
112
136
  if (amount !== null && amount > 0) {