earnrate 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +12 -11
  2. package/data/CHANGELOG.md +35 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +5 -4
  5. package/data/cards/chase-freedom-flex.yaml +1 -1
  6. package/data/cards/chase-southwest-plus.yaml +4 -0
  7. package/data/cards/chase-southwest-premier.yaml +4 -0
  8. package/data/cards/chase-southwest-priority.yaml +4 -0
  9. package/data/cards/citi-aadvantage-globe.yaml +7 -7
  10. package/data/cards/citi-costco-anywhere-visa-business.yaml +15 -1
  11. package/data/cards/citi-costco-anywhere-visa.yaml +15 -1
  12. package/data/merchants/overrides.yaml +8 -0
  13. package/data/merchants/seed.yaml +0 -6
  14. package/dist/cli/cheatsheet.js +1 -1
  15. package/dist/cli/explore.js +1 -1
  16. package/dist/cli/import.js +4 -3
  17. package/dist/cli/run.js +1 -1
  18. package/dist/data/integrity.js +10 -0
  19. package/dist/data/wallet-edit.js +8 -2
  20. package/dist/engine/cheat-sheet.js +44 -2
  21. package/dist/engine/explore.js +14 -5
  22. package/dist/engine/memberships.js +45 -0
  23. package/dist/engine/offers.js +22 -2
  24. package/dist/engine/recommend.js +78 -31
  25. package/dist/engine/setup.js +22 -3
  26. package/dist/engine/simulate.js +32 -10
  27. package/dist/engine/text.js +51 -0
  28. package/dist/engine/wallet-change.js +25 -4
  29. package/dist/engine/wallet-summary.js +62 -34
  30. package/dist/format/card-text.js +7 -13
  31. package/dist/format/cards.js +7 -8
  32. package/dist/format/explore.js +17 -8
  33. package/dist/format/offers.js +5 -9
  34. package/dist/format/recommendation.js +11 -5
  35. package/dist/format/wallet-yaml.js +18 -3
  36. package/dist/format/wallet.js +12 -19
  37. package/dist/mcp/inputs.js +2 -2
  38. package/dist/mcp/schemas.js +13 -0
  39. package/dist/mcp/tools/account.js +27 -9
  40. package/dist/mcp/tools/answers.js +24 -7
  41. package/dist/mcp/tools/wallet.js +49 -7
  42. package/dist/schema/card.js +14 -0
  43. package/dist/schema/wallet.js +4 -0
  44. package/package.json +1 -1
  45. package/wallet.example.yaml +1 -0
@@ -4,49 +4,40 @@ import { daysBetween } from "./periods.js";
4
4
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
5
  import { rotationGap } from "./rotation.js";
6
6
  import { activationRules } from "./setup.js";
7
- import { categoryPhrase, earnText } from "./text.js";
7
+ import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
8
8
  import { cardValuation } from "./valuation.js";
9
- const NUMBERS = {
10
- one: '1',
11
- two: '2',
12
- three: '3',
13
- four: '4',
14
- five: '5',
15
- six: '6',
16
- };
17
- // "five-percent" reads "5%"; other ids read as words.
18
- export function choiceName(id) {
19
- return id
20
- .replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`)
21
- .replace(/[-_.]/g, ' ');
22
- }
23
- const listed = (parts) => parts.length <= 2
24
- ? parts.join(' and ')
25
- : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
9
+ // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
10
+ // lower case and plural; brand names keep theirs.
11
+ const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
26
12
  function merchantName(catalog, id) {
27
13
  const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
28
- return all.find((m) => m.id === id)?.name ?? id.replace(/[-_.]/g, ' ');
14
+ const name = all.find((m) => m.id === id)?.name ?? words(id);
15
+ return name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`);
29
16
  }
30
17
  function optionName(catalog, card, choiceId, option) {
31
18
  const o = card.choices.find((c) => c.id === choiceId)?.options[option];
32
19
  if (!o)
33
- return option.replace(/[-_.]/g, ' ');
20
+ return words(option);
34
21
  const parts = [
35
22
  ...o.categories.map((c) => categoryPhrase(catalog, c)),
36
23
  ...o.merchants.map((m) => merchantName(catalog, m)),
37
24
  ];
38
25
  if (o.online)
39
26
  parts.push('online shopping');
40
- return parts.length > 0 ? listed(parts) : option.replace(/[-_.]/g, ' ');
27
+ return parts.length > 0 ? listed(parts) : words(option);
41
28
  }
42
- function ruleWhat(catalog, rule) {
29
+ // What a rule covers, as a list of phrases.
30
+ function ruleParts(catalog, rule) {
43
31
  if (rule.match.choice !== undefined)
44
- return `your ${choiceName(rule.match.choice)} picks`;
32
+ return [`your ${choiceName(rule.match.choice)} picks`];
45
33
  const parts = [
46
34
  ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
47
35
  ...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
48
36
  ];
49
- return parts.length > 0 ? listed(parts) : rule.id.replace(/[-_.]/g, ' ');
37
+ return parts.length > 0 ? parts : [words(rule.id)];
38
+ }
39
+ function ruleWhat(catalog, rule) {
40
+ return listed(ruleParts(catalog, rule));
50
41
  }
51
42
  function periodEnd(period) {
52
43
  const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
@@ -63,14 +54,45 @@ function periodEnd(period) {
63
54
  }
64
55
  const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
65
56
  (rule.valid_to === undefined || rule.valid_to >= today);
66
- function headlineRates(catalog, card, currency, today) {
67
- const best = card.rules
68
- .filter((r) => applies(r, today) && r.rate > card.base_rate)
69
- .sort((a, b) => b.rate - a.rate)
70
- .slice(0, 4)
71
- .map((r) => `${earnText(catalog, r.rate, currency)} on ${ruleWhat(catalog, r)}${r.requires?.activation ? ' (once activated)' : ''}`);
57
+ const matchKeys = (rule) => rule.match.choice !== undefined
58
+ ? []
59
+ : [
60
+ ...(rule.match.categories ?? []).map((c) => `category:${c}`),
61
+ ...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
62
+ ];
63
+ // Every bonus rate the cardholder gets now, best first, so none reads as the
64
+ // base rate. Rates behind a membership the wallet lacks are left out, and so
65
+ // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
66
+ // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
67
+ // a line.
68
+ function headlineRates(catalog, wallet, card, held, currency, today) {
69
+ const shown = card.rules
70
+ .filter((r) => applies(r, today) &&
71
+ r.rate > card.base_rate &&
72
+ (r.requires?.membership === undefined ||
73
+ wallet.memberships.includes(r.requires.membership)))
74
+ .sort((a, b) => b.rate - a.rate);
75
+ const lines = new Map();
76
+ for (const r of shown) {
77
+ const keys = matchKeys(r);
78
+ const higher = shown
79
+ .filter((h) => h.rate > r.rate &&
80
+ h.cap === undefined &&
81
+ (!h.requires?.activation || isActivated(card, held, h.id)))
82
+ .flatMap(matchKeys);
83
+ if (keys.length > 0 && keys.every((k) => higher.includes(k)))
84
+ continue;
85
+ const pending = r.requires?.activation && !isActivated(card, held, r.id);
86
+ const head = `${earnText(catalog, r.rate, currency)} on`;
87
+ const key = pending ? `${head}|activate` : head;
88
+ const whats = lines.get(key) ?? [];
89
+ for (const part of ruleParts(catalog, r))
90
+ if (!whats.includes(part))
91
+ whats.push(part);
92
+ lines.set(key, whats);
93
+ }
72
94
  return [
73
- ...best,
95
+ ...[...lines].map(([key, whats]) => `${key.replace('|activate', '')} ${listed(whats)}${key.endsWith('|activate') ? ' (once activated)' : ''}`),
74
96
  `${earnText(catalog, card.base_rate, currency)} on everything else`,
75
97
  ];
76
98
  }
@@ -113,7 +135,7 @@ function capSummaries(catalog, card, held, today) {
113
135
  const label = shared?.rotating
114
136
  ? 'the rotating bonus categories'
115
137
  : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
116
- key.replace(/[-_.]/g, ' ');
138
+ words(key);
117
139
  return {
118
140
  key,
119
141
  label,
@@ -142,6 +164,9 @@ export function walletSummary(catalog, wallet, today) {
142
164
  onlyAt: card.only_at ?? null,
143
165
  bonus: bonusStatus(catalog, wallet, held, today),
144
166
  currency: valuation.currency,
167
+ currencyName: valuation.currency === 'cash'
168
+ ? 'cash back'
169
+ : currencyLabel(catalog, valuation.currency),
145
170
  centsPerUnit: valuation.centsPerUnit,
146
171
  lastVerified: card.last_verified,
147
172
  verifiedBy: card.verified_by,
@@ -152,7 +177,10 @@ export function walletSummary(catalog, wallet, today) {
152
177
  ? {
153
178
  period: held.chosen.period,
154
179
  current: pickedCategories(card, held, today).staleChoice === null,
155
- until: periodEnd(held.chosen.period),
180
+ // Picks that carry over have no end date.
181
+ until: card.choices.every((c) => c.period === 'until_changed')
182
+ ? null
183
+ : periodEnd(held.chosen.period),
156
184
  picks: held.chosen.picks,
157
185
  pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
158
186
  choiceName(choice),
@@ -160,7 +188,7 @@ export function walletSummary(catalog, wallet, today) {
160
188
  ])),
161
189
  }
162
190
  : null,
163
- rates: headlineRates(catalog, card, valuation.currency, today),
191
+ rates: headlineRates(catalog, wallet, card, held, valuation.currency, today),
164
192
  activated: held.activated,
165
193
  toActivate: toActivate(catalog, card, held, today),
166
194
  anniversary: held.anniversary ?? null,
@@ -13,14 +13,6 @@ const ISSUERS = {
13
13
  usbank: 'U.S. Bank',
14
14
  'wells-fargo': 'Wells Fargo',
15
15
  };
16
- export const MEMBERSHIPS = {
17
- 'amazon-prime': 'Amazon Prime',
18
- 'bank-of-america-account': 'Bank of America checking or savings',
19
- 'bofa-rewards-member': 'BofA Rewards Member',
20
- 'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
21
- 'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
22
- 'bofa-rewards-premier': 'BofA Rewards Premier',
23
- };
24
16
  export const PICK_PERIODS = {
25
17
  month: 'each month',
26
18
  quarter: 'each quarter',
@@ -33,11 +25,11 @@ export const CAP_PERIODS = {
33
25
  calendar_year: 'a year',
34
26
  cardmember_year: 'a card year',
35
27
  };
36
- export const words = (id) => id.replace(/[-_.]/g, ' ');
28
+ export { MEMBERSHIPS, membershipName, words } from "../engine/text.js";
29
+ import { membershipName, words } from "../engine/text.js";
37
30
  // Whole dollars, for fees and caps.
38
31
  export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
39
32
  export const issuerName = (id) => ISSUERS[id] ?? id;
40
- export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
41
33
  export function cardText(catalog) {
42
34
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
43
35
  function shortName(id) {
@@ -50,15 +42,17 @@ export function cardText(catalog) {
50
42
  }
51
43
  function feeLabel(id) {
52
44
  const card = cards.get(id);
45
+ const m = card?.requires_membership;
46
+ const needs = m ? `; needs ${m.name} (${usd(m.annual_fee)} a year)` : '';
53
47
  const fee = card?.annual_fee;
54
48
  if (fee === undefined)
55
49
  return 'Annual fee not on file';
56
50
  if (fee === 0)
57
- return 'No annual fee';
51
+ return `No annual fee${needs}`;
58
52
  const first = card?.first_year_fee;
59
53
  if (first !== undefined && first !== fee)
60
- return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)`;
61
- return `${usd(fee)} a year`;
54
+ return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)${needs}`;
55
+ return `${usd(fee)} a year${needs}`;
62
56
  }
63
57
  // `until` is the last day a time-limited rate applies, already formatted.
64
58
  function requirementLines(cardId, r, until = null) {
@@ -1,10 +1,4 @@
1
- const NETWORKS = {
2
- visa: 'Visa',
3
- mastercard: 'Mastercard',
4
- amex: 'Amex',
5
- discover: 'Discover',
6
- store: 'store card',
7
- };
1
+ import { NETWORK_NAMES } from "../engine/text.js";
8
2
  export function formatCardInfo(card) {
9
3
  const fee = card.annualFee === null
10
4
  ? ''
@@ -13,6 +7,11 @@ export function formatCardInfo(card) {
13
7
  : `, $${String(card.annualFee)} a year`;
14
8
  const needs = [
15
9
  ...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
10
+ ...(card.requiresMembership
11
+ ? [
12
+ `needs ${card.requiresMembership.name} ($${String(card.requiresMembership.annualFee)} a year; membership id ${card.requiresMembership.id})`,
13
+ ]
14
+ : []),
16
15
  ...(card.memberships.length > 0
17
16
  ? [`rates that need a membership: ${card.memberships.join(', ')}`]
18
17
  : []),
@@ -23,7 +22,7 @@ export function formatCardInfo(card) {
23
22
  ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
24
23
  ];
25
24
  return [
26
- `${card.id}: ${card.name} (${NETWORKS[card.network]}${fee})${card.held ? ' [in wallet]' : ''}`,
25
+ `${card.id}: ${card.name} (${NETWORK_NAMES[card.network] ?? card.network}${fee})${card.held ? ' [in wallet]' : ''}`,
27
26
  ...needs.map((n) => ` ${n}`),
28
27
  ].join('\n');
29
28
  }
@@ -1,5 +1,9 @@
1
- import { feeText, money, pct } from "../engine/text.js";
1
+ import { membershipNote } from "../engine/memberships.js";
2
+ import { feeText, membershipName, money, pct } from "../engine/text.js";
2
3
  const wholeUsd = (n) => money(Math.round(n));
4
+ const membershipText = (memberships) => memberships
5
+ .map((m) => ` + ${wholeUsd(m.annualFee)} for ${m.name.replace(/^an? /, '')}`)
6
+ .join('');
3
7
  const TOP_BEST = 10;
4
8
  export function formatExploration(r, all) {
5
9
  const m = r.merchant;
@@ -20,7 +24,7 @@ export function formatExploration(r, all) {
20
24
  lines.push(` ${c}`);
21
25
  const upgrade = e.withMembership[0];
22
26
  if (upgrade)
23
- lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
27
+ lines.push(` With ${membershipName(upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
24
28
  for (const w of e.warnings)
25
29
  lines.push(` ! ${w}`);
26
30
  });
@@ -64,7 +68,7 @@ export function formatCatalogSimulation(r, catalog, all) {
64
68
  const card = s.cards[0];
65
69
  if (!card)
66
70
  return;
67
- lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${picksText(card.picks)}`);
71
+ lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee${membershipText(s.memberships)})${picksText(card.picks)}`);
68
72
  });
69
73
  const pairs = r.pairs.slice(0, top);
70
74
  if (pairs.length > 0) {
@@ -92,7 +96,7 @@ export function formatCatalogSimulation(r, catalog, all) {
92
96
  export function formatAdditions(r, catalog, all) {
93
97
  const name = cardNamer(catalog);
94
98
  const lines = [
95
- `Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees` : ''}.`,
99
+ `Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees: ${wholeUsd(r.current.net)} after fees` : ''}.`,
96
100
  ];
97
101
  for (const split of splitText(r.current, name))
98
102
  lines.push(` ${split}`);
@@ -101,14 +105,19 @@ export function formatAdditions(r, catalog, all) {
101
105
  lines.push('', 'No card would add more than its annual fee costs.');
102
106
  }
103
107
  else {
104
- lines.push('', 'Cards that would add the most (after their annual fee):');
108
+ lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
105
109
  gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
106
- lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee)${picksText(a.picks)}`);
110
+ lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee${membershipText(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
107
111
  });
108
112
  }
109
- const notes = new Set(r.current.notes);
113
+ const notes = new Set([
114
+ ...r.current.notes,
115
+ ...gains
116
+ .slice(0, all ? 20 : TOP_SIMULATED)
117
+ .flatMap((a) => a.simulation.memberships.map((m) => membershipNote(catalog, m))),
118
+ ]);
110
119
  if (notes.size > 0)
111
120
  lines.push('', ...notes);
112
- lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
121
+ lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
113
122
  return `${lines.join('\n')}\n`;
114
123
  }
@@ -1,4 +1,4 @@
1
- import { feeText, money } from "../engine/text.js";
1
+ import { feeText, money, shortDate } from "../engine/text.js";
2
2
  const TOP = 10;
3
3
  function reward(o, currencyName) {
4
4
  const amount = o.dollars !== null
@@ -6,12 +6,6 @@ function reward(o, currencyName) {
6
6
  : `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
7
7
  return o.varies ? `Up to ${amount}` : amount;
8
8
  }
9
- const asOf = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
10
- month: 'short',
11
- day: 'numeric',
12
- year: 'numeric',
13
- timeZone: 'UTC',
14
- });
15
9
  export function formatOffers(c, currencyName, all) {
16
10
  if (c.offers.length === 0) {
17
11
  return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
@@ -25,8 +19,10 @@ export function formatOffers(c, currencyName, all) {
25
19
  const spend = o.spend > 0
26
20
  ? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
27
21
  : 'with no spending required';
28
- lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.held ? ' In your wallet.' : ''}`);
29
- lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${asOf(o.lastVerified)}; yours may differ.`);
22
+ lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
23
+ lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
24
+ if (o.ineligible)
25
+ lines.push(` ${o.ineligible}`);
30
26
  if (o.note)
31
27
  lines.push(` ${o.note}`);
32
28
  });
@@ -24,13 +24,15 @@ function header(rec, purchase) {
24
24
  parts.push(m.channel === 'online' ? 'online' : 'in-store');
25
25
  return parts.join(' · ');
26
26
  }
27
- function cardLines(cards, style) {
27
+ function cardLines(cards, style, bonusPick) {
28
+ const rankWidth = Math.max(...cards.map((c) => c.rank));
28
29
  const nameWidth = Math.max(...cards.map((c) => label(c).length));
29
30
  const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
30
31
  const lines = [];
31
- cards.forEach((c, i) => {
32
+ cards.forEach((c) => {
32
33
  const earned = c.earned === null ? '' : ` ${cents(c.earned)}`;
33
- lines.push(` ${String(i + 1)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}`);
34
+ const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
35
+ lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
34
36
  for (const r of c.reasoning)
35
37
  lines.push(style('dim', ` ${r}`));
36
38
  for (const w of c.warnings)
@@ -60,9 +62,13 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
60
62
  else {
61
63
  lines.push(style('bold', noCard));
62
64
  }
63
- const shown = all ? rec.ranked : rec.ranked.slice(0, TOP);
65
+ const bonusPick = rec.bonus?.cardId ?? null;
66
+ const ranked = rec.ranked.map((c, i) => ({ ...c, rank: i + 1 }));
67
+ const shown = all
68
+ ? ranked
69
+ : ranked.filter((c, i) => i < TOP || c.cardId === bonusPick);
64
70
  if (shown.length > 0)
65
- lines.push('', ...cardLines(shown, style));
71
+ lines.push('', ...cardLines(shown, style, bonusPick));
66
72
  const hidden = rec.ranked.length - shown.length;
67
73
  if (hidden > 0) {
68
74
  lines.push(style('dim', ` + ${String(hidden)} more ${hidden === 1 ? 'card' : 'cards'} (--all lists them)`));
@@ -1,10 +1,11 @@
1
- import { parse, stringify } from 'yaml';
1
+ import { Document, parse, visit } from 'yaml';
2
2
  import { walletProblems } from "../data/integrity.js";
3
- import { WalletSchema } from "../schema/wallet.js";
3
+ import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
4
4
  // A wallet as the YAML file the CLI reads, for moving it between the
5
5
  // website, the remote server and the command line. Node-free.
6
6
  function compact(wallet) {
7
7
  return {
8
+ version: wallet.version ?? WALLET_VERSION,
8
9
  ...(Object.keys(wallet.valuations).length > 0
9
10
  ? { valuations: wallet.valuations }
10
11
  : {}),
@@ -26,8 +27,22 @@ function compact(wallet) {
26
27
  })),
27
28
  };
28
29
  }
30
+ // Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare 2026-10-15 as a
31
+ // date, not a string.
32
+ function quotedYaml(value) {
33
+ const doc = new Document(value);
34
+ visit(doc, {
35
+ Scalar(key, node) {
36
+ if (key === 'value' &&
37
+ typeof node.value === 'string' &&
38
+ /^(\d{4}(-|$)|\d{2}-\d{2}$)/.test(node.value))
39
+ node.type = 'QUOTE_DOUBLE';
40
+ },
41
+ });
42
+ return doc.toString();
43
+ }
29
44
  export function walletYaml(wallet) {
30
- return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${stringify(compact(wallet))}`;
45
+ return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${quotedYaml(compact(wallet))}`;
31
46
  }
32
47
  export function parseWalletYaml(text, catalog) {
33
48
  let raw;
@@ -1,24 +1,11 @@
1
- import { money } from "../engine/text.js";
2
- const NETWORKS = {
3
- visa: 'Visa',
4
- mastercard: 'Mastercard',
5
- amex: 'American Express',
6
- discover: 'Discover',
7
- store: 'store card',
8
- };
9
- const day = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
10
- month: 'short',
11
- day: 'numeric',
12
- year: 'numeric',
13
- timeZone: 'UTC',
14
- });
1
+ import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
15
2
  export function formatWalletSummary(summary) {
16
3
  const cards = summary.cards.map((c) => {
17
4
  const label = c.checkoutLabel
18
5
  ? `, shown at checkout as "${c.checkoutLabel}"`
19
6
  : '';
20
7
  const lines = [
21
- `- ${c.cardName} (${c.cardId})${label}: ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
8
+ `- ${c.cardName} (${c.cardId})${label}: ${NETWORK_NAMES[c.network] ?? c.network}, ${c.currencyName} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
22
9
  ` Earns: ${c.rates.join('; ')}.`,
23
10
  ];
24
11
  if (c.onlyAt)
@@ -27,10 +14,14 @@ export function formatWalletSummary(summary) {
27
14
  const picks = Object.entries(c.chosen.pickNames)
28
15
  .map(([choice, names]) => `${choice}: ${names.join(', ')}`)
29
16
  .join('; ');
30
- const until = c.chosen.until ? `, through ${day(c.chosen.until)}` : '';
31
- lines.push(c.chosen.current
32
- ? ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`
33
- : ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`);
17
+ const until = c.chosen.until
18
+ ? `, through ${shortDate(c.chosen.until)}`
19
+ : '';
20
+ lines.push(!c.chosen.current
21
+ ? ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`
22
+ : c.chosen.until === null
23
+ ? ` Picks: ${picks}. They stay until the user changes them.`
24
+ : ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`);
34
25
  }
35
26
  if (c.toActivate.length > 0)
36
27
  lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
@@ -39,6 +30,8 @@ export function formatWalletSummary(summary) {
39
30
  for (const cap of c.caps)
40
31
  if (cap.remaining !== null)
41
32
  lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
33
+ if (c.anniversary !== null)
34
+ lines.push(` Account anniversary: ${c.anniversary}.`);
42
35
  const b = c.bonus;
43
36
  if (b?.state === 'open' && b.spend !== null)
44
37
  lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
@@ -120,11 +120,11 @@ export function makeInputs(catalog) {
120
120
  .array(NamedCard)
121
121
  .optional()
122
122
  .describe("Cards the user named in this conversation, to answer for just those cards without saving anything. Leave out to use the user's saved wallet if they have one; with neither, the answer covers every card earnrate knows.");
123
- const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime, when they're named in the conversation rather than saved.");
123
+ const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime or costco, when they're named in the conversation rather than saved.");
124
124
  const CatalogFilters = {
125
125
  ...OfferFilters,
126
126
  issuer: z.enum(issuers).optional().describe("Only this issuer's cards."),
127
- memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime. Rates that need one they don't have are listed separately."),
127
+ memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime or costco. Rates that need one they don't have are listed separately; a card that needs one (Costco Anywhere) says so."),
128
128
  valuation: z
129
129
  .enum(['flat', 'points-heavy'])
130
130
  .optional()
@@ -170,6 +170,8 @@ export const OfferComparisonSchema = z.object({
170
170
  note: z.string().nullable(),
171
171
  annualFee: z.number().nullable(),
172
172
  firstYearFee: z.number().nullable(),
173
+ membershipFee: z.number(),
174
+ ineligible: z.string().nullable(),
173
175
  bonusValue: z.number(),
174
176
  firstYearValue: z.number(),
175
177
  held: z.boolean(),
@@ -204,6 +206,9 @@ export const CardSearchSchema = z.object({
204
206
  default: z.array(z.string()).nullable(),
205
207
  })),
206
208
  memberships: z.array(z.string()),
209
+ requiresMembership: z
210
+ .object({ id: z.string(), name: z.string(), annualFee: z.number() })
211
+ .nullable(),
207
212
  activation: z.array(z.string()),
208
213
  needsAnniversary: z.boolean(),
209
214
  })),
@@ -272,6 +277,13 @@ const SimulationSchema = z.object({
272
277
  spend: z.number(),
273
278
  rewards: z.number(),
274
279
  annualFees: z.number(),
280
+ memberships: z.array(z.object({
281
+ id: z.string(),
282
+ name: z.string(),
283
+ annualFee: z.number(),
284
+ cardIds: z.array(z.string()),
285
+ })),
286
+ membershipFees: z.number(),
275
287
  net: z.number(),
276
288
  lines: z.array(z.object({
277
289
  label: z.string(),
@@ -292,6 +304,7 @@ export const SpendingSimulationSchema = z.object({
292
304
  picks: Picks,
293
305
  extraRewards: z.number(),
294
306
  annualFee: z.number(),
307
+ membershipFee: z.number(),
295
308
  gain: z.number(),
296
309
  })),
297
310
  });
@@ -46,19 +46,35 @@ export function registerAccountTools(server, catalog, store, ctx) {
46
46
  const parsed = parseWalletYaml(yaml, catalog);
47
47
  if (typeof parsed === 'string')
48
48
  return fail(parsed);
49
- const current = await store.load().catch(() => null);
49
+ let current;
50
+ try {
51
+ current = await store.load();
52
+ }
53
+ catch (e) {
54
+ if (replace !== true)
55
+ return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
56
+ current = null;
57
+ }
50
58
  if (current !== null && current.cards.length > 0 && replace !== true)
51
59
  return fail(`This would replace the ${String(current.cards.length)} ${current.cards.length === 1 ? 'card' : 'cards'} already saved (${current.cards.map((c) => c.id).join(', ')}). Ask the user, then call again with replace: true.`);
52
- const count = parsed.cards.length;
53
- return ctx.changed(await store.replace(parsed), `Imported a wallet with ${String(count)} ${count === 1 ? 'card' : 'cards'}.`, { notes: false });
60
+ const cards = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
61
+ const before = current?.cards.length ?? 0;
62
+ return ctx.changed(await store.replace(parsed), before > 0
63
+ ? `Replaced the ${cards(before)} that were saved with the ${cards(parsed.cards.length)} imported.`
64
+ : `Imported a wallet with ${cards(parsed.cards.length)}.`, { notes: false });
54
65
  });
55
66
  server.registerTool('delete_wallet', {
56
67
  title: 'Delete the wallet',
57
- description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: offer export_wallet first, and pass confirm only after the user confirms. It refuses when the wallet has no cards, since there's nothing to delete.",
68
+ description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm and cards only after the user confirms. It refuses if the wallet no longer holds that many cards, so a retried call can't delete a different wallet.",
58
69
  inputSchema: z.object({
59
70
  confirm: z
60
71
  .literal(true)
61
72
  .describe('true, only after the user confirms the deletion.'),
73
+ cards: z
74
+ .number()
75
+ .int()
76
+ .nonnegative()
77
+ .describe('How many cards the wallet held when the user confirmed, from list_wallet: 0 for an empty wallet.'),
62
78
  }),
63
79
  outputSchema: z.object({ deleted: z.boolean() }),
64
80
  annotations: {
@@ -67,10 +83,12 @@ export function registerAccountTools(server, catalog, store, ctx) {
67
83
  idempotentHint: true,
68
84
  openWorldHint: false,
69
85
  },
70
- }, async () => {
71
- const current = await store.load().catch(() => null);
72
- if (current === null || current.cards.length === 0)
73
- return fail("This saved wallet has no cards, so there's nothing to delete. If the user meant a different wallet, it may already be deleted.");
86
+ }, async ({ cards }) => {
87
+ // An unreadable wallet can still be deleted: the user confirmed it.
88
+ const current = await store.load().catch(() => undefined);
89
+ const held = current === undefined ? cards : (current?.cards.length ?? 0);
90
+ if (held !== cards)
91
+ return fail(`This saved wallet holds ${String(held)} ${held === 1 ? 'card' : 'cards'}, not ${String(cards)}, so it isn't the wallet the user confirmed (it may have been deleted and a new one connected). Show it with list_wallet and confirm again.`);
74
92
  await store.erase();
75
93
  return {
76
94
  content: [
@@ -84,7 +102,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
84
102
  });
85
103
  server.registerTool('switch_wallet', {
86
104
  title: 'Switch wallets',
87
- description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
105
+ description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another Claude account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
88
106
  inputSchema: z.object({}),
89
107
  outputSchema: z.object({ disconnected: z.boolean() }),
90
108
  annotations: {