earnrate 0.2.0 → 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 (47) hide show
  1. package/README.md +22 -20
  2. package/dist/brand.js +2 -3
  3. package/dist/cli/args.js +64 -51
  4. package/dist/cli/cheatsheet.js +9 -12
  5. package/dist/cli/explore.js +28 -27
  6. package/dist/cli/import.js +3 -8
  7. package/dist/cli/offers.js +7 -6
  8. package/dist/cli/run.js +17 -13
  9. package/dist/cli/wallet.js +7 -6
  10. package/dist/data/file-store.js +54 -15
  11. package/dist/data/load.js +1 -1
  12. package/dist/data/store.js +0 -16
  13. package/dist/data/wallet-edit.js +68 -2
  14. package/dist/engine/bonus.js +15 -12
  15. package/dist/engine/cheat-sheet.js +2 -2
  16. package/dist/engine/evaluate.js +34 -10
  17. package/dist/engine/explore.js +9 -22
  18. package/dist/engine/offers.js +8 -21
  19. package/dist/engine/periods.js +9 -0
  20. package/dist/engine/recommend.js +14 -53
  21. package/dist/engine/record.js +2 -7
  22. package/dist/engine/simulate.js +12 -19
  23. package/dist/engine/text.js +53 -0
  24. package/dist/engine/transient.js +5 -0
  25. package/dist/engine/valuation.js +1 -1
  26. package/dist/engine/wallet-change.js +9 -15
  27. package/dist/engine/wallet-summary.js +4 -8
  28. package/dist/format/card-text.js +79 -0
  29. package/dist/format/cheat-sheet.js +1 -1
  30. package/dist/format/explore.js +3 -13
  31. package/dist/format/offers.js +5 -14
  32. package/dist/{cli/format.js → format/recommendation.js} +2 -6
  33. package/dist/format/wallet.js +38 -0
  34. package/dist/import/analyze.js +17 -19
  35. package/dist/mcp/context.js +92 -0
  36. package/dist/mcp/inputs.js +148 -0
  37. package/dist/mcp/schemas.js +1 -1
  38. package/dist/mcp/server.js +14 -818
  39. package/dist/mcp/tools/account.js +71 -0
  40. package/dist/mcp/tools/answers.js +329 -0
  41. package/dist/mcp/tools/wallet.js +128 -0
  42. package/dist/merchants/from-env.js +1 -1
  43. package/dist/merchants/resolve.js +3 -3
  44. package/package.json +2 -2
  45. package/dist/data/wallet-setup.js +0 -60
  46. package/dist/data/wallet-write.js +0 -140
  47. /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".
@@ -148,6 +148,7 @@ export async function runWalletCommand(command, argv, io) {
148
148
  const catalog = await loadCatalog();
149
149
  const file = walletPath(io.env);
150
150
  const where = displayPath(file);
151
+ const store = new FileWalletStore(catalog, file);
151
152
  if (command === 'cards') {
152
153
  const query = positionals.join(' ');
153
154
  const result = searchCards(catalog, query, await heldWallet(io), io.today);
@@ -169,7 +170,7 @@ export async function runWalletCommand(command, argv, io) {
169
170
  return usage('Give memberships or --none, not both.');
170
171
  }
171
172
  const list = values.none ? [] : positionals;
172
- return report(await setMemberships(file, catalog, list), list.length > 0
173
+ return report(await store.setMemberships(list), list.length > 0
173
174
  ? `Memberships set to ${list.join(', ')} in ${where}.`
174
175
  : `Memberships cleared in ${where}.`, null, io);
175
176
  }
@@ -179,7 +180,7 @@ export async function runWalletCommand(command, argv, io) {
179
180
  const id = positionals[0] ?? '';
180
181
  const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
181
182
  if (command === 'remove') {
182
- 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);
183
184
  }
184
185
  const input = { id };
185
186
  if (values.label !== undefined)
@@ -227,8 +228,8 @@ export async function runWalletCommand(command, argv, io) {
227
228
  return usage('Give --bonus-opened with the other --bonus options.');
228
229
  }
229
230
  if (command === 'add') {
230
- const result = await addCard(file, catalog, input, io.today);
231
+ const result = await store.addCard(input, io.today);
231
232
  return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
232
233
  }
233
- 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);
234
235
  }
@@ -1,9 +1,22 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
+ import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
2
3
  import { loadWallet } from "./load.js";
3
- import { addCard, removeCard, setMemberships, updateCard, } from "./wallet-setup.js";
4
- import { withWalletLock, writeBonusSpent, writeCapBooking, } from "./wallet-write.js";
5
- // The wallet as a YAML file (the CLI, the local MCP server, the .mcpb and the
6
- // local plugin). Edits splice lines so the user's comments survive.
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.
7
20
  export class FileWalletStore {
8
21
  where;
9
22
  catalog;
@@ -21,34 +34,60 @@ export class FileWalletStore {
21
34
  throw e;
22
35
  }
23
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
+ }
24
65
  addCard(input, today) {
25
- return withWalletLock(this.where, () => addCard(this.where, this.catalog, input, today));
66
+ return this.change((wallet) => addToWallet(this.catalog, wallet, input, today), (text, change) => addCardText(text, { id: input.id, ...change.fields }));
26
67
  }
27
68
  updateCard(input, today) {
28
- return withWalletLock(this.where, () => updateCard(this.where, this.catalog, input, today));
69
+ return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
29
70
  }
30
71
  removeCard(cardId) {
31
- return withWalletLock(this.where, () => removeCard(this.where, this.catalog, cardId));
72
+ return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
32
73
  }
33
74
  setMemberships(memberships) {
34
- return withWalletLock(this.where, () => setMemberships(this.where, this.catalog, memberships));
75
+ return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
35
76
  }
36
77
  record(purchase, merchant) {
37
78
  return withWalletLock(this.where, async () => {
38
79
  const wallet = await this.load();
39
80
  if (!wallet)
40
- return { ok: false, error: "There's no saved wallet to record it in." };
81
+ return failed("There's no saved wallet to record it in.");
41
82
  if (!wallet.cards.some((c) => c.id === purchase.cardId))
42
- return { ok: false, error: `${purchase.cardId} isn't in the wallet.` };
83
+ return failed(`${purchase.cardId} isn't in the wallet.`);
43
84
  const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
44
85
  try {
45
- if (booked.cap)
46
- await writeCapBooking(this.where, booked.cap);
47
- if (booked.bonus)
48
- await writeBonusSpent(this.where, purchase.cardId, booked.bonus.after);
86
+ if (booked.cap || booked.bonus)
87
+ await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : []);
49
88
  }
50
89
  catch (e) {
51
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
90
+ return failed(errorText(e));
52
91
  }
53
92
  return { ok: true, ...booked };
54
93
  });
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) {
@@ -76,8 +76,6 @@ export class JsonWalletStore {
76
76
  });
77
77
  }
78
78
  }
79
- // A store with nothing behind it, for tests and for a session that starts
80
- // empty and is thrown away.
81
79
  export function memoryBackend(initial = null) {
82
80
  let text = initial;
83
81
  return {
@@ -92,17 +90,3 @@ export function memoryBackend(initial = null) {
92
90
  },
93
91
  };
94
92
  }
95
- // No saved wallet, and saving refused: the public remote server before
96
- // anonymous saved wallets exist. Cards named in the conversation still work.
97
- export function noSavedWallet(where) {
98
- const refused = () => Promise.resolve(failed("Saving a wallet isn't available here yet. Name your cards in the conversation instead; nothing is stored."));
99
- return {
100
- where,
101
- load: () => Promise.resolve(null),
102
- addCard: refused,
103
- updateCard: refused,
104
- removeCard: refused,
105
- setMemberships: refused,
106
- record: refused,
107
- };
108
- }
@@ -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
@@ -154,6 +155,71 @@ export function setMembershipsText(text, memberships) {
154
155
  const cards = root.items.find((p) => keyOf(p) === 'cards');
155
156
  return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
156
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
+ }
157
223
  async function readText(file) {
158
224
  try {
159
225
  return await readFile(file, 'utf8');
@@ -173,10 +239,10 @@ export async function readWalletText(file) {
173
239
  }
174
240
  // Writes `after` only if it parses to exactly `expected`, so a splice that
175
241
  // went wrong can never change anything it wasn't meant to.
176
- export async function commitWalletText(file, after, expected) {
242
+ export async function commitWalletText(file, after, expected, fallback = BY_HAND) {
177
243
  const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
178
244
  if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
179
- throw new Error(`couldn't update ${file} safely; ${BY_HAND}`);
245
+ throw new Error(`couldn't update ${file} safely; ${fallback}`);
180
246
  }
181
247
  await mkdir(path.dirname(file), { recursive: true });
182
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,
@@ -3,7 +3,7 @@ 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,
@@ -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) {
@@ -1,9 +1,10 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext } from "./evaluate.js";
3
- import { periodKey } from "./periods.js";
4
- import { categoryPhrase, currencyLabel, dollars, earnText, num, recommend, } from "./recommend.js";
3
+ import { choicePeriod, periodKey } from "./periods.js";
4
+ import { recommend } from "./recommend.js";
5
5
  import { rotationGap } from "./rotation.js";
6
6
  import { membershipsUsed } from "./setup.js";
7
+ import { categoryPhrase, codesAs, earnText, money, pointValuesText, } from "./text.js";
7
8
  import { cardValuation } from "./valuation.js";
8
9
  const EPSILON = 1e-9;
9
10
  export const ALL_PERSONAL_CARDS = {
@@ -29,18 +30,11 @@ export function matchesFilters(card, filters) {
29
30
  return false;
30
31
  return true;
31
32
  }
32
- // The period a card's picks are keyed by in a wallet.
33
- export function choicePeriod(card) {
34
- for (const c of card.choices)
35
- if (c.period !== 'until_changed')
36
- return c.period;
37
- return 'quarter';
38
- }
39
33
  // A card as its holder would set it up for the best result: the given picks,
40
34
  // every activation done, and no cap used yet. The anniversary is `date`, so a
41
35
  // cardmember-year cap starts fresh.
42
36
  export function idealHolding(card, date, picks = {}) {
43
- const period = choicePeriod(card);
37
+ const period = choicePeriod(card) ?? 'calendar_year';
44
38
  return {
45
39
  id: card.id,
46
40
  anniversary: date.slice(5),
@@ -163,7 +157,7 @@ function conditionsText(catalog, r) {
163
157
  if (r.membership !== null)
164
158
  conditions.push(`Needs ${r.membership}.`);
165
159
  if (r.cap) {
166
- conditions.push(`Up to ${dollars(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
160
+ conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
167
161
  }
168
162
  return conditions;
169
163
  }
@@ -262,22 +256,15 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
262
256
  assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
263
257
  }
264
258
  else if (merchant.source !== 'none') {
265
- const code = merchant.likelyMcc === null ? '' : ` (MCC ${String(merchant.likelyMcc)})`;
266
- assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
259
+ assumptions.push(codesAs(catalog, merchant));
267
260
  }
268
261
  assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
269
262
  assumptions.push(options.memberships.length === 0
270
263
  ? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
271
264
  : `Assumes these memberships: ${options.memberships.join(', ')}.`);
272
- const values = new Map();
273
- for (const e of ranked.slice(0, 10)) {
274
- if (e.currency !== 'cash')
275
- values.set(e.currency, e.centsPerUnit);
276
- }
277
- if (values.size > 0) {
278
- const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
279
- assumptions.push(`Values points at: ${list.join(', ')}.`);
280
- }
265
+ const values = pointValuesText(catalog, ranked.slice(0, 10));
266
+ if (values !== null)
267
+ assumptions.push(values);
281
268
  return assumptions;
282
269
  }
283
270
  function exploreWarnings(catalog, merchant) {
@@ -1,14 +1,7 @@
1
+ import { bonusDollars } from "./bonus.js";
2
+ import { matchesFilters } from "./explore.js";
3
+ import { roundCents } from "./text.js";
1
4
  import { cardValuation } from "./valuation.js";
2
- const round = (n) => Math.round(n * 100) / 100;
3
- function bonusValue(card, centsPerUnit, points, dollars) {
4
- if (points !== undefined)
5
- return (points * centsPerUnit) / 100;
6
- if (dollars === undefined)
7
- return 0;
8
- // A cash back card's bonus is paid like its rewards, so it pools when they
9
- // do; a points card's dollar bonus is a statement credit.
10
- return card.currency === 'cash' ? dollars * centsPerUnit : dollars;
11
- }
12
5
  export function compareOffers(catalog, wallet, filters, today) {
13
6
  const held = new Set(wallet.cards.map((c) => c.id));
14
7
  const offers = [];
@@ -16,16 +9,9 @@ export function compareOffers(catalog, wallet, filters, today) {
16
9
  for (const card of catalog.cards) {
17
10
  if (card.network === 'store')
18
11
  continue;
19
- if (card.business !== filters.business)
20
- continue;
21
12
  if (!filters.includeHeld && held.has(card.id))
22
13
  continue;
23
- const isCash = card.currency === 'cash';
24
- if (filters.kind === 'cash' && !isCash)
25
- continue;
26
- if (filters.kind === 'points' && isCash)
27
- continue;
28
- if (filters.noAnnualFee && card.annual_fee !== 0)
14
+ if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
29
15
  continue;
30
16
  const offer = card.welcome_offer;
31
17
  if (!offer || (offer.valid_to !== undefined && offer.valid_to < today)) {
@@ -33,7 +19,8 @@ export function compareOffers(catalog, wallet, filters, today) {
33
19
  continue;
34
20
  }
35
21
  const valuation = cardValuation(catalog, wallet, card);
36
- const value = bonusValue(card, valuation.centsPerUnit, offer.points, offer.dollars);
22
+ const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
23
+ 0;
37
24
  const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
38
25
  offers.push({
39
26
  cardId: card.id,
@@ -50,8 +37,8 @@ export function compareOffers(catalog, wallet, filters, today) {
50
37
  note: offer.note ?? null,
51
38
  annualFee: card.annual_fee ?? null,
52
39
  firstYearFee,
53
- bonusValue: round(value),
54
- firstYearValue: round(value - (firstYearFee ?? 0)),
40
+ bonusValue: roundCents(value),
41
+ firstYearValue: roundCents(value - (firstYearFee ?? 0)),
55
42
  held: held.has(card.id),
56
43
  lastVerified: card.last_verified,
57
44
  verifiedBy: card.verified_by,
@@ -29,3 +29,12 @@ export function localToday(now = new Date()) {
29
29
  String(now.getDate()).padStart(2, '0'),
30
30
  ].join('-');
31
31
  }
32
+ // The period a card's picks are filed under in a wallet. Picks that carry
33
+ // over (until_changed) are never compared, so they file under the calendar
34
+ // year. null when the card's picks reset on different schedules.
35
+ export function choicePeriod(card) {
36
+ const periods = [
37
+ ...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
38
+ ];
39
+ return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
40
+ }