earnrate 0.2.5 → 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 (105) hide show
  1. package/README.md +14 -11
  2. package/data/CHANGELOG.md +40 -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 +19 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
  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 +15 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +8 -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 +16 -3
  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 +5 -2
  47. package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
  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 +12 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +19 -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/issuers.yaml +32 -0
  61. package/data/merchants/seed.yaml +7 -0
  62. package/dist/cli/bin.js +10 -1
  63. package/dist/cli/cheatsheet.js +10 -5
  64. package/dist/cli/explore.js +6 -13
  65. package/dist/cli/import.js +2 -2
  66. package/dist/cli/offers.js +17 -7
  67. package/dist/cli/run.js +14 -17
  68. package/dist/cli/wallet.js +8 -5
  69. package/dist/data/file-store.js +6 -7
  70. package/dist/data/integrity.js +4 -0
  71. package/dist/data/load.js +4 -1
  72. package/dist/data/store.js +6 -22
  73. package/dist/data/wallet-edit.js +38 -13
  74. package/dist/engine/cheat-sheet.js +4 -5
  75. package/dist/engine/compare.js +2 -0
  76. package/dist/engine/evaluate.js +2 -4
  77. package/dist/engine/explore.js +4 -36
  78. package/dist/engine/memberships.js +1 -1
  79. package/dist/engine/offers.js +2 -1
  80. package/dist/engine/periods.js +18 -0
  81. package/dist/engine/recommend.js +14 -15
  82. package/dist/engine/setup.js +14 -24
  83. package/dist/engine/simulate.js +7 -6
  84. package/dist/engine/text.js +13 -1
  85. package/dist/engine/wallet-change.js +10 -12
  86. package/dist/engine/wallet-summary.js +64 -28
  87. package/dist/format/card-text.js +7 -20
  88. package/dist/format/explore.js +2 -2
  89. package/dist/format/offers.js +2 -2
  90. package/dist/format/recommendation.js +2 -2
  91. package/dist/format/wallet.js +1 -1
  92. package/dist/import/analyze.js +2 -1
  93. package/dist/mcp/context.js +30 -9
  94. package/dist/mcp/inputs.js +1 -1
  95. package/dist/mcp/schemas.js +1 -0
  96. package/dist/mcp/server.js +7 -1
  97. package/dist/mcp/tools/account.js +44 -16
  98. package/dist/mcp/tools/answers.js +58 -19
  99. package/dist/mcp/tools/wallet.js +58 -24
  100. package/dist/merchants/cache.js +0 -10
  101. package/dist/merchants/resolve.js +81 -4
  102. package/dist/schema/card.js +4 -1
  103. package/dist/schema/issuer.js +8 -0
  104. package/dist/schema/wallet.js +1 -1
  105. package/package.json +1 -1
@@ -3,15 +3,15 @@ import { addMonths } from "./bonus.js";
3
3
  import { capByKey } from "./evaluate.js";
4
4
  import { choicePeriod, periodKey } from "./periods.js";
5
5
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
6
+ import { idList } from "./text.js";
6
7
  const failed = (error) => ({ ok: false, error });
7
- const list = (ids) => ids.join(', ');
8
8
  function findCatalogCard(catalog, id, today) {
9
9
  const card = catalog.cards.find((c) => c.id === id);
10
10
  if (card)
11
11
  return card;
12
12
  const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
13
13
  return near.length > 0
14
- ? `No card '${id}' in earnrate. Closest: ${list(near)}.`
14
+ ? `No card '${id}' in earnrate. Closest: ${idList(near)}.`
15
15
  : `No card '${id}' in earnrate. Search the catalog to find its id.`;
16
16
  }
17
17
  function isAnniversary(text) {
@@ -51,7 +51,7 @@ export function cardFields(card, held, input, today) {
51
51
  for (const id of input.activated) {
52
52
  if (!needing.includes(id)) {
53
53
  problems.push(needing.length > 0
54
- ? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
54
+ ? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${idList(needing)}.`
55
55
  : `${card.id} has no rules that need activating.`);
56
56
  }
57
57
  }
@@ -62,14 +62,14 @@ export function cardFields(card, held, input, today) {
62
62
  const choice = card.choices.find((c) => c.id === choiceId);
63
63
  if (!choice) {
64
64
  problems.push(card.choices.length > 0
65
- ? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
65
+ ? `${card.id} has no '${choiceId}' pick. Its picks are: ${idList(card.choices.map((c) => c.id))}.`
66
66
  : `${card.id} has no categories to pick.`);
67
67
  continue;
68
68
  }
69
69
  const options = Object.keys(choice.options);
70
70
  for (const p of picks) {
71
71
  if (!options.includes(p)) {
72
- problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
72
+ problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${idList(options)}.`);
73
73
  }
74
74
  }
75
75
  if (picks.length === 0 || picks.length > choice.pick) {
@@ -101,7 +101,7 @@ export function cardFields(card, held, input, today) {
101
101
  .map((r) => r.id),
102
102
  ];
103
103
  problems.push(keys.length > 0
104
- ? `${card.id} has no cap '${key}'. Its caps are: ${list(keys)}.`
104
+ ? `${card.id} has no cap '${key}'. Its caps are: ${idList(keys)}.`
105
105
  : `${card.id} has no spending caps.`);
106
106
  continue;
107
107
  }
@@ -206,7 +206,7 @@ export function updateInWallet(catalog, wallet, input, today) {
206
206
  const held = wallet.cards.find((c) => c.id === input.id);
207
207
  const card = catalog.cards.find((c) => c.id === input.id);
208
208
  if (!held || !card) {
209
- return failed(`${input.id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
209
+ return failed(`${input.id} isn't in the wallet. Cards: ${idList(wallet.cards.map((c) => c.id))}.`);
210
210
  }
211
211
  const fields = cardFields(card, held, input, today);
212
212
  if (typeof fields === 'string')
@@ -227,10 +227,8 @@ export function updateInWallet(catalog, wallet, input, today) {
227
227
  }
228
228
  export function removeFromWallet(wallet, id) {
229
229
  if (!wallet.cards.some((c) => c.id === id)) {
230
- return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
230
+ return failed(`${id} isn't in the wallet. Cards: ${idList(wallet.cards.map((c) => c.id))}.`);
231
231
  }
232
- // Removing the last card leaves an empty wallet, which each store keeps
233
- // in its own way (or refuses, for a wallet file).
234
232
  return {
235
233
  ok: true,
236
234
  wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
@@ -242,7 +240,7 @@ export function withMemberships(catalog, wallet, memberships) {
242
240
  const known = knownMemberships(catalog);
243
241
  const unknown = memberships.filter((m) => !known.includes(m));
244
242
  if (unknown.length > 0) {
245
- return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
243
+ return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${idList(known)}.`);
246
244
  }
247
245
  return {
248
246
  ok: true,
@@ -256,7 +254,7 @@ export function withMemberships(catalog, wallet, memberships) {
256
254
  export function withValuations(catalog, wallet, changes) {
257
255
  const unknown = Object.keys(changes).filter((id) => !catalog.currencies.some((c) => c.id === id));
258
256
  if (unknown.length > 0)
259
- return failed(`Unknown currency ${unknown.map((id) => `'${id}'`).join(', ')}. Currencies: ${list(catalog.currencies.map((c) => c.id))}.`);
257
+ return failed(`Unknown currency ${unknown.map((id) => `'${id}'`).join(', ')}. Currencies: ${idList(catalog.currencies.map((c) => c.id))}.`);
260
258
  const odd = Object.entries(changes).filter(([, cents]) => cents !== null && !(cents > 0 && cents <= MAX_CENTS));
261
259
  if (odd.length > 0)
262
260
  return failed(`A point is worth more than 0¢ and at most ${String(MAX_CENTS)}¢; check ${odd.map(([id]) => id).join(', ')}.`);
@@ -1,17 +1,18 @@
1
+ import { findMerchant } from "../merchants/resolve.js";
1
2
  import { bonusStatus } from "./bonus.js";
2
3
  import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
3
- import { daysBetween } from "./periods.js";
4
+ import { membershipName } from "./memberships.js";
5
+ import { daysBetween, inEffect, periodEnd } from "./periods.js";
4
6
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
7
  import { rotationGap } from "./rotation.js";
6
8
  import { activationRules } from "./setup.js";
7
- import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
9
+ import { CAP_PERIODS, categoryPhrase, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
8
10
  import { cardValuation, currencyValuation } from "./valuation.js";
9
11
  // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
10
12
  // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
11
13
  const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
12
14
  function merchantName(catalog, id) {
13
- const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
14
- const merchant = all.find((m) => m.id === id);
15
+ const merchant = findMerchant(catalog, id);
15
16
  const name = merchant?.name ?? words(id);
16
17
  return merchant?.kind === 'booking'
17
18
  ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
@@ -33,30 +34,22 @@ export function optionName(catalog, card, choiceId, option) {
33
34
  function ruleParts(catalog, rule) {
34
35
  if (rule.match.choice !== undefined)
35
36
  return [`your ${choiceName(rule.match.choice)} picks`];
37
+ const merchants = rule.match.merchants ?? [];
38
+ // A long list of one kind of store reads as "select streaming services".
39
+ const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
40
+ const [kind] = kinds;
41
+ const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
42
+ ? [`select ${categoryPhrase(catalog, kind)}`]
43
+ : merchants.map((m) => merchantName(catalog, m));
36
44
  const parts = [
37
45
  ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
38
- ...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
46
+ ...merchantParts,
39
47
  ];
40
48
  return parts.length > 0 ? parts : [words(rule.id)];
41
49
  }
42
50
  function ruleWhat(catalog, rule) {
43
51
  return listed(ruleParts(catalog, rule));
44
52
  }
45
- function periodEnd(period) {
46
- const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
47
- if (quarter) {
48
- const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
49
- return `${quarter[1] ?? ''}-${end ?? ''}`;
50
- }
51
- const month = /^(\d{4})-(\d{2})$/.exec(period);
52
- if (month) {
53
- const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
54
- return last.toISOString().slice(0, 10);
55
- }
56
- return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
57
- }
58
- const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
59
- (rule.valid_to === undefined || rule.valid_to >= today);
60
53
  const matchKeys = (rule) => rule.match.choice !== undefined
61
54
  ? []
62
55
  : [
@@ -68,9 +61,22 @@ const matchKeys = (rule) => rule.match.choice !== undefined
68
61
  // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
69
62
  // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
70
63
  // a line.
64
+ // "first $1,500 a quarter, combined": how much of a rate's spending earns it.
65
+ function capLimit(card, shown, rule) {
66
+ if (rule.cap === undefined)
67
+ return '';
68
+ const cap = typeof rule.cap === 'string'
69
+ ? card.caps.find((c) => c.id === rule.cap)
70
+ : rule.cap;
71
+ if (!cap)
72
+ return '';
73
+ const shared = typeof rule.cap === 'string' &&
74
+ shown.filter((r) => r.cap === rule.cap).length > 1;
75
+ return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
76
+ }
71
77
  function headlineRates(catalog, wallet, card, held, currency, today) {
72
78
  const shown = card.rules
73
- .filter((r) => applies(r, today) &&
79
+ .filter((r) => inEffect(r, today) &&
74
80
  r.rate > card.base_rate &&
75
81
  (r.requires?.membership === undefined ||
76
82
  wallet.memberships.includes(r.requires.membership)))
@@ -86,8 +92,11 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
86
92
  if (keys.length > 0 && keys.every((k) => higher.includes(k)))
87
93
  continue;
88
94
  const pending = r.requires?.activation && !isActivated(card, held, r.id);
89
- const head = `${earnText(catalog, r.rate, currency)} on`;
90
- const key = pending ? `${head}|activate` : head;
95
+ const key = [
96
+ `${earnText(catalog, r.rate, currency)} on`,
97
+ capLimit(card, shown, r),
98
+ pending ? ', once activated' : '',
99
+ ].join('|');
91
100
  const whats = lines.get(key) ?? [];
92
101
  for (const part of ruleParts(catalog, r))
93
102
  if (!whats.includes(part))
@@ -95,7 +104,11 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
95
104
  lines.set(key, whats);
96
105
  }
97
106
  return [
98
- ...[...lines].map(([key, whats]) => `${key.replace('|activate', '')} ${listed(whats)}${key.endsWith('|activate') ? ' (once activated)' : ''}`),
107
+ ...[...lines].map(([key, whats]) => {
108
+ const [head = '', limit = '', activate = ''] = key.split('|');
109
+ const after = `${limit}${activate}`;
110
+ return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
111
+ }),
99
112
  `${earnText(catalog, card.base_rate, currency)} on everything else`,
100
113
  ];
101
114
  }
@@ -110,9 +123,14 @@ function toActivate(catalog, card, held, today) {
110
123
  seen.add(g);
111
124
  const rules = card.rules.filter((r) => group.includes(r.id));
112
125
  const first = rules[0];
126
+ const deadline = first?.activate_by === undefined
127
+ ? ''
128
+ : `, activate by ${shortDate(first.activate_by)}`;
113
129
  const when = first?.valid_from !== undefined && first.valid_to !== undefined
114
- ? ` (${first.valid_from} to ${first.valid_to})`
115
- : '';
130
+ ? ` (${shortDate(first.valid_from)} to ${shortDate(first.valid_to)}${deadline})`
131
+ : deadline === ''
132
+ ? ''
133
+ : ` (${deadline.slice(2)})`;
116
134
  out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
117
135
  }
118
136
  return out;
@@ -204,7 +222,16 @@ export function walletSummary(catalog, wallet, today) {
204
222
  anniversary: held.anniversary ?? null,
205
223
  });
206
224
  }
207
- const pointValues = Object.keys(wallet.valuations)
225
+ return {
226
+ memberships: wallet.memberships,
227
+ membershipNames: wallet.memberships.map((m) => membershipName(catalog, m)),
228
+ pointValues: pointValues(catalog, wallet),
229
+ started: null,
230
+ cards,
231
+ };
232
+ }
233
+ function pointValues(catalog, wallet) {
234
+ return Object.keys(wallet.valuations)
208
235
  .filter((id) => catalog.currencies.some((c) => c.id === id))
209
236
  .map((id) => ({
210
237
  currency: id,
@@ -212,5 +239,14 @@ export function walletSummary(catalog, wallet, today) {
212
239
  centsPerUnit: currencyValuation(catalog, wallet, id).centsPerUnit,
213
240
  redemption: redemptionOf(catalog, id, wallet.valuations[id]),
214
241
  }));
215
- return { memberships: wallet.memberships, pointValues, started: null, cards };
242
+ }
243
+ // The memberships and point values a wallet holds besides its cards, in
244
+ // words: what replacing or deleting it would lose.
245
+ export function savedSettingsText(catalog, wallet) {
246
+ return [
247
+ ...wallet.memberships.map((m) => membershipName(catalog, m)),
248
+ ...pointValues(catalog, wallet).map((v) => v.redemption === null
249
+ ? `${v.name} at ${String(v.centsPerUnit)}¢`
250
+ : `${v.name} redeemed ${v.redemption}`),
251
+ ];
216
252
  }
@@ -1,24 +1,10 @@
1
1
  import { earnText } from "../engine/text.js";
2
2
  // Card names, fees and requirements as earnrate.dev and its static search
3
3
  // pages show them. Node-free: the site bundles it.
4
- const ISSUERS = {
5
- amex: 'American Express',
6
- 'bank-of-america': 'Bank of America',
7
- 'capital-one': 'Capital One',
8
- chase: 'Chase',
9
- citi: 'Citi',
10
- column: 'Bilt',
11
- sofi: 'SoFi',
12
- 'td-bank': 'TD Bank',
13
- usbank: 'U.S. Bank',
14
- 'wells-fargo': 'Wells Fargo',
15
- };
16
- export { CAP_PERIODS, PICK_PERIODS, words } from "../engine/text.js";
17
4
  import { membershipName, membershipText } from "../engine/memberships.js";
18
5
  import { CAP_PERIODS, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
19
- // Whole dollars, for fees and caps.
20
- export const usd = wholeDollars;
21
- export const issuerName = (id) => ISSUERS[id] ?? id;
6
+ // An issuer's name as people know it, from data/issuers.yaml.
7
+ export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
22
8
  export function cardText(catalog) {
23
9
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
24
10
  function shortName(id) {
@@ -40,8 +26,8 @@ export function cardText(catalog) {
40
26
  return `No annual fee${needs}`;
41
27
  const first = card?.first_year_fee;
42
28
  if (first !== undefined && first !== fee)
43
- return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)${needs}`;
44
- return `${usd(fee)} a year${needs}`;
29
+ return `${wholeDollars(fee)} a year (${first === 0 ? 'waived' : wholeDollars(first)} the first year)${needs}`;
30
+ return `${wholeDollars(fee)} a year${needs}`;
45
31
  }
46
32
  // `until` is the last day a time-limited rate applies, already formatted.
47
33
  function requirementLines(cardId, r, until = null) {
@@ -49,13 +35,13 @@ export function cardText(catalog) {
49
35
  if (r.pick)
50
36
  lines.push(`Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
51
37
  if (r.activation !== null)
52
- lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
38
+ lines.push(`Activate the bonus with ${issuerName(catalog, cards.get(cardId)?.issuer ?? '')} (free)`);
53
39
  if (r.membership !== null)
54
40
  lines.push(`With ${membershipName(catalog, r.membership)}`);
55
41
  if (until !== null)
56
42
  lines.push(`This rate runs through ${until}`);
57
43
  if (r.cap)
58
- lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
44
+ lines.push(`Up to ${wholeDollars(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
59
45
  return lines;
60
46
  }
61
47
  const memberName = (id) => membershipName(catalog, id);
@@ -65,5 +51,6 @@ export function cardText(catalog) {
65
51
  feeLabel,
66
52
  requirementLines,
67
53
  membershipName: memberName,
54
+ issuerName: (id) => issuerName(catalog, id),
68
55
  };
69
56
  }
@@ -1,5 +1,5 @@
1
1
  import { membershipName, membershipNote, } from "../engine/memberships.js";
2
- import { choiceName, feeText, money, pct, wholeDollars, words, } from "../engine/text.js";
2
+ import { choiceName, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
3
3
  const membershipFees = (memberships) => memberships
4
4
  .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
5
5
  .join('');
@@ -17,7 +17,7 @@ export function formatExploration(r, catalog, all) {
17
17
  const width = Math.max(...shown.map((e) => e.cardName.length));
18
18
  const lines = [`Best cards for ${title}:`, ''];
19
19
  shown.forEach((e, i) => {
20
- const earned = e.earned === null ? '' : ` ${money(e.earned)}`;
20
+ const earned = e.earned === null ? '' : ` ${dollarsAndCents(e.earned)}`;
21
21
  lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
22
22
  for (const c of e.conditions)
23
23
  lines.push(` ${c}`);
@@ -1,5 +1,5 @@
1
1
  import { offerTerms } from "../engine/offers.js";
2
- import { feeText, money, shortDate, wholeDollars } from "../engine/text.js";
2
+ import { capitalize, feeText, money, shortDate, wholeDollars, } from "../engine/text.js";
3
3
  const TOP = 10;
4
4
  export function formatOffers(c, currencyName, all) {
5
5
  if (c.offers.length === 0) {
@@ -11,7 +11,7 @@ export function formatOffers(c, currencyName, all) {
11
11
  shown.forEach((o, i) => {
12
12
  const weeks = (o.months * 52) / 12;
13
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}. ${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.' : ''}`);
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
15
  lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
16
16
  if (o.ineligible)
17
17
  lines.push(` ${o.ineligible}`);
@@ -1,4 +1,4 @@
1
- import { dollarsAndCents, money, pct } from "../engine/text.js";
1
+ import { capitalize, dollarsAndCents, money, pct } from "../engine/text.js";
2
2
  import { noCardText } from "../engine/recommend.js";
3
3
  export const plain = (_format, text) => text;
4
4
  const TOP = 3;
@@ -55,7 +55,7 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
55
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,7 +40,7 @@ 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
45
  const values = summary.pointValues.length > 0
46
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.`
@@ -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
  }
@@ -101,8 +115,8 @@ export function makeContext(deps) {
101
115
  const text = [
102
116
  message,
103
117
  output.cardIds.length > 0
104
- ? `Wallet: ${output.cardIds.join(', ')}.`
105
- : '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.`,
106
120
  ...(notes ? [formatNotes(result.notes)] : []),
107
121
  ].join('\n');
108
122
  return {
@@ -111,7 +125,14 @@ export function makeContext(deps) {
111
125
  };
112
126
  }
113
127
  const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
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`;
114
132
  return {
133
+ walletName,
134
+ fromCards,
135
+ walletUse,
115
136
  wallet,
116
137
  readableWallet,
117
138
  resolve,
@@ -6,7 +6,7 @@ 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()
@@ -78,6 +78,7 @@ 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()),
81
82
  pointValues: z.array(z.object({
82
83
  currency: z.string(),
83
84
  name: z.string(),
@@ -33,11 +33,17 @@ const SAVING = {
33
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.',
34
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.",
35
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
+ };
36
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.
37
41
 
38
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.
39
43
 
40
- 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.`;
41
47
  export function createEarnrateServer(deps) {
42
48
  const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: instructions(deps.savedWallets) });
43
49
  const ctx = makeContext(deps);