earnrate 0.2.0 → 0.2.2

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 (64) hide show
  1. package/README.md +23 -20
  2. package/data/cards/amex-blue-business-plus.yaml +1 -0
  3. package/data/cards/amex-blue-cash-everyday.yaml +4 -3
  4. package/data/cards/amex-blue-cash-preferred.yaml +5 -7
  5. package/data/cards/amex-gold.yaml +1 -0
  6. package/data/cards/amex-platinum.yaml +1 -0
  7. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +6 -6
  8. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +6 -6
  9. package/data/cards/chase-freedom-flex.yaml +5 -1
  10. package/data/cards/chase-southwest-plus.yaml +5 -5
  11. package/data/cards/chase-southwest-premier-business.yaml +8 -5
  12. package/data/cards/chase-southwest-premier.yaml +6 -6
  13. package/data/cards/chase-southwest-priority.yaml +6 -6
  14. package/data/cards/citi-aadvantage-business.yaml +6 -6
  15. package/data/cards/citi-aadvantage-platinum-select.yaml +10 -7
  16. package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -4
  17. package/dist/brand.js +2 -3
  18. package/dist/cli/args.js +64 -51
  19. package/dist/cli/cheatsheet.js +9 -12
  20. package/dist/cli/explore.js +28 -27
  21. package/dist/cli/import.js +3 -8
  22. package/dist/cli/offers.js +7 -6
  23. package/dist/cli/run.js +17 -13
  24. package/dist/cli/wallet.js +7 -6
  25. package/dist/data/file-store.js +54 -15
  26. package/dist/data/load.js +1 -1
  27. package/dist/data/store.js +0 -16
  28. package/dist/data/wallet-edit.js +78 -3
  29. package/dist/engine/bonus.js +15 -12
  30. package/dist/engine/cheat-sheet.js +2 -2
  31. package/dist/engine/evaluate.js +56 -13
  32. package/dist/engine/explore.js +9 -22
  33. package/dist/engine/offers.js +10 -22
  34. package/dist/engine/periods.js +9 -0
  35. package/dist/engine/recommend.js +14 -53
  36. package/dist/engine/record.js +2 -7
  37. package/dist/engine/setup.js +2 -2
  38. package/dist/engine/simulate.js +12 -19
  39. package/dist/engine/text.js +53 -0
  40. package/dist/engine/transient.js +5 -0
  41. package/dist/engine/valuation.js +1 -1
  42. package/dist/engine/wallet-change.js +46 -19
  43. package/dist/engine/wallet-summary.js +4 -8
  44. package/dist/format/card-text.js +79 -0
  45. package/dist/format/cheat-sheet.js +1 -1
  46. package/dist/format/explore.js +3 -13
  47. package/dist/format/offers.js +15 -18
  48. package/dist/{cli/format.js → format/recommendation.js} +2 -6
  49. package/dist/format/wallet.js +57 -0
  50. package/dist/import/analyze.js +17 -19
  51. package/dist/mcp/context.js +92 -0
  52. package/dist/mcp/inputs.js +155 -0
  53. package/dist/mcp/schemas.js +2 -1
  54. package/dist/mcp/server.js +15 -819
  55. package/dist/mcp/tools/account.js +78 -0
  56. package/dist/mcp/tools/answers.js +338 -0
  57. package/dist/mcp/tools/wallet.js +128 -0
  58. package/dist/merchants/from-env.js +1 -1
  59. package/dist/merchants/resolve.js +3 -3
  60. package/dist/schema/card.js +4 -0
  61. package/package.json +3 -2
  62. package/dist/data/wallet-setup.js +0 -60
  63. package/dist/data/wallet-write.js +0 -140
  64. /package/dist/{cli → format}/cards.js +0 -0
@@ -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
@@ -32,12 +33,20 @@ function fieldLines(field, f, indent) {
32
33
  case 'checkout_label':
33
34
  case 'anniversary': {
34
35
  const value = f[field];
35
- return value === undefined ? '' : `${indent}${field}: ${scalar(value)}\n`;
36
+ return value == null ? '' : `${indent}${field}: ${scalar(value)}\n`;
36
37
  }
37
38
  case 'activated':
38
39
  return f.activated === undefined
39
40
  ? ''
40
41
  : `${indent}activated: ${flowList(f.activated)}\n`;
42
+ case 'cap_usage': {
43
+ if (f.cap_usage === undefined)
44
+ return '';
45
+ const caps = Object.entries(f.cap_usage)
46
+ .map(([key, u]) => `${indent} ${key}: { period: ${scalar(u.period)}, spent: ${String(u.spent)} }\n`)
47
+ .join('');
48
+ return caps === '' ? '' : `${indent}cap_usage:\n${caps}`;
49
+ }
41
50
  case 'bonus': {
42
51
  if (!f.bonus)
43
52
  return '';
@@ -65,6 +74,7 @@ const FIELDS = [
65
74
  'anniversary',
66
75
  'chosen',
67
76
  'activated',
77
+ 'cap_usage',
68
78
  'bonus',
69
79
  ];
70
80
  function cardsSeq(text) {
@@ -154,6 +164,71 @@ export function setMembershipsText(text, memberships) {
154
164
  const cards = root.items.find((p) => keyOf(p) === 'cards');
155
165
  return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
156
166
  }
167
+ function flowUsage(booking) {
168
+ const period = /^\d+$/.test(booking.period)
169
+ ? `"${booking.period}"`
170
+ : booking.period;
171
+ return `{ period: ${period}, spent: ${String(booking.after)} }`;
172
+ }
173
+ export function capUsageText(text, booking) {
174
+ const card = findCard(text, booking.cardId);
175
+ const value = flowUsage(booking);
176
+ const usage = card.get('cap_usage', true);
177
+ if (isMap(usage) && !usage.flow && usage.items.length > 0) {
178
+ const existing = usage.items.find((p) => keyOf(p) === booking.capKey);
179
+ if (existing) {
180
+ const [start, end] = range(existing.value);
181
+ return text.slice(0, start) + value + text.slice(end);
182
+ }
183
+ const indent = ' '.repeat(column(text, range(usage.items[0]?.key)[0]));
184
+ const at = afterLine(text, range(usage)[1] - 1);
185
+ return insertLines(text, at, `${indent}${booking.capKey}: ${value}\n`);
186
+ }
187
+ if (usage !== undefined)
188
+ throw new Error(`card '${booking.cardId}' has a cap_usage that isn't a block map; ${BY_HAND}`);
189
+ const indent = ' '.repeat(column(text, range(card.items[0]?.key)[0]));
190
+ const at = afterLine(text, range(card)[1] - 1);
191
+ return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
192
+ }
193
+ export function bonusSpentText(text, cardId, spent) {
194
+ const bonus = findCard(text, cardId).get('bonus', true);
195
+ if (!isMap(bonus))
196
+ throw new Error(`card '${cardId}' has no bonus`);
197
+ const value = String(spent);
198
+ const existing = bonus.items.find((p) => keyOf(p) === 'spent');
199
+ if (existing) {
200
+ const [start, end] = range(existing.value);
201
+ return text.slice(0, start) + value + text.slice(end);
202
+ }
203
+ if (bonus.flow) {
204
+ const close = text.lastIndexOf('}', range(bonus)[1]);
205
+ return `${text.slice(0, close).trimEnd()}, spent: ${value} ${text.slice(close)}`;
206
+ }
207
+ const indent = ' '.repeat(column(text, range(bonus.items[0]?.key)[0]));
208
+ const at = afterLine(text, range(bonus)[1] - 1);
209
+ return insertLines(text, at, `${indent}spent: ${value}\n`);
210
+ }
211
+ // Writes spending toward caps and welcome bonuses in one checked write.
212
+ export async function writeProgress(file, caps, bonuses) {
213
+ const { text, wallet } = await readWalletText(file);
214
+ if (text === null || wallet === null)
215
+ throw new Error(`No wallet at ${file}.`);
216
+ let after = text;
217
+ let expected = wallet;
218
+ for (const b of caps) {
219
+ after = capUsageText(after, b);
220
+ expected = applyBooking(expected, b);
221
+ }
222
+ for (const b of bonuses) {
223
+ after = bonusSpentText(after, b.cardId, b.after);
224
+ expected = applyBonusBooking(expected, b);
225
+ }
226
+ const byHand = [
227
+ ...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b)}`),
228
+ ...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
229
+ ].join('; ');
230
+ await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
231
+ }
157
232
  async function readText(file) {
158
233
  try {
159
234
  return await readFile(file, 'utf8');
@@ -173,10 +248,10 @@ export async function readWalletText(file) {
173
248
  }
174
249
  // Writes `after` only if it parses to exactly `expected`, so a splice that
175
250
  // went wrong can never change anything it wasn't meant to.
176
- export async function commitWalletText(file, after, expected) {
251
+ export async function commitWalletText(file, after, expected, fallback = BY_HAND) {
177
252
  const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
178
253
  if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
179
- throw new Error(`couldn't update ${file} safely; ${BY_HAND}`);
254
+ throw new Error(`couldn't update ${file} safely; ${fallback}`);
180
255
  }
181
256
  await mkdir(path.dirname(file), { recursive: true });
182
257
  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
  }
@@ -47,7 +56,26 @@ export function pickedCategories(card, held, date) {
47
56
  }
48
57
  return { picked, staleChoice };
49
58
  }
50
- function applicability(rule, ctx, held, picked) {
59
+ // Activating a rotating quarter (Freedom Flex) turns on all its categories:
60
+ // rules on the same rotating cap with the same dates share one activation.
61
+ export function activationGroup(card, ruleId) {
62
+ const rule = card.rules.find((r) => r.id === ruleId);
63
+ const cap = typeof rule?.cap === 'string'
64
+ ? card.caps.find((c) => c.id === rule.cap)
65
+ : undefined;
66
+ if (!rule || !cap?.rotating)
67
+ return [ruleId];
68
+ return card.rules
69
+ .filter((r) => r.requires?.activation === true &&
70
+ r.cap === rule.cap &&
71
+ r.valid_from === rule.valid_from &&
72
+ r.valid_to === rule.valid_to)
73
+ .map((r) => r.id);
74
+ }
75
+ export function isActivated(card, held, ruleId) {
76
+ return activationGroup(card, ruleId).some((id) => held.activated.includes(id));
77
+ }
78
+ function applicability(card, rule, ctx, held, picked) {
51
79
  const { match, exclude } = rule;
52
80
  const inCategory = (cats) => cats.some((c) => categoryMatches(c, ctx.category));
53
81
  if (match.categories?.length && !inCategory(match.categories))
@@ -89,24 +117,39 @@ function applicability(rule, ctx, held, picked) {
89
117
  const membership = rule.requires?.membership;
90
118
  if (membership !== undefined && !ctx.memberships.has(membership))
91
119
  return 'no';
92
- if (rule.requires?.activation && !held.activated.includes(rule.id))
120
+ if (rule.requires?.activation && !isActivated(card, held, rule.id))
93
121
  return 'needs-activation';
94
122
  return 'applies';
95
123
  }
124
+ // A cap's key in the wallet's cap_usage: the shared cap's id, or the rule's
125
+ // id for a cap written on the rule.
126
+ export function capByKey(card, key) {
127
+ const shared = card.caps.find((c) => c.id === key);
128
+ if (shared)
129
+ return shared;
130
+ const inline = card.rules.find((r) => r.id === key)?.cap;
131
+ return typeof inline === 'object' ? inline : undefined;
132
+ }
133
+ // How much of a cap has been spent in the period `date` falls in.
134
+ export function capUsed(cap, key, held, date) {
135
+ const period = periodKey(cap.period, date, held.anniversary ?? null);
136
+ const usage = held.cap_usage[key];
137
+ return {
138
+ period,
139
+ spent: period !== null && usage?.period === period ? usage.spent : 0,
140
+ };
141
+ }
96
142
  function capState(rule, card, held, date) {
97
143
  if (rule.cap === undefined)
98
144
  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')
145
+ const key = typeof rule.cap === 'string' ? rule.cap : rule.id;
146
+ const cap = capByKey(card, key);
147
+ if (cap === undefined)
102
148
  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) };
149
+ const { period, spent } = capUsed(cap, key, held, date);
150
+ return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
108
151
  }
109
- export function rateAfterCap(rate, cap, amount) {
152
+ function rateAfterCap(rate, cap, amount) {
110
153
  if (!cap)
111
154
  return rate;
112
155
  if (amount !== null && amount > 0) {
@@ -134,7 +177,7 @@ export function evaluateCard(card, held, ctx) {
134
177
  let nominal = base;
135
178
  const blocked = [];
136
179
  for (const rule of card.rules) {
137
- const status = applicability(rule, ctx, held, picked);
180
+ const status = applicability(card, rule, ctx, held, picked);
138
181
  if (status === 'no')
139
182
  continue;
140
183
  if (status === 'needs-activation') {
@@ -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,
@@ -47,11 +34,12 @@ export function compareOffers(catalog, wallet, filters, today) {
47
34
  spend: offer.spend,
48
35
  months: offer.months,
49
36
  validTo: offer.valid_to ?? null,
37
+ varies: offer.varies === true,
50
38
  note: offer.note ?? null,
51
39
  annualFee: card.annual_fee ?? null,
52
40
  firstYearFee,
53
- bonusValue: round(value),
54
- firstYearValue: round(value - (firstYearFee ?? 0)),
41
+ bonusValue: roundCents(value),
42
+ firstYearValue: roundCents(value - (firstYearFee ?? 0)),
55
43
  held: held.has(card.id),
56
44
  lastVerified: card.last_verified,
57
45
  verifiedBy: card.verified_by,
@@ -60,7 +48,7 @@ export function compareOffers(catalog, wallet, filters, today) {
60
48
  offers.sort((a, b) => b.firstYearValue - a.firstYearValue || a.cardId.localeCompare(b.cardId));
61
49
  const notes = [
62
50
  `Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
63
- "Each offer is the public one on the issuer's page, checked weekly. Offers can differ by person and by how you apply, and issuers limit who can get a bonus; each card's note says how.",
51
+ "Each offer is the public one on the issuer's page on the date shown, checked weekly. Yours may differ: issuers test and target offers, so use the one shown to you when you apply. Issuers also limit who can get a bonus; each card's note says how.",
64
52
  "First-year value is the bonus at your point values minus the first year's annual fee. It leaves out what the card earns on your spending and any credits or perks.",
65
53
  ];
66
54
  return { offers, withoutOffer, notes };
@@ -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
+ }
@@ -1,8 +1,9 @@
1
1
  import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
2
- import { evaluateCard, purchaseContext, } from "./evaluate.js";
3
2
  import { bonusStatus } from "./bonus.js";
3
+ import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
4
4
  import { daysBetween } from "./periods.js";
5
5
  import { rotationGap } from "./rotation.js";
6
+ import { categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, num, pointValuesText, } from "./text.js";
6
7
  import { cardValuation } from "./valuation.js";
7
8
  const EPSILON = 1e-9;
8
9
  export const STALE_AFTER_DAYS = 180;
@@ -20,36 +21,9 @@ const PERIOD_WORDS = {
20
21
  cardmember_year: 'cardmember-year',
21
22
  };
22
23
  const displayName = (e) => e.held.checkout_label ?? e.card.name;
23
- export const num = (n) => String(Number(n.toFixed(2)));
24
- // Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
25
- const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
26
- export const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
27
- export function currencyName(catalog, id) {
28
- return catalog.currencies.find((c) => c.id === id)?.name ?? id;
29
- }
30
- export function currencyLabel(catalog, id) {
31
- const name = currencyName(catalog, id);
32
- return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
33
- }
34
24
  // "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
35
25
  const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
36
26
  const roundDown = (n) => String(Math.floor(n * 100 + EPSILON) / 100);
37
- function categoryName(catalog, id) {
38
- return catalog.categories.find((c) => c.id === id)?.name ?? id;
39
- }
40
- // Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
41
- // internet service" and "EV charging" keep their acronyms.
42
- export function categoryPhrase(catalog, id) {
43
- const name = categoryName(catalog, id);
44
- return /^[A-Z]{2}/.test(name)
45
- ? name
46
- : name.charAt(0).toLowerCase() + name.slice(1);
47
- }
48
- export function earnText(catalog, rate, currency) {
49
- return currency === 'cash'
50
- ? `${num(rate)}% cash back`
51
- : `${num(rate)}x ${currencyName(catalog, currency)}`;
52
- }
53
27
  function compareEntries(a, b) {
54
28
  const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
55
29
  if (Math.abs(byReturn) > EPSILON)
@@ -71,14 +45,11 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
71
45
  const card = catalog.cards.find((c) => c.id === held.id);
72
46
  if (!card)
73
47
  continue;
74
- if (card.only_at) {
75
- if (merchant.merchantId === null ||
76
- !card.only_at.includes(merchant.merchantId))
48
+ if (!usableAt(card, merchant)) {
49
+ // A store card elsewhere isn't worth mentioning; a network the merchant
50
+ // doesn't take is.
51
+ if (card.only_at || card.network === 'store' || !merchant.accepts)
77
52
  continue;
78
- }
79
- else if (card.network !== 'store' &&
80
- merchant.accepts &&
81
- !merchant.accepts.includes(card.network)) {
82
53
  const takes = merchant.accepts
83
54
  .map((n) => NETWORK_NAMES[n] ?? n)
84
55
  .join(' or ');
@@ -126,7 +97,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
126
97
  const cap = best.cap;
127
98
  if (cap && amount !== null && amount > cap.remaining) {
128
99
  const over = amount - cap.remaining;
129
- lines.push(`${dollars(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${dollars(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
100
+ lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
130
101
  }
131
102
  if (valuation.pooledFrom !== null) {
132
103
  const partners = card.pools_into?.when_wallet_has ?? [];
@@ -147,7 +118,7 @@ function warningsFor(catalog, entry, purchase) {
147
118
  const { best, nominal } = evaluation;
148
119
  const warnings = [];
149
120
  const capLabel = (e) => e.cap
150
- ? `${dollars(e.cap.cap.amount)} ${PERIOD_WORDS[e.cap.cap.period] ?? ''} cap`
121
+ ? `${money(e.cap.cap.amount)} ${PERIOD_WORDS[e.cap.cap.period] ?? ''} cap`
151
122
  : '';
152
123
  if (best.cap && best.rule) {
153
124
  const { remaining, cap, period } = best.cap;
@@ -155,10 +126,10 @@ function warningsFor(catalog, entry, purchase) {
155
126
  warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
156
127
  }
157
128
  else if (purchase.amount !== null && purchase.amount > remaining) {
158
- warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${dollars(remaining)} is left for ${period}.`);
129
+ warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${money(remaining)} is left for ${period}.`);
159
130
  }
160
131
  else if (remaining < cap.amount * NEAR_CAP_SHARE) {
161
- warnings.push(`Only ${dollars(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
132
+ warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
162
133
  }
163
134
  }
164
135
  if (nominal.rule &&
@@ -265,11 +236,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
265
236
  assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
266
237
  }
267
238
  else {
268
- const category = catalog.categories.find((c) => c.id === merchant.category);
269
- const mcc = merchant.likelyMcc ??
270
- (category?.mccs.length === 1 ? category.mccs[0] : undefined);
271
- const code = mcc === undefined ? '' : ` (MCC ${String(mcc)})`;
272
- assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
239
+ assumptions.push(codesAs(catalog, merchant));
273
240
  }
274
241
  if (merchant.inference) {
275
242
  assumptions.push(`Claude's reasoning: ${merchant.inference.rationale}`);
@@ -280,15 +247,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
280
247
  if (purchase.channel === null && merchant.channel === 'online') {
281
248
  assumptions.push('Assumes an online purchase, since you gave a web address.');
282
249
  }
283
- const pointValues = new Map();
284
- for (const e of entries) {
285
- if (e.valuation.currency !== 'cash')
286
- pointValues.set(e.valuation.currency, e.valuation.centsPerUnit);
287
- }
288
- if (pointValues.size > 0) {
289
- const list = [...pointValues].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
290
- assumptions.push(`Values points at: ${list.join(', ')}.`);
291
- }
250
+ const values = pointValuesText(catalog, entries.map((e) => e.valuation));
251
+ if (values !== null)
252
+ assumptions.push(values);
292
253
  return assumptions;
293
254
  }
294
255
  export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
@@ -1,7 +1,7 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { bonusStatus, countsTowardBonus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext } from "./evaluate.js";
4
- const roundCents = (n) => Math.round(n * 100) / 100;
4
+ import { roundCents } from "./text.js";
5
5
  // Books the spend against the cap of the highest-rate rule that matches, even
6
6
  // when that cap is already used up, so usage stays accurate across a reset.
7
7
  // Returns null when there's nothing to track: no cap, or a cardmember-year cap
@@ -50,12 +50,8 @@ export function applyBooking(wallet, booking) {
50
50
  : c),
51
51
  };
52
52
  }
53
- export function recordPurchase(catalog, wallet, purchase) {
54
- const booking = capBooking(catalog, wallet, purchase);
55
- return booking ? applyBooking(wallet, booking) : wallet;
56
- }
57
53
  // The purchase's progress toward the card's open welcome bonus, if it counts.
58
- export function bonusBooking(catalog, wallet, purchase) {
54
+ function bonusBooking(catalog, wallet, purchase) {
59
55
  const held = wallet.cards.find((c) => c.id === purchase.cardId);
60
56
  const status = held ? bonusStatus(catalog, wallet, held, purchase.date) : null;
61
57
  if (!status ||
@@ -79,7 +75,6 @@ export function applyBonusBooking(wallet, b) {
79
75
  : c),
80
76
  };
81
77
  }
82
- // Everything recording a purchase changes: its cap and its welcome bonus.
83
78
  export function bookPurchase(catalog, wallet, purchase, merchant) {
84
79
  const cap = capBooking(catalog, wallet, purchase, merchant);
85
80
  const bonus = bonusBooking(catalog, wallet, purchase);
@@ -1,4 +1,4 @@
1
- import { pickedCategories } from "./evaluate.js";
1
+ import { isActivated, pickedCategories } from "./evaluate.js";
2
2
  import { daysBetween } from "./periods.js";
3
3
  import { rotationGap } from "./rotation.js";
4
4
  // Rules that start within this many days are worth activating now.
@@ -144,7 +144,7 @@ export function setupNotes(catalog, wallet, held, today) {
144
144
  text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
145
145
  });
146
146
  }
147
- const toActivate = activationRules(card, today).filter((id) => !held.activated.includes(id));
147
+ const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
148
148
  if (toActivate.length > 0) {
149
149
  notes.push({
150
150
  kind: 'activation',