earnrate 0.2.4 → 0.2.6

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 (116) hide show
  1. package/README.md +18 -15
  2. package/data/CHANGELOG.md +79 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +14 -6
  5. package/data/cards/amex-blue-cash-everyday.yaml +12 -3
  6. package/data/cards/amex-blue-cash-preferred.yaml +17 -3
  7. package/data/cards/amex-gold.yaml +23 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
  13. package/data/cards/bilt-obsidian.yaml +4 -3
  14. package/data/cards/capital-one-kohls-card.yaml +11 -2
  15. package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
  16. package/data/cards/capital-one-savor.yaml +32 -10
  17. package/data/cards/capital-one-venture-x.yaml +19 -4
  18. package/data/cards/capital-one-venture.yaml +19 -5
  19. package/data/cards/chase-aeroplan.yaml +15 -3
  20. package/data/cards/chase-freedom-flex.yaml +50 -10
  21. package/data/cards/chase-freedom-unlimited.yaml +6 -2
  22. package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
  23. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
  24. package/data/cards/chase-ihg-one-rewards-premier.yaml +19 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +15 -2
  26. package/data/cards/chase-ink-business-cash.yaml +7 -3
  27. package/data/cards/chase-ink-business-preferred.yaml +9 -2
  28. package/data/cards/chase-prime-visa.yaml +24 -5
  29. package/data/cards/chase-sapphire-preferred.yaml +59 -6
  30. package/data/cards/chase-sapphire-reserve.yaml +12 -2
  31. package/data/cards/chase-southwest-performance-business.yaml +22 -4
  32. package/data/cards/chase-southwest-plus.yaml +13 -3
  33. package/data/cards/chase-southwest-premier-business.yaml +22 -5
  34. package/data/cards/chase-southwest-premier.yaml +13 -3
  35. package/data/cards/chase-southwest-priority.yaml +18 -2
  36. package/data/cards/chase-united-business.yaml +5 -2
  37. package/data/cards/chase-united-club-business.yaml +2 -2
  38. package/data/cards/chase-united-club.yaml +17 -4
  39. package/data/cards/chase-united-explorer.yaml +5 -2
  40. package/data/cards/chase-united-gateway.yaml +2 -2
  41. package/data/cards/chase-united-quest.yaml +12 -3
  42. package/data/cards/citi-aadvantage-business.yaml +10 -4
  43. package/data/cards/citi-aadvantage-executive.yaml +8 -8
  44. package/data/cards/citi-aadvantage-mileup.yaml +7 -4
  45. package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
  46. package/data/cards/citi-costco-anywhere-visa-business.yaml +6 -9
  47. package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
  48. package/data/cards/citi-custom-cash.yaml +43 -12
  49. package/data/cards/citi-double-cash.yaml +9 -3
  50. package/data/cards/citi-my-best-buy-visa.yaml +6 -3
  51. package/data/cards/citi-strata-elite.yaml +12 -2
  52. package/data/cards/citi-strata-premier.yaml +24 -2
  53. package/data/cards/sofi-unlimited-2.yaml +17 -3
  54. package/data/cards/td-bank-nordstrom-credit-card.yaml +16 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +23 -3
  56. package/data/cards/td-bank-target-circle-card.yaml +13 -4
  57. package/data/cards/usbank-cash-plus.yaml +29 -7
  58. package/data/cards/wells-fargo-active-cash.yaml +9 -2
  59. package/data/cards/wells-fargo-autograph.yaml +31 -7
  60. package/data/categories.yaml +12 -0
  61. package/data/currencies.yaml +2 -0
  62. package/data/issuers.yaml +32 -0
  63. package/data/memberships.yaml +33 -0
  64. package/data/merchants/overrides.yaml +25 -0
  65. package/data/merchants/seed.yaml +7 -0
  66. package/dist/cli/bin.js +10 -1
  67. package/dist/cli/cheatsheet.js +11 -6
  68. package/dist/cli/explore.js +17 -23
  69. package/dist/cli/import.js +10 -10
  70. package/dist/cli/offers.js +19 -8
  71. package/dist/cli/run.js +31 -20
  72. package/dist/cli/wallet.js +50 -5
  73. package/dist/data/file-store.js +9 -7
  74. package/dist/data/integrity.js +22 -2
  75. package/dist/data/load.js +13 -2
  76. package/dist/data/store.js +13 -22
  77. package/dist/data/wallet-edit.js +108 -8
  78. package/dist/engine/cheat-sheet.js +15 -19
  79. package/dist/engine/compare.js +10 -0
  80. package/dist/engine/evaluate.js +2 -4
  81. package/dist/engine/explore.js +13 -56
  82. package/dist/engine/memberships.js +41 -15
  83. package/dist/engine/offers.js +18 -5
  84. package/dist/engine/periods.js +18 -0
  85. package/dist/engine/profiles.js +8 -0
  86. package/dist/engine/recommend.js +23 -37
  87. package/dist/engine/setup.js +28 -40
  88. package/dist/engine/simulate.js +11 -10
  89. package/dist/engine/text.js +49 -23
  90. package/dist/engine/valuation.js +1 -1
  91. package/dist/engine/wallet-change.js +26 -11
  92. package/dist/engine/wallet-summary.js +85 -31
  93. package/dist/format/card-text.js +19 -36
  94. package/dist/format/cards.js +3 -3
  95. package/dist/format/explore.js +15 -16
  96. package/dist/format/offers.js +10 -15
  97. package/dist/format/recommendation.js +6 -6
  98. package/dist/format/wallet.js +5 -2
  99. package/dist/import/analyze.js +2 -1
  100. package/dist/mcp/context.js +61 -13
  101. package/dist/mcp/inputs.js +16 -7
  102. package/dist/mcp/schemas.js +9 -0
  103. package/dist/mcp/server.js +8 -1
  104. package/dist/mcp/tools/account.js +45 -17
  105. package/dist/mcp/tools/answers.js +84 -29
  106. package/dist/mcp/tools/wallet.js +109 -22
  107. package/dist/merchants/cache.js +0 -10
  108. package/dist/merchants/resolve.js +81 -4
  109. package/dist/schema/card.js +8 -12
  110. package/dist/schema/category.js +4 -0
  111. package/dist/schema/currency.js +2 -0
  112. package/dist/schema/issuer.js +8 -0
  113. package/dist/schema/membership.js +15 -0
  114. package/dist/schema/merchant.js +4 -0
  115. package/dist/schema/wallet.js +1 -1
  116. package/package.json +1 -1
@@ -1,11 +1,10 @@
1
- import { membershipNote } from "../engine/memberships.js";
2
- import { feeText, membershipName, money, pct } from "../engine/text.js";
3
- const wholeUsd = (n) => money(Math.round(n));
4
- const membershipText = (memberships) => memberships
5
- .map((m) => ` + ${wholeUsd(m.annualFee)} for ${m.name.replace(/^an? /, '')}`)
1
+ import { membershipName, membershipNote, } from "../engine/memberships.js";
2
+ import { choiceName, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
3
+ const membershipFees = (memberships) => memberships
4
+ .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
6
5
  .join('');
7
6
  const TOP_BEST = 10;
8
- export function formatExploration(r, all) {
7
+ export function formatExploration(r, catalog, all) {
9
8
  const m = r.merchant;
10
9
  const title = m.source === 'category'
11
10
  ? m.resolvedName.toLowerCase()
@@ -18,13 +17,13 @@ export function formatExploration(r, all) {
18
17
  const width = Math.max(...shown.map((e) => e.cardName.length));
19
18
  const lines = [`Best cards for ${title}:`, ''];
20
19
  shown.forEach((e, i) => {
21
- const earned = e.earned === null ? '' : ` ${money(e.earned)}`;
20
+ const earned = e.earned === null ? '' : ` ${dollarsAndCents(e.earned)}`;
22
21
  lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
23
22
  for (const c of e.conditions)
24
23
  lines.push(` ${c}`);
25
24
  const upgrade = e.withMembership[0];
26
25
  if (upgrade)
27
- lines.push(` With ${membershipName(upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
26
+ lines.push(` With ${membershipName(catalog, upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
28
27
  for (const w of e.warnings)
29
28
  lines.push(` ! ${w}`);
30
29
  });
@@ -36,8 +35,8 @@ export function formatExploration(r, all) {
36
35
  return `${lines.join('\n')}\n`;
37
36
  }
38
37
  function picksText(picks) {
39
- const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
40
- return parts.length === 0 ? '' : ` (picks ${parts.join('; ')})`;
38
+ const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words).join(' and ')} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
39
+ return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
41
40
  }
42
41
  function cardNamer(catalog) {
43
42
  return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
@@ -60,7 +59,7 @@ export function formatCatalogSimulation(r, catalog, all) {
60
59
  return 'No cards match.\n';
61
60
  const top = all ? 20 : TOP_SIMULATED;
62
61
  const lines = [
63
- `A year of this spending: ${wholeUsd(first.spend)}.`,
62
+ `A year of this spending: ${wholeDollars(first.spend)}.`,
64
63
  '',
65
64
  'Best single cards (rewards minus annual fee, per year):',
66
65
  ];
@@ -68,13 +67,13 @@ export function formatCatalogSimulation(r, catalog, all) {
68
67
  const card = s.cards[0];
69
68
  if (!card)
70
69
  return;
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)}`);
70
+ lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${picksText(card.picks)}`);
72
71
  });
73
72
  const pairs = r.pairs.slice(0, top);
74
73
  if (pairs.length > 0) {
75
74
  lines.push('', 'Best two-card pairs:');
76
75
  pairs.forEach((s, i) => {
77
- lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeUsd(s.net)}`);
76
+ lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}`);
78
77
  if (s.alternatives.length > 0)
79
78
  lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
80
79
  for (const split of splitText(s, name))
@@ -96,7 +95,7 @@ export function formatCatalogSimulation(r, catalog, all) {
96
95
  export function formatAdditions(r, catalog, all) {
97
96
  const name = cardNamer(catalog);
98
97
  const lines = [
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` : ''}.`,
98
+ `Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees: ${wholeDollars(r.current.net)} after fees` : ''}.`,
100
99
  ];
101
100
  for (const split of splitText(r.current, name))
102
101
  lines.push(` ${split}`);
@@ -107,7 +106,7 @@ export function formatAdditions(r, catalog, all) {
107
106
  else {
108
107
  lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
109
108
  gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
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)}`);
109
+ lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
111
110
  });
112
111
  }
113
112
  const notes = new Set([
@@ -118,6 +117,6 @@ export function formatAdditions(r, catalog, all) {
118
117
  ]);
119
118
  if (notes.size > 0)
120
119
  lines.push('', ...notes);
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.');
120
+ lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. 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.');
122
121
  return `${lines.join('\n')}\n`;
123
122
  }
@@ -1,11 +1,6 @@
1
- import { feeText, money, shortDate } from "../engine/text.js";
1
+ import { offerTerms } from "../engine/offers.js";
2
+ import { capitalize, feeText, money, shortDate, wholeDollars, } from "../engine/text.js";
2
3
  const TOP = 10;
3
- function reward(o, currencyName) {
4
- const amount = o.dollars !== null
5
- ? money(o.dollars)
6
- : `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
7
- return o.varies ? `Up to ${amount}` : amount;
8
- }
9
4
  export function formatOffers(c, currencyName, all) {
10
5
  if (c.offers.length === 0) {
11
6
  return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
@@ -15,19 +10,19 @@ export function formatOffers(c, currencyName, all) {
15
10
  const lines = [];
16
11
  shown.forEach((o, i) => {
17
12
  const weeks = (o.months * 52) / 12;
18
- const pace = o.spend > 0 ? ` (about ${money(Math.round(o.spend / weeks))} a week)` : '';
19
- const spend = o.spend > 0
20
- ? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
21
- : 'with no spending required';
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.`);
13
+ const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
14
+ lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
15
+ lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
24
16
  if (o.ineligible)
25
17
  lines.push(` ${o.ineligible}`);
26
18
  if (o.note)
27
19
  lines.push(` ${o.note}`);
28
20
  });
29
- if (c.offers.length > shown.length)
30
- lines.push(` + ${String(c.offers.length - shown.length)} more (--all lists them)`);
21
+ const ruledOut = c.offers.filter((o) => o.ineligible !== null && !shown.includes(o));
22
+ if (ruledOut.length > 0)
23
+ lines.push(` ${String(ruledOut.length)} more left out as likely not eligible with a card you hold: ${ruledOut.map((o) => o.cardName).join(', ')}.`);
24
+ if (c.offers.length > shown.length + ruledOut.length)
25
+ lines.push(` + ${String(c.offers.length - shown.length - ruledOut.length)} more (--all lists them)`);
31
26
  if (c.withoutOffer.length > 0)
32
27
  lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
33
28
  lines.push('', ...c.notes);
@@ -1,7 +1,7 @@
1
- import { money, pct } from "../engine/text.js";
1
+ import { capitalize, dollarsAndCents, money, pct } from "../engine/text.js";
2
+ import { noCardText } from "../engine/recommend.js";
2
3
  export const plain = (_format, text) => text;
3
4
  const TOP = 3;
4
- const cents = (n) => `$${n.toFixed(2)}`;
5
5
  const label = (c) => c.checkoutLabel ?? c.cardName;
6
6
  function header(rec, purchase) {
7
7
  const m = rec.merchant;
@@ -30,7 +30,7 @@ function cardLines(cards, style, bonusPick) {
30
30
  const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
31
31
  const lines = [];
32
32
  cards.forEach((c) => {
33
- const earned = c.earned === null ? '' : ` ${cents(c.earned)}`;
33
+ const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`;
34
34
  const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
35
35
  lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
36
36
  for (const r of c.reasoning)
@@ -50,12 +50,12 @@ function excludedLines(excluded) {
50
50
  export function formatRecommendation(rec, purchase, { all = false, style = plain, notices = [], } = {}) {
51
51
  const lines = [header(rec, purchase), ''];
52
52
  const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId) ?? rec.ranked[0];
53
- const noCard = `None of your cards work at ${rec.merchant.resolvedName}.`;
53
+ const noCard = noCardText(rec.merchant.resolvedName);
54
54
  if (rec.decision && top) {
55
- const earned = top.earned === null ? '' : `, about ${cents(top.earned)}`;
55
+ const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
56
56
  const confidence = rec.decision.confidence;
57
57
  const why = rec.bonus ? ' for its welcome bonus' : '';
58
- lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${confidence[0]?.toUpperCase() ?? ''}${confidence.slice(1)} confidence.`);
58
+ lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${capitalize(confidence)} confidence.`);
59
59
  if (rec.bonus && rec.notes[0])
60
60
  lines.push(rec.notes[0]);
61
61
  }
@@ -40,9 +40,12 @@ export function formatWalletSummary(summary) {
40
40
  return lines.join('\n');
41
41
  });
42
42
  const memberships = summary.memberships.length > 0
43
- ? `Memberships: ${summary.memberships.join(', ')}.`
43
+ ? `Memberships: ${summary.memberships.map((id, i) => `${summary.membershipNames[i] ?? id} (${id})`).join(', ')}.`
44
44
  : 'Memberships: none saved.';
45
- return [...cards, memberships].join('\n');
45
+ const values = summary.pointValues.length > 0
46
+ ? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
47
+ : "Point values: earnrate's defaults.";
48
+ return [...cards, memberships, values].join('\n');
46
49
  }
47
50
  export function formatBooking(amount, cardId, cap, bonus) {
48
51
  const messages = [];
@@ -1,4 +1,5 @@
1
1
  import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
2
+ import { HALF_CENT } from "../engine/compare.js";
2
3
  import { capByKey, categoryMatches } from "../engine/evaluate.js";
3
4
  import { periodKey } from "../engine/periods.js";
4
5
  import { recommend } from "../engine/recommend.js";
@@ -155,7 +156,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
155
156
  if (topBooking)
156
157
  simulated = applyBooking(simulated, topBooking);
157
158
  const gain = (top.earned ?? 0) - (used.earned ?? 0);
158
- if (top.cardId !== cardId && gain > 0.005) {
159
+ if (top.cardId !== cardId && gain > HALF_CENT) {
159
160
  const key = `${name}|${cardId}|${top.cardId}`;
160
161
  const miss = missed.get(key) ?? {
161
162
  merchant: name,
@@ -1,7 +1,8 @@
1
1
  import { walletProblems } from "../data/integrity.js";
2
2
  import { EMPTY_WALLET, walletFromCards } from "../engine/transient.js";
3
+ import { capitalize } from "../engine/text.js";
3
4
  import { formatNotes } from "../format/cards.js";
4
- import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
5
+ import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
5
6
  import { defined } from "./inputs.js";
6
7
  export const fail = (text) => ({
7
8
  content: [{ type: 'text', text }],
@@ -15,9 +16,22 @@ export function makeContext(deps) {
15
16
  const theWallet = deps.savedWallets === 'file'
16
17
  ? `the wallet at ${deps.store.where}`
17
18
  : 'the saved wallet';
19
+ // Which wallet this server keeps, for replies and tool descriptions. The
20
+ // local extension and the hosted connector can both be connected at once,
21
+ // each with its own wallet, and their tools are otherwise identical.
22
+ const walletName = deps.savedWallets === 'file'
23
+ ? `the wallet file on this computer (${deps.store.where})`
24
+ : 'your hosted earnrate wallet';
25
+ const walletUse = deps.savedWallets === 'file'
26
+ ? "It uses the wallet file on this computer, not the hosted earnrate connector's wallet."
27
+ : "It uses the user's hosted earnrate wallet, not a wallet file on their computer.";
28
+ const startHint = 'Answers cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.';
29
+ const noCards = deps.savedWallets === 'file'
30
+ ? `The wallet at ${deps.store.where} has no cards yet. ${startHint}`
31
+ : `The user's saved wallet has no cards yet. ${startHint}`;
18
32
  const noWallet = deps.savedWallets === 'file'
19
33
  ? `There's no saved wallet yet (${deps.store.where}). Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`
20
- : "The user's saved wallet has no cards yet. Answers cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.";
34
+ : noCards;
21
35
  // Read on every call, so edits to the wallet apply without a restart.
22
36
  async function wallet() {
23
37
  let loaded;
@@ -32,21 +46,21 @@ export function makeContext(deps) {
32
46
  }
33
47
  const problems = walletProblems(loaded, catalog);
34
48
  return problems.length > 0
35
- ? `${theWallet[0]?.toUpperCase() ?? ''}${theWallet.slice(1)} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
49
+ ? `${capitalize(theWallet)} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
36
50
  : loaded;
37
51
  }
38
- // No saved wallet is the normal state, not a failure: it reads as an
39
- // empty one, with a note on how to start one.
52
+ // No saved wallet, or one with no cards, is the normal state, not a
53
+ // failure: it reads as an empty one, with a note on how to add cards.
40
54
  async function readableWallet() {
41
55
  const w = await wallet();
42
56
  if (typeof w !== 'string')
43
- return { wallet: w, start: null };
57
+ return { wallet: w, start: w.cards.length === 0 ? noCards : null };
44
58
  return w === noWallet ? { wallet: EMPTY_WALLET, start: w } : w;
45
59
  }
46
60
  // The caller's category is free and comes before the paid Claude fallback;
47
61
  // neither ever overrides a merchant earnrate already knows.
48
62
  async function resolve(merchant, channel, category) {
49
- const known = resolveMerchant(catalog, merchant, channel);
63
+ const known = resolvePlace(catalog, merchant, channel);
50
64
  if (known.source !== 'none' || category !== null || !deps.llm) {
51
65
  return { merchant: withCallerCategory(known, category), notices: [] };
52
66
  }
@@ -54,15 +68,34 @@ export function makeContext(deps) {
54
68
  }
55
69
  // Cards named in the conversation come first, then the saved wallet; with
56
70
  // neither (null), answers cover every card earnrate knows.
71
+ // The point values and memberships the user saved apply to every answer,
72
+ // not just ones from their saved cards. A wallet that can't be read has
73
+ // none.
74
+ async function saved() {
75
+ try {
76
+ const wallet = await deps.store.load();
77
+ return {
78
+ valuations: wallet?.valuations ?? {},
79
+ memberships: wallet?.memberships ?? [],
80
+ };
81
+ }
82
+ catch {
83
+ return { valuations: {}, memberships: [] };
84
+ }
85
+ }
57
86
  async function cardsFor(cards, memberships) {
58
- if (cards && cards.length > 0)
59
- return walletFromCards(catalog, {
87
+ if (cards && cards.length > 0) {
88
+ const named = walletFromCards(catalog, {
60
89
  cards: cards.map(({ cardId, ...rest }) => ({
61
90
  id: cardId,
62
91
  ...defined(rest),
63
92
  })),
64
- ...(memberships ? { memberships } : {}),
93
+ memberships: memberships ?? (await saved()).memberships,
65
94
  }, deps.today());
95
+ return typeof named === 'string'
96
+ ? named
97
+ : { ...named, valuations: (await saved()).valuations };
98
+ }
66
99
  const read = await readableWallet();
67
100
  if (typeof read === 'string')
68
101
  return read;
@@ -82,8 +115,8 @@ export function makeContext(deps) {
82
115
  const text = [
83
116
  message,
84
117
  output.cardIds.length > 0
85
- ? `Wallet: ${output.cardIds.join(', ')}.`
86
- : 'The wallet has no cards now; answers cover every card.',
118
+ ? `Saved in ${walletName}, which has ${String(output.cardIds.length)} ${output.cardIds.length === 1 ? 'card' : 'cards'}.`
119
+ : `Saved in ${walletName}, which has no cards now; answers cover every card.`,
87
120
  ...(notes ? [formatNotes(result.notes)] : []),
88
121
  ].join('\n');
89
122
  return {
@@ -92,5 +125,20 @@ export function makeContext(deps) {
92
125
  };
93
126
  }
94
127
  const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
95
- return { wallet, readableWallet, resolve, cardsFor, changed, cardName };
128
+ // The first line of an answer from the user's own cards: which ones.
129
+ const fromCards = (named) => named !== undefined && named.length > 0
130
+ ? 'For the cards named in this conversation.\n'
131
+ : `From ${walletName}.\n`;
132
+ return {
133
+ walletName,
134
+ fromCards,
135
+ walletUse,
136
+ wallet,
137
+ readableWallet,
138
+ resolve,
139
+ cardsFor,
140
+ saved,
141
+ changed,
142
+ cardName,
143
+ };
96
144
  }
@@ -1,12 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
3
- import { VALUATION_PROFILES } from "../engine/profiles.js";
3
+ import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
4
4
  import { knownMemberships } from "../engine/setup.js";
5
5
  import { IsoDate } from "../schema/common.js";
6
6
  export const Merchant = z
7
7
  .string()
8
8
  .min(1)
9
- .describe('Merchant name or web address as the user would say it or see it, e.g. "Whole Foods" or "delta.com".');
9
+ .describe('Merchant name or web address as the user would say it or see it, e.g. "Whole Foods" or "delta.com", or a kind of place, e.g. "restaurants" or "gas station".');
10
10
  export const ChannelInput = z
11
11
  .enum(['online', 'in_store'])
12
12
  .optional()
@@ -112,6 +112,8 @@ export function makeInputs(catalog) {
112
112
  .enum(categoryIds)
113
113
  .optional()
114
114
  .describe(`Only for a merchant earnrate may not know, and only if you're confident what kind of business it is: its spending category, e.g. grocery.supermarket for a supermarket, dining for a restaurant, gas for a gas station. Ignored when earnrate knows the merchant. Leave it out if unsure. Categories: ${categoryList}.`);
115
+ // record_purchase can't skip an unknown merchant's caps, so it asks.
116
+ const RecordCategoryInput = CategoryInput.describe(`For a merchant earnrate doesn't know, its spending category, e.g. dining for a restaurant: without one the purchase isn't recorded, since its caps can't be told. If nobody knows, other records it toward a welcome bonus without touching any cap. Ignored when earnrate knows the merchant. Categories: ${categoryList}.`);
115
117
  const CatalogCategory = z
116
118
  .enum(categoryIds)
117
119
  .describe(`A spending category: ${categoryList}.`);
@@ -128,11 +130,12 @@ export function makeInputs(catalog) {
128
130
  valuation: z
129
131
  .enum(['flat', 'points-heavy'])
130
132
  .optional()
131
- .describe('flat (default): points at their cash-out value, about 1¢. points-heavy: Amex MR and Chase UR at 1.7¢, for people who transfer points to airlines and hotels.'),
133
+ .describe(`Instead of the user's saved point values (or earnrate's defaults): flat, points at their cash-out value, ${profileText('flat')}; or points-heavy, ${profileText('points-heavy')}, for people who transfer points to airlines and hotels.`),
132
134
  };
133
135
  const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
134
136
  return {
135
137
  CategoryInput,
138
+ RecordCategoryInput,
136
139
  CatalogCategory,
137
140
  NamedCards,
138
141
  NamedMemberships,
@@ -141,8 +144,12 @@ export function makeInputs(catalog) {
141
144
  AllMemberships,
142
145
  };
143
146
  }
144
- // The catalog filters as exploreCards and simulate take them.
145
- export const settings = (args) => ({
147
+ // The catalog filters as exploreCards and simulate take them. Memberships or
148
+ // a valuation profile asked for win over the user's saved ones.
149
+ export const settings = (args, saved = {
150
+ valuations: {},
151
+ memberships: [],
152
+ }) => ({
146
153
  filters: {
147
154
  ...ALL_PERSONAL_CARDS,
148
155
  kind: args.kind ?? 'any',
@@ -150,6 +157,8 @@ export const settings = (args) => ({
150
157
  business: args.business ?? false,
151
158
  issuer: args.issuer ?? null,
152
159
  },
153
- memberships: args.memberships ?? [],
154
- valuations: args.valuation ? VALUATION_PROFILES[args.valuation] : {},
160
+ memberships: args.memberships ?? saved.memberships,
161
+ valuations: args.valuation
162
+ ? VALUATION_PROFILES[args.valuation]
163
+ : saved.valuations,
155
164
  });
@@ -78,6 +78,14 @@ const RecommendationSchema = z.object({
78
78
  });
79
79
  export const WalletSummarySchema = z.object({
80
80
  memberships: z.array(z.string()),
81
+ membershipNames: z.array(z.string()),
82
+ pointValues: z.array(z.object({
83
+ currency: z.string(),
84
+ name: z.string(),
85
+ centsPerUnit: z.number(),
86
+ redemption: z.string().nullable(),
87
+ })),
88
+ started: z.string().nullable(),
81
89
  cards: z.array(z.object({
82
90
  cardId: z.string(),
83
91
  cardName: z.string(),
@@ -99,6 +107,7 @@ export const WalletSummarySchema = z.object({
99
107
  })
100
108
  .nullable(),
101
109
  currency: z.string(),
110
+ currencyName: z.string(),
102
111
  centsPerUnit: z.number(),
103
112
  lastVerified: z.string(),
104
113
  verifiedBy: z.string(),
@@ -23,6 +23,7 @@ export const SAVED_WALLET_TOOLS = [
23
23
  'update_card',
24
24
  'remove_card',
25
25
  'set_memberships',
26
+ 'set_point_values',
26
27
  'export_wallet',
27
28
  'import_wallet',
28
29
  'delete_wallet',
@@ -32,11 +33,17 @@ const SAVING = {
32
33
  file: 'Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools.',
33
34
  account: "The user signed in to earnrate when they connected it, to an anonymous saved wallet (no email or name). It may have no cards yet; answers then cover every card. Save cards with add_card only when the user agrees. The wallet can change outside this conversation when the user signs in again on earnrate's page, so read it with list_wallet rather than from memory. To use a different wallet or start a new one, call switch_wallet; the user then connects on earnrate's page with that wallet's recovery code. Never ask for the recovery code in the conversation; it's typed only on earnrate's page. To change the code, the user opens https://mcp.earnrate.dev/change-code. earnrate.dev and the earnrate command line keep their own separate wallets; export_wallet and import_wallet move one between them.",
34
35
  };
36
+ const WHICH_WALLET = {
37
+ file: "This server's saved wallet is a file on the user's computer.",
38
+ account: "This server's saved wallet is hosted by earnrate, not a file on the user's computer.",
39
+ };
35
40
  const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
36
41
 
37
42
  Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
38
43
 
39
- The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.`;
44
+ The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
45
+
46
+ ${WHICH_WALLET[savedWallets]} The earnrate extension on the user's computer and the hosted earnrate connector keep separate wallets with the same tools. If both are connected, say which wallet an answer comes from, and ask the user which one to use before saving or recording anything.`;
40
47
  export function createEarnrateServer(deps) {
41
48
  const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: instructions(deps.savedWallets) });
42
49
  const ctx = makeContext(deps);
@@ -1,16 +1,33 @@
1
1
  import { z } from 'zod';
2
+ import { emptyWallet } from "../../data/store.js";
2
3
  import { parseWalletYaml, walletYaml } from "../../format/wallet-yaml.js";
4
+ import { listed } from "../../engine/text.js";
5
+ import { savedSettingsText } from "../../engine/wallet-summary.js";
3
6
  import { fail } from "../context.js";
4
7
  import { WalletChangeSchema } from "../schemas.js";
5
8
  // A saved wallet on the hosted server has no file to copy or delete, so
6
9
  // these do it.
7
10
  export function registerAccountTools(server, catalog, store, ctx) {
11
+ const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
12
+ // Everything a saved wallet holds, in words; empty when there's nothing.
13
+ const contents = (w) => [
14
+ ...(w.cards.length > 0
15
+ ? [
16
+ `${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`,
17
+ ]
18
+ : []),
19
+ ...savedSettingsText(catalog, w),
20
+ ];
8
21
  server.registerTool('export_wallet', {
9
22
  title: 'Export the wallet',
10
- description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, cap usage and bonus progress. Never card numbers.',
23
+ description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage and bonus progress. Never card numbers.',
11
24
  inputSchema: z.object({}),
12
25
  outputSchema: z.object({ yaml: z.string() }),
13
- annotations: { readOnlyHint: true, openWorldHint: false },
26
+ annotations: {
27
+ title: 'Export the wallet',
28
+ readOnlyHint: true,
29
+ openWorldHint: false,
30
+ },
14
31
  }, async () => {
15
32
  const w = await ctx.wallet();
16
33
  if (typeof w === 'string')
@@ -23,7 +40,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
23
40
  });
24
41
  server.registerTool('import_wallet', {
25
42
  title: 'Import a wallet',
26
- description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If cards are already saved, it refuses unless replace is true: confirm with the user first.',
43
+ description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If anything is already saved (cards, memberships or point values), it refuses unless replace is true: confirm with the user first.',
27
44
  inputSchema: z.object({
28
45
  yaml: z
29
46
  .string()
@@ -33,10 +50,11 @@ export function registerAccountTools(server, catalog, store, ctx) {
33
50
  replace: z
34
51
  .boolean()
35
52
  .optional()
36
- .describe('true to replace a wallet that already has cards, after the user confirms.'),
53
+ .describe('true to replace what the wallet already holds, after the user confirms.'),
37
54
  }),
38
55
  outputSchema: WalletChangeSchema,
39
56
  annotations: {
57
+ title: 'Import a wallet',
40
58
  readOnlyHint: false,
41
59
  destructiveHint: true,
42
60
  idempotentHint: true,
@@ -55,17 +73,16 @@ export function registerAccountTools(server, catalog, store, ctx) {
55
73
  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
74
  current = null;
57
75
  }
58
- if (current !== null && current.cards.length > 0 && replace !== true)
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.`);
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 });
76
+ const before = current === null ? [] : contents(current);
77
+ if (before.length > 0 && replace !== true)
78
+ return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
79
+ return ctx.changed(await store.replace(parsed), before.length > 0
80
+ ? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
81
+ : `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
65
82
  });
66
83
  server.registerTool('delete_wallet', {
67
84
  title: 'Delete the wallet',
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.",
85
+ 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, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
69
86
  inputSchema: z.object({
70
87
  confirm: z
71
88
  .literal(true)
@@ -75,20 +92,30 @@ export function registerAccountTools(server, catalog, store, ctx) {
75
92
  .int()
76
93
  .nonnegative()
77
94
  .describe('How many cards the wallet held when the user confirmed, from list_wallet: 0 for an empty wallet.'),
95
+ saved: z
96
+ .number()
97
+ .int()
98
+ .nonnegative()
99
+ .optional()
100
+ .describe('How many memberships and point values list_wallet showed, counted together: 0 if none.'),
78
101
  }),
79
102
  outputSchema: z.object({ deleted: z.boolean() }),
80
103
  annotations: {
104
+ title: 'Delete the wallet',
81
105
  readOnlyHint: false,
82
106
  destructiveHint: true,
83
107
  idempotentHint: true,
84
108
  openWorldHint: false,
85
109
  },
86
- }, async ({ cards }) => {
110
+ }, async ({ cards, saved }) => {
87
111
  // 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.`);
112
+ const loaded = await store.load().catch(() => undefined);
113
+ if (loaded !== undefined) {
114
+ const current = loaded ?? emptyWallet();
115
+ const settings = savedSettingsText(catalog, current);
116
+ if (current.cards.length !== cards || settings.length !== (saved ?? 0))
117
+ return fail(`This saved wallet holds ${listed(contents(current)) || 'nothing'}, not what the user confirmed (${cardsText(cards)}${saved === undefined ? '' : ` and ${String(saved)} memberships and point values`}); it may have changed, or be a different wallet. Show it with list_wallet, confirm again, and pass cards: ${String(current.cards.length)} and saved: ${String(settings.length)}.`);
118
+ }
92
119
  await store.erase();
93
120
  return {
94
121
  content: [
@@ -106,6 +133,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
106
133
  inputSchema: z.object({}),
107
134
  outputSchema: z.object({ disconnected: z.boolean() }),
108
135
  annotations: {
136
+ title: 'Switch wallets',
109
137
  readOnlyHint: false,
110
138
  destructiveHint: false,
111
139
  idempotentHint: true,