earnrate 0.2.10 → 0.2.12

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 (130) hide show
  1. package/README.md +31 -11
  2. package/data/CHANGELOG.md +154 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +1 -0
  5. package/data/cards/amex-blue-cash-everyday.yaml +1 -0
  6. package/data/cards/amex-blue-cash-preferred.yaml +7 -0
  7. package/data/cards/amex-business-gold.yaml +134 -0
  8. package/data/cards/amex-business-platinum.yaml +115 -0
  9. package/data/cards/amex-delta-skymiles-blue.yaml +91 -0
  10. package/data/cards/amex-delta-skymiles-gold.yaml +122 -0
  11. package/data/cards/amex-delta-skymiles-platinum.yaml +120 -0
  12. package/data/cards/amex-delta-skymiles-reserve.yaml +82 -0
  13. package/data/cards/amex-gold.yaml +1 -0
  14. package/data/cards/amex-hilton-honors-aspire.yaml +142 -0
  15. package/data/cards/amex-hilton-honors-business.yaml +93 -0
  16. package/data/cards/amex-hilton-honors-surpass.yaml +153 -0
  17. package/data/cards/amex-hilton-honors.yaml +119 -0
  18. package/data/cards/amex-marriott-bonvoy-bevy.yaml +118 -0
  19. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +93 -0
  20. package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
  21. package/data/cards/amex-platinum.yaml +15 -0
  22. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +14 -0
  23. package/data/cards/bank-of-america-atmos-rewards-business.yaml +15 -0
  24. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +17 -0
  25. package/data/cards/bank-of-america-customized-cash-rewards.yaml +34 -1
  26. package/data/cards/bank-of-america-premium-rewards.yaml +134 -0
  27. package/data/cards/bank-of-america-travel-rewards.yaml +123 -0
  28. package/data/cards/bank-of-america-unlimited-cash-rewards.yaml +83 -0
  29. package/data/cards/barclays-jetblue-plus.yaml +86 -0
  30. package/data/cards/bilt-blue.yaml +60 -0
  31. package/data/cards/bilt-obsidian.yaml +1 -0
  32. package/data/cards/bilt-palladium.yaml +76 -0
  33. package/data/cards/capital-one-kohls-card.yaml +1 -0
  34. package/data/cards/capital-one-kohls-rewards-visa.yaml +1 -0
  35. package/data/cards/capital-one-quicksilver.yaml +82 -0
  36. package/data/cards/capital-one-savor.yaml +2 -0
  37. package/data/cards/capital-one-spark-cash-plus.yaml +80 -0
  38. package/data/cards/capital-one-venture-x-business.yaml +97 -0
  39. package/data/cards/capital-one-venture-x.yaml +2 -0
  40. package/data/cards/capital-one-venture.yaml +2 -0
  41. package/data/cards/chase-aeroplan.yaml +30 -0
  42. package/data/cards/chase-freedom-flex.yaml +1 -0
  43. package/data/cards/chase-freedom-rise.yaml +29 -0
  44. package/data/cards/chase-freedom-unlimited.yaml +1 -0
  45. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -0
  46. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +2 -0
  47. package/data/cards/chase-ihg-one-rewards-premier.yaml +1 -0
  48. package/data/cards/chase-ihg-one-rewards.yaml +1 -0
  49. package/data/cards/chase-ink-business-cash.yaml +1 -0
  50. package/data/cards/chase-ink-business-preferred.yaml +1 -0
  51. package/data/cards/chase-ink-business-unlimited.yaml +69 -0
  52. package/data/cards/chase-marriott-bonvoy-bold.yaml +124 -0
  53. package/data/cards/chase-marriott-bonvoy-boundless.yaml +78 -0
  54. package/data/cards/chase-prime-visa.yaml +1 -0
  55. package/data/cards/chase-sapphire-preferred.yaml +1 -0
  56. package/data/cards/chase-sapphire-reserve.yaml +30 -0
  57. package/data/cards/chase-southwest-performance-business.yaml +2 -0
  58. package/data/cards/chase-southwest-plus.yaml +2 -0
  59. package/data/cards/chase-southwest-premier-business.yaml +2 -0
  60. package/data/cards/chase-southwest-premier.yaml +2 -0
  61. package/data/cards/chase-southwest-priority.yaml +2 -0
  62. package/data/cards/chase-united-business.yaml +11 -0
  63. package/data/cards/chase-united-club-business.yaml +20 -0
  64. package/data/cards/chase-united-club.yaml +32 -0
  65. package/data/cards/chase-united-explorer.yaml +20 -0
  66. package/data/cards/chase-united-gateway.yaml +19 -0
  67. package/data/cards/chase-united-quest.yaml +20 -0
  68. package/data/cards/citi-aadvantage-business.yaml +14 -1
  69. package/data/cards/citi-aadvantage-executive.yaml +2 -0
  70. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  71. package/data/cards/citi-aadvantage-mileup.yaml +2 -0
  72. package/data/cards/citi-aadvantage-platinum-select.yaml +14 -1
  73. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  74. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  75. package/data/cards/citi-custom-cash.yaml +1 -0
  76. package/data/cards/citi-double-cash.yaml +1 -0
  77. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  78. package/data/cards/citi-strata-elite.yaml +1 -0
  79. package/data/cards/citi-strata-premier.yaml +1 -0
  80. package/data/cards/citi-strata.yaml +164 -0
  81. package/data/cards/discover-it-cash-back.yaml +188 -0
  82. package/data/cards/paypal-cashback-mastercard.yaml +41 -0
  83. package/data/cards/sams-club-mastercard.yaml +68 -0
  84. package/data/cards/sofi-unlimited-2.yaml +1 -0
  85. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  86. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  87. package/data/cards/td-bank-target-circle-card.yaml +1 -0
  88. package/data/cards/usbank-altitude-connect.yaml +150 -0
  89. package/data/cards/usbank-altitude-go.yaml +118 -0
  90. package/data/cards/usbank-cash-plus.yaml +1 -0
  91. package/data/cards/wells-fargo-active-cash.yaml +1 -0
  92. package/data/cards/wells-fargo-autograph-journey.yaml +73 -0
  93. package/data/cards/wells-fargo-autograph.yaml +1 -0
  94. package/data/categories.yaml +3 -1
  95. package/data/currencies.yaml +60 -0
  96. package/data/issuers.yaml +25 -1
  97. package/data/memberships.yaml +10 -0
  98. package/data/merchants/overrides.yaml +515 -1
  99. package/data/merchants/seed.yaml +883 -6
  100. package/dist/cli/import.js +2 -2
  101. package/dist/cli/wallet.js +8 -1
  102. package/dist/data/integrity.js +37 -0
  103. package/dist/data/wallet-edit.js +3 -1
  104. package/dist/engine/cheat-sheet.js +3 -0
  105. package/dist/engine/checklist.js +14 -3
  106. package/dist/engine/evaluate.js +19 -7
  107. package/dist/engine/intro.js +36 -0
  108. package/dist/engine/recommend.js +41 -10
  109. package/dist/engine/record.js +2 -0
  110. package/dist/engine/rule-text.js +15 -1
  111. package/dist/engine/setup.js +6 -0
  112. package/dist/engine/text.js +23 -0
  113. package/dist/engine/wallet-change.js +20 -3
  114. package/dist/engine/wallet-summary.js +24 -4
  115. package/dist/format/card-art.js +21 -0
  116. package/dist/format/cards.js +1 -0
  117. package/dist/format/wallet-yaml.js +1 -0
  118. package/dist/format/wallet.js +10 -4
  119. package/dist/import/analyze.js +1 -0
  120. package/dist/mcp/inputs.js +3 -0
  121. package/dist/mcp/prompts.js +90 -0
  122. package/dist/mcp/schemas.js +17 -0
  123. package/dist/mcp/server.js +2 -0
  124. package/dist/mcp/tools/account.js +8 -5
  125. package/dist/mcp/tools/answers.js +7 -0
  126. package/dist/mcp/tools/wallet.js +7 -2
  127. package/dist/schema/card.js +23 -0
  128. package/dist/schema/issuer.js +4 -0
  129. package/dist/schema/wallet.js +3 -0
  130. package/package.json +12 -3
@@ -1,13 +1,14 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
2
  import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
3
+ import { introUntil, introWindows, openedOn } from "./intro.js";
3
4
  import { membershipName } from "./memberships.js";
4
5
  import { milestoneSummaries } from "./milestones.js";
5
- import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
6
+ import { optionName, ruleParts } from "./rule-text.js";
6
7
  import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
7
8
  import { STALE_AFTER_DAYS } from "./recommend.js";
8
9
  import { rotationGap } from "./rotation.js";
9
10
  import { activationRules } from "./setup.js";
10
- import { CAP_PERIODS, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
11
+ import { CAP_PERIODS, categoryReadsOn, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
11
12
  import { cardValuation, currencyValuation } from "./valuation.js";
12
13
  const matchKeys = (rule) => rule.match.choice !== undefined
13
14
  ? []
@@ -98,8 +99,13 @@ export function activationsDue(catalog, card, held, today) {
98
99
  seen.add(g);
99
100
  const rules = card.rules.filter((r) => group.includes(r.id));
100
101
  const first = rules[0];
102
+ const categories = rules.flatMap((r) => r.match.categories ?? []);
103
+ const servicesOnly = categories.length > 0 &&
104
+ rules.every((r) => (r.match.merchants ?? []).length === 0) &&
105
+ categories.every((c) => categoryReadsOn(catalog, c));
101
106
  out.push({
102
- what: rules.map((r) => ruleWhat(catalog, r)).join(', '),
107
+ what: listed([...new Set(rules.flatMap((r) => ruleParts(catalog, r)))]),
108
+ preposition: servicesOnly ? 'on' : 'at',
103
109
  from: first?.valid_from ?? null,
104
110
  to: first?.valid_to ?? null,
105
111
  activateBy: first?.activate_by ?? null,
@@ -144,7 +150,7 @@ function capSummaries(catalog, card, held, today) {
144
150
  const shared = card.caps.find((c) => c.id === key);
145
151
  const label = shared?.rotating
146
152
  ? 'the rotating bonus categories'
147
- : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
153
+ : listed([...new Set(covered.flatMap((r) => ruleParts(catalog, r)))]) ||
148
154
  words(key);
149
155
  return {
150
156
  key,
@@ -211,6 +217,8 @@ export function walletSummary(catalog, wallet, today) {
211
217
  activated: held.activated,
212
218
  toActivate: toActivate(catalog, card, held, today),
213
219
  anniversary: held.anniversary ?? null,
220
+ opened: openedOn(held),
221
+ intro: introSummary(card, held, today),
214
222
  });
215
223
  }
216
224
  return {
@@ -221,6 +229,18 @@ export function walletSummary(catalog, wallet, today) {
221
229
  cards,
222
230
  };
223
231
  }
232
+ function introSummary(card, held, today) {
233
+ const [first] = card.intro_rates;
234
+ if (!first)
235
+ return null;
236
+ const opened = openedOn(held);
237
+ if (opened === null)
238
+ return { state: 'no-date', until: null, days: first.days };
239
+ const [active] = introWindows(card, held, today);
240
+ if (active)
241
+ return { state: 'active', until: active.until, days: active.intro.days };
242
+ return { state: 'ended', until: introUntil(first, opened), days: first.days };
243
+ }
224
244
  function pointValues(catalog, wallet) {
225
245
  return Object.keys(wallet.valuations)
226
246
  .filter((id) => catalog.currencies.some((c) => c.id === id))
@@ -0,0 +1,21 @@
1
+ const FALLBACK = '#5c5650';
2
+ export function cardColor(catalog, cardId) {
3
+ const card = catalog.cards.find((c) => c.id === cardId);
4
+ if (card?.color)
5
+ return card.color;
6
+ return catalog.issuers.find((i) => i.id === card?.issuer)?.color ?? FALLBACK;
7
+ }
8
+ // Relative luminance, to keep the chip and edge visible on light cards.
9
+ function light(hex) {
10
+ const [r, g, b] = [1, 3, 5].map((i) => {
11
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
12
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
13
+ });
14
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.35;
15
+ }
16
+ // 86 x 54 is a card's 85.6 x 54 mm; `width` scales it.
17
+ export function cardArtSvg(color, width = 48) {
18
+ const height = Math.round((width * 54) / 86);
19
+ const edge = light(color) ? 'rgba(0,0,0,0.18)' : 'rgba(255,255,255,0.14)';
20
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 54" width="${String(width)}" height="${String(height)}" aria-hidden="true" focusable="false"><rect x="0.5" y="0.5" width="85" height="53" rx="5" fill="${color}" stroke="${edge}"/><path d="M0 40 L56 0 H80 Q85.5 0 85.5 5 V8 L20 54 H5 Q0 54 0 49 Z" fill="#fff" opacity="0.07"/><rect x="10" y="17" width="14" height="11" rx="2" fill="#d9c08a" stroke="rgba(0,0,0,0.25)" stroke-width="0.6"/><path d="M10 22.5 H24 M17 17 V28" stroke="rgba(0,0,0,0.25)" stroke-width="0.6"/><rect x="10" y="40" width="30" height="3" rx="1.5" fill="${light(color) ? '#000' : '#fff'}" opacity="0.28"/></svg>`;
21
+ }
@@ -20,6 +20,7 @@ export function formatCardInfo(card) {
20
20
  : []),
21
21
  ...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
22
22
  ...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
23
+ ...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
23
24
  ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
24
25
  foreignFeeText(card.foreignTransactionFee).toLowerCase(),
25
26
  ];
@@ -19,6 +19,7 @@ function compact(wallet) {
19
19
  ? {}
20
20
  : { checkout_label: c.checkout_label }),
21
21
  ...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
22
+ ...(c.opened === undefined ? {} : { opened: c.opened }),
22
23
  ...(Object.keys(c.cap_usage).length > 0
23
24
  ? { cap_usage: c.cap_usage }
24
25
  : {}),
@@ -1,4 +1,4 @@
1
- import { CAP_PERIODS, money, NETWORK_NAMES, shortDate } from "../engine/text.js";
1
+ import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, shortDate, } from "../engine/text.js";
2
2
  // "To do now", above the card details; nothing when all's done.
3
3
  export function formatChecklist(items) {
4
4
  return items.length === 0
@@ -36,11 +36,17 @@ export function formatWalletSummary(summary) {
36
36
  for (const cap of c.caps)
37
37
  lines.push(cap.remaining === null
38
38
  ? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
39
- : ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${cap.period === 'cardmember_year' ? 'this cardmember year' : `for ${cap.currentPeriod ?? ''}`}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
39
+ : ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${periodPhrase(cap.period, cap.currentPeriod ?? '')}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
40
40
  for (const m of c.milestones)
41
41
  lines.push(` Spend bonus (${m.id}): ${m.text}`);
42
42
  if (c.anniversary !== null)
43
43
  lines.push(` Account anniversary: ${c.anniversary}.`);
44
+ if (c.intro?.state === 'active' && c.intro.until !== null)
45
+ lines.push(` Opened ${c.opened ?? ''}: ${introAdjective(c.intro.days)} rates through ${shortDate(c.intro.until)}.`);
46
+ else if (c.intro?.state === 'no-date')
47
+ lines.push(` Has ${introAdjective(c.intro.days)} rates for new accounts; give its opening date if it opened ${introSince(c.intro.days)}.`);
48
+ else if (c.opened !== null)
49
+ lines.push(` Opened ${c.opened}.`);
44
50
  const b = c.bonus;
45
51
  if (b?.state === 'open' && b.spend !== null)
46
52
  lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
@@ -60,7 +66,7 @@ export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
60
66
  const messages = [];
61
67
  if (cap) {
62
68
  const left = Math.max(0, cap.capAmount - cap.after);
63
- messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} for ${cap.period}, ${money(left)} left.`);
69
+ messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
64
70
  }
65
71
  if (bonus) {
66
72
  const left = Math.max(0, bonus.spend - bonus.after);
@@ -74,7 +80,7 @@ export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
74
80
  const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
75
81
  const next = milestones.find((m) => m.after < m.spend);
76
82
  messages.push([
77
- `Spend on ${cardId} this year: ${money(first.after)}.`,
83
+ `Spend on ${cardId} this ${first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year'}: ${money(first.after)}.`,
78
84
  ...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
79
85
  ...(next
80
86
  ? [
@@ -87,6 +87,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
87
87
  cardId: held.id,
88
88
  milestoneId: p.milestone.id,
89
89
  period: p.period,
90
+ periodKind: p.milestone.period,
90
91
  spend: p.milestone.spend,
91
92
  before: p.spent,
92
93
  after: p.spent,
@@ -64,6 +64,9 @@ const cardFields = {
64
64
  .regex(/^\d{2}-\d{2}$/)
65
65
  .optional()
66
66
  .describe("The account's anniversary as MM-DD, for cards whose cap resets on it."),
67
+ opened: IsoDate.nullable()
68
+ .optional()
69
+ .describe('Optional: the day the account opened, YYYY-MM-DD. Only for cards with a first-year rate (search_cards lists it), which applies only while a purchase falls in the window; without a date the card is ranked at its standard rates. Ask the user; never guess or assume a card is new. null removes it.'),
67
70
  activated: z
68
71
  .array(z.string())
69
72
  .optional()
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod';
2
+ // Ready-made questions a client can offer: the user's question, then what
3
+ // to call and how to answer. Rates and card facts come only from the tools.
4
+ export const PROMPTS = ['which-card', 'cheat-sheet', 'this-quarter', 'set-up'];
5
+ const FROM_SAVED_OR_ALL = "the user's saved cards if there are any, otherwise every card earnrate knows";
6
+ const ask = (question, guidance) => ({
7
+ messages: [
8
+ {
9
+ role: 'user',
10
+ content: {
11
+ type: 'text',
12
+ text: `${question}\n\n${guidance.join(' ')}`,
13
+ },
14
+ },
15
+ ],
16
+ });
17
+ const dollars = (amount) => (/^\d/.test(amount) ? `$${amount}` : amount);
18
+ export function registerPrompts(server, deps, ctx) {
19
+ server.registerPrompt('which-card', {
20
+ title: 'Which credit card should I use?',
21
+ description: 'The card that earns the most at a store or for a kind of purchase, with the reason. From your saved cards, or every card if you have none saved.',
22
+ argsSchema: z.object({
23
+ store: z
24
+ .string()
25
+ .trim()
26
+ .min(1)
27
+ .describe('The store, or the kind of purchase: Costco, groceries'),
28
+ amount: z
29
+ .string()
30
+ .optional()
31
+ .describe('How much you are spending, in US dollars'),
32
+ country: z
33
+ .string()
34
+ .optional()
35
+ .describe('Where you are paying, if not the U.S.: FR, Japan'),
36
+ }),
37
+ }, (args) => {
38
+ const amount = args.amount?.trim() || null;
39
+ const country = args.country?.trim() || null;
40
+ const call = [
41
+ `merchant ${JSON.stringify(args.store)}`,
42
+ ...(amount === null
43
+ ? []
44
+ : [`amount ${JSON.stringify(amount)} as a number of US dollars`]),
45
+ ...(country === null
46
+ ? []
47
+ : [`country as the two-letter code for ${JSON.stringify(country)}`]),
48
+ ];
49
+ return ask(`Which credit card should I use at ${args.store}${amount === null ? '' : ` for ${dollars(amount)}`}${country === null ? '' : ` in ${country}`}?`, [
50
+ `Call earnrate's best_card_for_purchase with ${call.join(', ')}.`,
51
+ `It answers from ${FROM_SAVED_OR_ALL}, so don't ask for their cards first.`,
52
+ 'Say which card to use and why, with the assumptions and warnings it gives.',
53
+ ...(country === null
54
+ ? [
55
+ 'If the user mentions paying abroad or in another currency, ask where, or pass `country` as its two-letter code.',
56
+ ]
57
+ : []),
58
+ ]);
59
+ });
60
+ server.registerPrompt('cheat-sheet', {
61
+ title: 'Which card for everyday spending?',
62
+ description: 'The card to use for each everyday kind of purchase right now, from your saved cards or every card.',
63
+ }, () => ask('Which card should I use for everyday spending?', [
64
+ "Call earnrate's cheat_sheet and show the card for each kind of purchase, with its notes.",
65
+ `It uses ${FROM_SAVED_OR_ALL}.`,
66
+ ]));
67
+ server.registerPrompt('this-quarter', {
68
+ title: 'What should I do with my cards this quarter?',
69
+ description: 'Bonuses to activate, categories to pick, caps running low and welcome bonuses to finish, from your saved cards.',
70
+ }, () => ask('What should I do with my cards this quarter?', [
71
+ "Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first.",
72
+ ctx.walletUse,
73
+ 'If no cards are saved, say in a sentence or two what saving them adds (reminders to activate bonuses, pick categories, and watch caps and welcome bonuses), without pressing the user to do it, and offer the cheat sheet from every card instead.',
74
+ ]));
75
+ server.registerPrompt('set-up', {
76
+ title: 'Set up my cards',
77
+ description: 'Save the cards you hold, by name, with their category picks, memberships and activations.',
78
+ }, () => ask('Help me set up my cards in earnrate.', [
79
+ 'Ask which cards the user has; their names are enough. Never ask for card numbers, expiry dates or security codes.',
80
+ ...(deps.savedWallets === 'account'
81
+ ? [
82
+ "Never ask for the recovery code either; it's typed only on earnrate's page.",
83
+ ]
84
+ : []),
85
+ 'Find each card with search_cards and check the match with the user, then save it with add_card.',
86
+ ctx.walletUse,
87
+ 'When a card needs category picks, a membership like Amazon Prime, activations or its anniversary, ask rather than guessing, and save the answers with update_card or set_memberships.',
88
+ 'When done, call list_wallet and show its to-do list.',
89
+ ]));
90
+ }
@@ -100,6 +100,7 @@ export const WalletSummarySchema = z.object({
100
100
  'bonus',
101
101
  'milestone',
102
102
  'rotation',
103
+ 'intro',
103
104
  ]),
104
105
  cardId: z.string(),
105
106
  text: z.string(),
@@ -169,6 +170,14 @@ export const WalletSummarySchema = z.object({
169
170
  activated: z.array(z.string()),
170
171
  toActivate: z.array(z.string()),
171
172
  anniversary: z.string().nullable(),
173
+ opened: z.string().nullable(),
174
+ intro: z
175
+ .object({
176
+ state: z.enum(['active', 'ended', 'no-date']),
177
+ until: z.string().nullable(),
178
+ days: z.number(),
179
+ })
180
+ .nullable(),
172
181
  })),
173
182
  });
174
183
  export const RecordResultSchema = z.object({
@@ -179,6 +188,12 @@ export const RecordResultSchema = z.object({
179
188
  capKey: z.string(),
180
189
  ruleId: z.string(),
181
190
  period: z.string(),
191
+ periodKind: z.enum([
192
+ 'month',
193
+ 'quarter',
194
+ 'calendar_year',
195
+ 'cardmember_year',
196
+ ]),
182
197
  capAmount: z.number(),
183
198
  before: z.number(),
184
199
  after: z.number(),
@@ -197,6 +212,7 @@ export const RecordResultSchema = z.object({
197
212
  cardId: z.string(),
198
213
  milestoneId: z.string(),
199
214
  period: z.string(),
215
+ periodKind: z.enum(['calendar_year', 'cardmember_year']),
200
216
  spend: z.number(),
201
217
  before: z.number(),
202
218
  after: z.number(),
@@ -270,6 +286,7 @@ export const CardSearchSchema = z.object({
270
286
  rewards: z.array(z.string()),
271
287
  text: z.string(),
272
288
  })),
289
+ introRates: z.array(z.object({ id: z.string(), days: z.number(), text: z.string() })),
273
290
  })),
274
291
  });
275
292
  export const WalletChangeSchema = z.object({
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { MCP_SERVER_NAME } from "../brand.js";
3
3
  import { makeContext } from "./context.js";
4
4
  import { makeInputs } from "./inputs.js";
5
+ import { registerPrompts } from "./prompts.js";
5
6
  import { registerAccountTools } from "./tools/account.js";
6
7
  import { registerAnswerTools } from "./tools/answers.js";
7
8
  import { registerWalletTools } from "./tools/wallet.js";
@@ -66,6 +67,7 @@ export function createEarnrateServer(deps) {
66
67
  const inputs = makeInputs(deps.catalog);
67
68
  registerAnswerTools(server, deps, ctx, inputs);
68
69
  registerWalletTools(server, deps, ctx, inputs);
70
+ registerPrompts(server, deps, ctx);
69
71
  if (deps.savedWallets === 'account')
70
72
  registerAccountTools(server, deps.catalog, deps.store, ctx);
71
73
  return server;
@@ -26,6 +26,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
26
26
  annotations: {
27
27
  title: 'Export the wallet',
28
28
  readOnlyHint: true,
29
+ destructiveHint: false,
29
30
  openWorldHint: false,
30
31
  },
31
32
  }, async () => {
@@ -82,7 +83,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
82
83
  });
83
84
  server.registerTool('delete_wallet', {
84
85
  title: 'Delete the 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.",
86
+ description: "Deletes the saved wallet and its recovery code now and for good, and signs out every 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.",
86
87
  inputSchema: z.object({
87
88
  confirm: z
88
89
  .literal(true)
@@ -121,7 +122,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
121
122
  content: [
122
123
  {
123
124
  type: 'text',
124
- text: "Deleted the saved wallet and its recovery code, and signed out every Claude app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
125
+ text: "Deleted the saved wallet and its recovery code, and signed out every app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
125
126
  },
126
127
  ],
127
128
  structuredContent: { deleted: true },
@@ -129,13 +130,15 @@ export function registerAccountTools(server, catalog, store, ctx) {
129
130
  });
130
131
  server.registerTool('switch_wallet', {
131
132
  title: 'Switch wallets',
132
- 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.",
133
+ 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 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.",
133
134
  inputSchema: z.object({}),
134
135
  outputSchema: z.object({ disconnected: z.boolean() }),
135
136
  annotations: {
136
137
  title: 'Switch wallets',
137
138
  readOnlyHint: false,
138
- destructiveHint: false,
139
+ // It signs out this app's other connections to the wallet, which only
140
+ // the recovery code undoes.
141
+ destructiveHint: true,
139
142
  idempotentHint: true,
140
143
  openWorldHint: false,
141
144
  },
@@ -145,7 +148,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
145
148
  content: [
146
149
  {
147
150
  type: 'text',
148
- text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other Claude accounts connected to this wallet through the same app are signed out too.",
151
+ text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other accounts connected to this wallet through the same app are signed out too.",
149
152
  },
150
153
  ],
151
154
  structuredContent: { disconnected: true },
@@ -54,6 +54,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
54
54
  annotations: {
55
55
  title: 'Best card for a purchase',
56
56
  readOnlyHint: true,
57
+ destructiveHint: false,
57
58
  openWorldHint: deps.llm !== null,
58
59
  },
59
60
  }, async ({ merchant, amount, channel, date, country, category, cards, memberships, valuation, }) => {
@@ -127,6 +128,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
127
128
  annotations: {
128
129
  title: 'Resolve a merchant',
129
130
  readOnlyHint: true,
131
+ destructiveHint: false,
130
132
  openWorldHint: deps.llm !== null,
131
133
  },
132
134
  }, async ({ merchant, channel, category }) => {
@@ -166,6 +168,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
166
168
  annotations: {
167
169
  title: 'Compare welcome offers',
168
170
  readOnlyHint: true,
171
+ destructiveHint: false,
169
172
  openWorldHint: false,
170
173
  },
171
174
  }, async (args) => {
@@ -219,6 +222,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
219
222
  annotations: {
220
223
  title: 'Best cards for a purchase',
221
224
  readOnlyHint: true,
225
+ destructiveHint: false,
222
226
  openWorldHint: false,
223
227
  },
224
228
  }, async (args) => {
@@ -266,6 +270,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
266
270
  annotations: {
267
271
  title: 'Simulate a year of spending',
268
272
  readOnlyHint: true,
273
+ destructiveHint: false,
269
274
  openWorldHint: false,
270
275
  },
271
276
  }, async (args) => {
@@ -349,6 +354,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
349
354
  annotations: {
350
355
  title: 'Cheat sheet',
351
356
  readOnlyHint: true,
357
+ destructiveHint: false,
352
358
  openWorldHint: false,
353
359
  },
354
360
  }, async (args) => {
@@ -391,6 +397,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
391
397
  annotations: {
392
398
  title: 'Search cards',
393
399
  readOnlyHint: true,
400
+ destructiveHint: false,
394
401
  openWorldHint: false,
395
402
  },
396
403
  }, async ({ query }) => {
@@ -39,6 +39,10 @@ function changes(catalog, args) {
39
39
  out.push(`checkout name "${args.checkoutLabel}"`);
40
40
  if (args.anniversary !== undefined)
41
41
  out.push(`anniversary ${args.anniversary}`);
42
+ if (args.opened === null)
43
+ out.push('opening date cleared');
44
+ else if (args.opened !== undefined)
45
+ out.push(`opened ${args.opened}`);
42
46
  if (args.activated !== undefined)
43
47
  out.push(args.activated.length === 0
44
48
  ? 'nothing activated'
@@ -46,7 +50,7 @@ function changes(catalog, args) {
46
50
  for (const [key, spent] of Object.entries(args.capSpent ?? {}))
47
51
  out.push(`${money(spent)} already spent on ${key} this period`);
48
52
  for (const [id, spent] of Object.entries(args.milestoneSpent ?? {}))
49
- out.push(`${money(spent)} spent toward ${id} this year`);
53
+ out.push(`${money(spent)} spent toward ${id} this ${card?.milestones.find((m) => m.id === id)?.period === 'cardmember_year' ? 'cardmember year' : 'year'}`);
50
54
  if (args.bonus === null)
51
55
  out.push('welcome bonus no longer tracked');
52
56
  else if (args.bonus !== undefined)
@@ -63,6 +67,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
63
67
  annotations: {
64
68
  title: 'List wallet',
65
69
  readOnlyHint: true,
70
+ destructiveHint: false,
66
71
  openWorldHint: false,
67
72
  },
68
73
  }, async () => {
@@ -178,7 +183,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
178
183
  });
179
184
  server.registerTool('update_card', {
180
185
  title: 'Update a card',
181
- description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent), spending this year toward its spend milestones (milestoneSpent) or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
186
+ description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend milestones (milestoneSpent) or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
182
187
  inputSchema: CardChange,
183
188
  outputSchema: WalletChangeSchema,
184
189
  annotations: {
@@ -145,10 +145,32 @@ export const ChoiceSchema = z.strictObject({
145
145
  })),
146
146
  ])),
147
147
  });
148
+ // A rate for a stretch after the account opens, counted from the opening
149
+ // day: Discover's Cashback Match doubles every rate for the first 365 days,
150
+ // Customized Cash adds 3% to its choice category. With `rules`, it changes
151
+ // only those rules' rates, inside their caps; past a cap they earn what they
152
+ // always do. Without, it changes every rate on the card, past caps included.
153
+ export const IntroRateSchema = z
154
+ .strictObject({
155
+ id: KebabId,
156
+ days: z.number().int().positive(),
157
+ rules: z.array(KebabId).min(1).optional(),
158
+ multiply: z.number().positive().optional(),
159
+ add: z.number().positive().optional(), // currency units per dollar
160
+ note: z.string().optional(),
161
+ })
162
+ .refine((i) => (i.multiply === undefined) !== (i.add === undefined), 'an intro rate needs multiply or add, not both');
163
+ const HexColor = z.string().regex(/^#[0-9a-f]{6}$/);
148
164
  export const CardSchema = z
149
165
  .strictObject({
150
166
  id: KebabId,
151
167
  name: z.string().min(1),
168
+ // A name that fits one line of the Wallet pass's strip (24 characters),
169
+ // for cards whose name is longer: "AAdvantage Platinum".
170
+ short_name: z.string().min(1).max(24).optional(),
171
+ // The card face's main color, roughly, for earnrate's drawing of the
172
+ // card. Never the issuer's card art.
173
+ color: HexColor.optional(),
152
174
  issuer: z.string().min(1),
153
175
  network: CardNetwork,
154
176
  // Merchant ids a store card works at. Required for, and only for, `store`.
@@ -183,6 +205,7 @@ export const CardSchema = z
183
205
  choices: z.array(ChoiceSchema).default([]),
184
206
  rules: z.array(RuleSchema).default([]),
185
207
  milestones: z.array(MilestoneSchema).default([]),
208
+ intro_rates: z.array(IntroRateSchema).default([]),
186
209
  // A short quote from an issuer page for each rule (or `base_rate`). The
187
210
  // weekly source check fails a rule whose quote disappears from its page.
188
211
  evidence: z
@@ -4,5 +4,9 @@ export const IssuerSchema = z.strictObject({
4
4
  id: KebabId,
5
5
  name: z.string().min(1), // as shown: "American Express"
6
6
  search: z.array(z.string().min(1)).default([]), // other words people use
7
+ color: z
8
+ .string()
9
+ .regex(/^#[0-9a-f]{6}$/)
10
+ .optional(), // for a card without its own
7
11
  });
8
12
  export const IssuersFileSchema = z.array(IssuerSchema).min(1);
@@ -28,6 +28,9 @@ export const WalletCardSchema = z.strictObject({
28
28
  .string()
29
29
  .regex(/^\d{2}-\d{2}$/)
30
30
  .optional(),
31
+ // The day the account opened, for rates that apply only for a stretch
32
+ // after it (intro_rates). bonus.opened stands in when this isn't set.
33
+ opened: IsoDate.optional(),
31
34
  // Keyed by shared cap id, or by rule id for an inline cap.
32
35
  cap_usage: z.record(KebabId, CapUsage).default({}),
33
36
  chosen: z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",
@@ -42,19 +42,23 @@
42
42
  "prepack": "yarn build",
43
43
  "smoke:pack": "node scripts/smoke-pack.ts",
44
44
  "mcpb:pack": "node scripts/build-mcpb.ts",
45
+ "chatgpt:pack": "node scripts/pack-chatgpt.ts",
45
46
  "smoke:mcpb": "node scripts/smoke-mcpb.ts",
46
47
  "remote:build": "node scripts/build-remote.ts",
47
48
  "remote:check": "yarn remote:build && WRANGLER_SEND_METRICS=false wrangler deploy --config remote/wrangler.jsonc --dry-run --outdir dist",
48
49
  "remote:deploy": "yarn remote:build && WRANGLER_SEND_METRICS=false wrangler deploy --config remote/wrangler.jsonc",
50
+ "pass:build": "node scripts/build-pass.ts",
51
+ "pass:check": "yarn pass:build && WRANGLER_SEND_METRICS=false wrangler deploy --config pass/wrangler.jsonc --dry-run --outdir dist",
52
+ "pass:deploy": "yarn pass:build && WRANGLER_SEND_METRICS=false wrangler deploy --config pass/wrangler.jsonc",
49
53
  "monitor:check": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc --dry-run --outdir dist",
50
54
  "monitor:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc",
51
55
  "site:build": "vite build site && node scripts/build-pages.ts",
52
56
  "site:dev": "vite site",
53
- "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy",
57
+ "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy && node scripts/indexnow.ts",
54
58
  "test": "vitest run",
55
59
  "test:watch": "vitest",
56
60
  "coverage": "vitest run --coverage --coverage.include=src/**",
57
- "typecheck": "tsc && tsc -p site && tsc -p remote && tsc -p monitor",
61
+ "typecheck": "tsc && tsc -p site && tsc -p remote && tsc -p monitor && tsc -p pass",
58
62
  "lint": "eslint",
59
63
  "format": "prettier --write .",
60
64
  "format:check": "prettier --check .",
@@ -71,11 +75,16 @@
71
75
  "@cloudflare/workers-oauth-provider": "1.2.4",
72
76
  "@cloudflare/workers-types": "5.20261031.1",
73
77
  "@eslint/js": "^10.0.1",
78
+ "@expo-google-fonts/inter": "0.2.3",
74
79
  "@modelcontextprotocol/client": "2.3.1",
80
+ "@resvg/resvg-wasm": "2.6.2",
75
81
  "@types/node": "^24",
82
+ "@types/node-forge": "1.3.11",
76
83
  "@vitest/coverage-v8": "5.0.5",
77
84
  "eslint": "^10.13.0",
78
85
  "eslint-config-prettier": "^10.1.8",
86
+ "fflate": "0.8.2",
87
+ "node-forge": "1.3.1",
79
88
  "playwright": "1.64.1",
80
89
  "preact": "^10.29.8",
81
90
  "prettier": "^3.9.10",