earnrate 0.2.1 → 0.2.3

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 (36) hide show
  1. package/README.md +2 -1
  2. package/data/cards/amex-blue-business-plus.yaml +1 -0
  3. package/data/cards/amex-blue-cash-everyday.yaml +4 -3
  4. package/data/cards/amex-blue-cash-preferred.yaml +5 -7
  5. package/data/cards/amex-gold.yaml +1 -0
  6. package/data/cards/amex-platinum.yaml +1 -0
  7. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +6 -6
  8. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +6 -6
  9. package/data/cards/chase-freedom-flex.yaml +5 -1
  10. package/data/cards/chase-southwest-plus.yaml +5 -5
  11. package/data/cards/chase-southwest-premier-business.yaml +8 -5
  12. package/data/cards/chase-southwest-premier.yaml +6 -6
  13. package/data/cards/chase-southwest-priority.yaml +6 -6
  14. package/data/cards/citi-aadvantage-business.yaml +6 -6
  15. package/data/cards/citi-aadvantage-platinum-select.yaml +10 -7
  16. package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -4
  17. package/data/merchants/overrides.yaml +4 -1
  18. package/dist/data/file-store.js +3 -1
  19. package/dist/data/store.js +9 -1
  20. package/dist/data/wallet-edit.js +10 -1
  21. package/dist/engine/evaluate.js +22 -3
  22. package/dist/engine/offers.js +2 -1
  23. package/dist/engine/recommend.js +14 -1
  24. package/dist/engine/setup.js +3 -3
  25. package/dist/engine/wallet-change.js +40 -8
  26. package/dist/engine/wallet-summary.js +105 -3
  27. package/dist/format/offers.js +11 -5
  28. package/dist/format/wallet.js +48 -19
  29. package/dist/mcp/context.js +7 -3
  30. package/dist/mcp/inputs.js +12 -5
  31. package/dist/mcp/schemas.js +6 -0
  32. package/dist/mcp/server.js +4 -4
  33. package/dist/mcp/tools/account.js +42 -5
  34. package/dist/mcp/tools/answers.js +13 -4
  35. package/dist/schema/card.js +4 -0
  36. package/package.json +2 -1
@@ -1,4 +1,4 @@
1
- import { pickedCategories } from "./evaluate.js";
1
+ import { isActivated, pickedCategories } from "./evaluate.js";
2
2
  import { daysBetween } from "./periods.js";
3
3
  import { rotationGap } from "./rotation.js";
4
4
  // Rules that start within this many days are worth activating now.
@@ -49,7 +49,7 @@ function needsAnniversary(card) {
49
49
  return (card.caps.some((c) => c.period === 'cardmember_year') ||
50
50
  card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
51
51
  }
52
- function activationRules(card, today) {
52
+ export function activationRules(card, today) {
53
53
  return card.rules
54
54
  .filter((r) => r.requires?.activation === true &&
55
55
  (r.valid_to === undefined || r.valid_to >= today) &&
@@ -144,7 +144,7 @@ export function setupNotes(catalog, wallet, held, today) {
144
144
  text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
145
145
  });
146
146
  }
147
- const toActivate = activationRules(card, today).filter((id) => !held.activated.includes(id));
147
+ const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
148
148
  if (toActivate.length > 0) {
149
149
  notes.push({
150
150
  kind: 'activation',
@@ -1,3 +1,4 @@
1
+ import { capByKey } from "./evaluate.js";
1
2
  import { choicePeriod, periodKey } from "./periods.js";
2
3
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
3
4
  const failed = (error) => ({ ok: false, error });
@@ -25,7 +26,9 @@ function pickKey(card, today) {
25
26
  export function cardFields(card, held, input, today) {
26
27
  const problems = [];
27
28
  const fields = {};
28
- if (input.checkoutLabel !== undefined) {
29
+ if (input.checkoutLabel === null)
30
+ fields.checkout_label = null;
31
+ else if (input.checkoutLabel !== undefined) {
29
32
  if (input.checkoutLabel.trim() === '')
30
33
  problems.push('The checkout label is empty.');
31
34
  // earnrate never stores card numbers; a last four is fine.
@@ -83,6 +86,34 @@ export function cardFields(card, held, input, today) {
83
86
  };
84
87
  }
85
88
  }
89
+ if (input.capSpent !== undefined) {
90
+ const usage = { ...held?.cap_usage };
91
+ const anniversary = fields.anniversary ?? held?.anniversary ?? null;
92
+ for (const [key, spent] of Object.entries(input.capSpent)) {
93
+ const cap = capByKey(card, key);
94
+ if (!cap) {
95
+ const keys = [
96
+ ...card.caps.map((c) => c.id),
97
+ ...card.rules
98
+ .filter((r) => typeof r.cap === 'object')
99
+ .map((r) => r.id),
100
+ ];
101
+ problems.push(keys.length > 0
102
+ ? `${card.id} has no cap '${key}'. Its caps are: ${list(keys)}.`
103
+ : `${card.id} has no spending caps.`);
104
+ continue;
105
+ }
106
+ if (!(spent >= 0))
107
+ problems.push('Spending on a cap must be $0 or more.');
108
+ const period = periodKey(cap.period, today, anniversary);
109
+ if (period === null) {
110
+ problems.push(`The '${key}' cap resets on the card's anniversary; give the anniversary (MM-DD) too.`);
111
+ continue;
112
+ }
113
+ usage[key] = { period, spent };
114
+ }
115
+ fields.cap_usage = usage;
116
+ }
86
117
  if (input.bonus === null)
87
118
  fields.bonus = null;
88
119
  else if (input.bonus !== undefined) {
@@ -115,15 +146,17 @@ const newHolding = (id) => ({
115
146
  cap_usage: {},
116
147
  activated: [],
117
148
  });
118
- const withFields = ({ bonus, ...held }, f) => ({
149
+ const withFields = ({ bonus, checkout_label, ...held }, f) => ({
119
150
  ...held,
120
151
  ...(bonus && f.bonus === undefined ? { bonus } : {}),
121
- ...(f.checkout_label === undefined
122
- ? {}
123
- : { checkout_label: f.checkout_label }),
152
+ ...(checkout_label !== undefined && f.checkout_label === undefined
153
+ ? { checkout_label }
154
+ : {}),
155
+ ...(f.checkout_label ? { checkout_label: f.checkout_label } : {}),
124
156
  ...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
125
157
  ...(f.chosen === undefined ? {} : { chosen: f.chosen }),
126
158
  ...(f.activated === undefined ? {} : { activated: f.activated }),
159
+ ...(f.cap_usage === undefined ? {} : { cap_usage: f.cap_usage }),
127
160
  ...(f.bonus ? { bonus: f.bonus } : {}),
128
161
  });
129
162
  // `wallet` is null when there isn't one yet; adding a card starts it.
@@ -175,9 +208,8 @@ export function removeFromWallet(wallet, id) {
175
208
  if (!wallet.cards.some((c) => c.id === id)) {
176
209
  return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
177
210
  }
178
- if (wallet.cards.length === 1) {
179
- return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
180
- }
211
+ // Removing the last card leaves an empty wallet, which each store keeps
212
+ // in its own way (or refuses, for a wallet file).
181
213
  return {
182
214
  ok: true,
183
215
  wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
@@ -1,10 +1,98 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
- import { capUsed, pickedCategories } from "./evaluate.js";
2
+ import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
3
3
  import { daysBetween } from "./periods.js";
4
4
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
5
  import { rotationGap } from "./rotation.js";
6
+ import { activationRules } from "./setup.js";
7
+ import { categoryPhrase, earnText } from "./text.js";
6
8
  import { cardValuation } from "./valuation.js";
7
- function capSummaries(card, held, today) {
9
+ const NUMBERS = {
10
+ one: '1',
11
+ two: '2',
12
+ three: '3',
13
+ four: '4',
14
+ five: '5',
15
+ six: '6',
16
+ };
17
+ // "five-percent" reads "5%"; other ids read as words.
18
+ export function choiceName(id) {
19
+ return id
20
+ .replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`)
21
+ .replace(/[-_.]/g, ' ');
22
+ }
23
+ const listed = (parts) => parts.length <= 2
24
+ ? parts.join(' and ')
25
+ : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
26
+ function merchantName(catalog, id) {
27
+ const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
28
+ return all.find((m) => m.id === id)?.name ?? id.replace(/[-_.]/g, ' ');
29
+ }
30
+ function optionName(catalog, card, choiceId, option) {
31
+ const o = card.choices.find((c) => c.id === choiceId)?.options[option];
32
+ if (!o)
33
+ return option.replace(/[-_.]/g, ' ');
34
+ const parts = [
35
+ ...o.categories.map((c) => categoryPhrase(catalog, c)),
36
+ ...o.merchants.map((m) => merchantName(catalog, m)),
37
+ ];
38
+ if (o.online)
39
+ parts.push('online shopping');
40
+ return parts.length > 0 ? listed(parts) : option.replace(/[-_.]/g, ' ');
41
+ }
42
+ function ruleWhat(catalog, rule) {
43
+ if (rule.match.choice !== undefined)
44
+ return `your ${choiceName(rule.match.choice)} picks`;
45
+ const parts = [
46
+ ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
47
+ ...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
48
+ ];
49
+ return parts.length > 0 ? listed(parts) : rule.id.replace(/[-_.]/g, ' ');
50
+ }
51
+ function periodEnd(period) {
52
+ const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
53
+ if (quarter) {
54
+ const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
55
+ return `${quarter[1] ?? ''}-${end ?? ''}`;
56
+ }
57
+ const month = /^(\d{4})-(\d{2})$/.exec(period);
58
+ if (month) {
59
+ const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
60
+ return last.toISOString().slice(0, 10);
61
+ }
62
+ return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
63
+ }
64
+ const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
65
+ (rule.valid_to === undefined || rule.valid_to >= today);
66
+ function headlineRates(catalog, card, currency, today) {
67
+ const best = card.rules
68
+ .filter((r) => applies(r, today) && r.rate > card.base_rate)
69
+ .sort((a, b) => b.rate - a.rate)
70
+ .slice(0, 4)
71
+ .map((r) => `${earnText(catalog, r.rate, currency)} on ${ruleWhat(catalog, r)}${r.requires?.activation ? ' (once activated)' : ''}`);
72
+ return [
73
+ ...best,
74
+ `${earnText(catalog, card.base_rate, currency)} on everything else`,
75
+ ];
76
+ }
77
+ function toActivate(catalog, card, held, today) {
78
+ const seen = new Set();
79
+ const out = [];
80
+ for (const id of activationRules(card, today)) {
81
+ if (seen.has(id) || isActivated(card, held, id))
82
+ continue;
83
+ const group = activationGroup(card, id);
84
+ for (const g of group)
85
+ seen.add(g);
86
+ const rules = card.rules.filter((r) => group.includes(r.id));
87
+ const first = rules[0];
88
+ const when = first?.valid_from !== undefined && first.valid_to !== undefined
89
+ ? ` (${first.valid_from} to ${first.valid_to})`
90
+ : '';
91
+ out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
92
+ }
93
+ return out;
94
+ }
95
+ function capSummaries(catalog, card, held, today) {
8
96
  const caps = new Map();
9
97
  for (const shared of card.caps)
10
98
  caps.set(shared.id, { cap: shared, rules: [] });
@@ -20,8 +108,15 @@ function capSummaries(card, held, today) {
20
108
  }
21
109
  return [...caps].map(([key, { cap, rules }]) => {
22
110
  const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
111
+ const covered = card.rules.filter((r) => rules.includes(r.id));
112
+ const shared = card.caps.find((c) => c.id === key);
113
+ const label = shared?.rotating
114
+ ? 'the rotating bonus categories'
115
+ : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
116
+ key.replace(/[-_.]/g, ' ');
23
117
  return {
24
118
  key,
119
+ label,
25
120
  rules,
26
121
  amount: cap.amount,
27
122
  period: cap.period,
@@ -52,15 +147,22 @@ export function walletSummary(catalog, wallet, today) {
52
147
  verifiedBy: card.verified_by,
53
148
  dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
54
149
  rotationGap: rotationGap(card, today),
55
- caps: capSummaries(card, held, today),
150
+ caps: capSummaries(catalog, card, held, today),
56
151
  chosen: held.chosen
57
152
  ? {
58
153
  period: held.chosen.period,
59
154
  current: pickedCategories(card, held, today).staleChoice === null,
155
+ until: periodEnd(held.chosen.period),
60
156
  picks: held.chosen.picks,
157
+ pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
158
+ choiceName(choice),
159
+ options.map((o) => optionName(catalog, card, choice, o)),
160
+ ])),
61
161
  }
62
162
  : null,
163
+ rates: headlineRates(catalog, card, valuation.currency, today),
63
164
  activated: held.activated,
165
+ toActivate: toActivate(catalog, card, held, today),
64
166
  anniversary: held.anniversary ?? null,
65
167
  });
66
168
  }
@@ -1,10 +1,17 @@
1
1
  import { feeText, money } from "../engine/text.js";
2
2
  const TOP = 10;
3
3
  function reward(o, currencyName) {
4
- if (o.dollars !== null)
5
- return money(o.dollars);
6
- return `${(o.points ?? 0).toLocaleString('en-US')} ${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;
7
8
  }
9
+ const asOf = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
10
+ month: 'short',
11
+ day: 'numeric',
12
+ year: 'numeric',
13
+ timeZone: 'UTC',
14
+ });
8
15
  export function formatOffers(c, currencyName, all) {
9
16
  if (c.offers.length === 0) {
10
17
  return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
@@ -19,8 +26,7 @@ export function formatOffers(c, currencyName, all) {
19
26
  ? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
20
27
  : 'with no spending required';
21
28
  lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.held ? ' In your wallet.' : ''}`);
22
- if (o.validTo)
23
- lines.push(` Offer ends ${o.validTo}.`);
29
+ lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${asOf(o.lastVerified)}; yours may differ.`);
24
30
  if (o.note)
25
31
  lines.push(` ${o.note}`);
26
32
  });
@@ -1,26 +1,55 @@
1
1
  import { money } from "../engine/text.js";
2
+ const NETWORKS = {
3
+ visa: 'Visa',
4
+ mastercard: 'Mastercard',
5
+ amex: 'American Express',
6
+ discover: 'Discover',
7
+ store: 'store card',
8
+ };
9
+ const day = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
10
+ month: 'short',
11
+ day: 'numeric',
12
+ year: 'numeric',
13
+ timeZone: 'UTC',
14
+ });
2
15
  export function formatWalletSummary(summary) {
3
- return summary.cards
4
- .map((c) => {
5
- const caps = c.caps
6
- .filter((cap) => cap.remaining !== null)
7
- .map((cap) => `${cap.key} ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}`);
8
- const chosen = c.chosen
9
- ? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
16
+ const cards = summary.cards.map((c) => {
17
+ const label = c.checkoutLabel
18
+ ? `, shown at checkout as "${c.checkoutLabel}"`
10
19
  : '';
11
- const onlyAt = c.onlyAt ? `; works only at ${c.onlyAt.join(', ')}` : '';
12
- const rotation = c.rotationGap === null
13
- ? ''
14
- : `; rotating categories for ${c.rotationGap} not in earnrate yet`;
20
+ const lines = [
21
+ `- ${c.cardName} (${c.cardId})${label}: ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
22
+ ` Earns: ${c.rates.join('; ')}.`,
23
+ ];
24
+ if (c.onlyAt)
25
+ lines.push(` Works only at ${c.onlyAt.join(', ')}.`);
26
+ if (c.chosen) {
27
+ const picks = Object.entries(c.chosen.pickNames)
28
+ .map(([choice, names]) => `${choice}: ${names.join(', ')}`)
29
+ .join('; ');
30
+ const until = c.chosen.until ? `, through ${day(c.chosen.until)}` : '';
31
+ lines.push(c.chosen.current
32
+ ? ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`
33
+ : ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`);
34
+ }
35
+ if (c.toActivate.length > 0)
36
+ lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
37
+ if (c.rotationGap !== null)
38
+ lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
39
+ for (const cap of c.caps)
40
+ if (cap.remaining !== null)
41
+ lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
15
42
  const b = c.bonus;
16
- const bonus = b?.state === 'open' && b.spend !== null
17
- ? `; welcome bonus ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}`
18
- : b
19
- ? `; welcome bonus ${b.state}`
20
- : '';
21
- return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${rotation}${onlyAt}${bonus}`;
22
- })
23
- .join('\n');
43
+ if (b?.state === 'open' && b.spend !== null)
44
+ lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
45
+ else if (b)
46
+ lines.push(` Welcome bonus: ${b.state}.`);
47
+ return lines.join('\n');
48
+ });
49
+ const memberships = summary.memberships.length > 0
50
+ ? `Memberships: ${summary.memberships.join(', ')}.`
51
+ : 'Memberships: none saved.';
52
+ return [...cards, memberships].join('\n');
24
53
  }
25
54
  export function formatBooking(amount, cardId, cap, bonus) {
26
55
  const messages = [];
@@ -7,7 +7,7 @@ export const fail = (text) => ({
7
7
  content: [{ type: 'text', text }],
8
8
  isError: true,
9
9
  });
10
- export const ALL_CARDS_NOTE = "The user hasn't named their cards, so this covers every card earnrate knows. That's a complete answer. If they want the best of their own cards, they can name them (pass them as `cards`); saving a wallet is optional.";
10
+ export const ALL_CARDS_NOTE = "No cards are saved or named, so this covers every card earnrate knows. That's a complete answer. If the user wants the best of their own cards, they can name them (pass them as `cards`); saving them is optional.";
11
11
  // What every tool shares: the saved wallet, merchant resolution, and which
12
12
  // cards an answer is about.
13
13
  export function makeContext(deps) {
@@ -15,7 +15,9 @@ export function makeContext(deps) {
15
15
  const theWallet = deps.savedWallets === 'file'
16
16
  ? `the wallet at ${deps.store.where}`
17
17
  : 'the saved wallet';
18
- const noWallet = `There's no saved wallet yet${deps.savedWallets === 'file' ? ` (${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.`;
18
+ const noWallet = deps.savedWallets === 'file'
19
+ ? `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.";
19
21
  // Read on every call, so edits to the wallet apply without a restart.
20
22
  async function wallet() {
21
23
  let loaded;
@@ -79,7 +81,9 @@ export function makeContext(deps) {
79
81
  };
80
82
  const text = [
81
83
  message,
82
- `Wallet: ${output.cardIds.join(', ')}.`,
84
+ output.cardIds.length > 0
85
+ ? `Wallet: ${output.cardIds.join(', ')}.`
86
+ : 'The wallet has no cards now; answers cover every card.',
83
87
  ...(notes ? [formatNotes(result.notes)] : []),
84
88
  ].join('\n');
85
89
  return {
@@ -46,8 +46,9 @@ const cardFields = {
46
46
  checkoutLabel: z
47
47
  .string()
48
48
  .min(1)
49
+ .nullable()
49
50
  .optional()
50
- .describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional.'),
51
+ .describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional; null goes back to the card\'s own name.'),
51
52
  picks: z
52
53
  .record(z.string(), z.array(z.string()).min(1))
53
54
  .optional()
@@ -61,6 +62,10 @@ const cardFields = {
61
62
  .array(z.string())
62
63
  .optional()
63
64
  .describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
65
+ capSpent: z
66
+ .record(z.string(), z.number().nonnegative())
67
+ .optional()
68
+ .describe('Dollars already spent this period on a capped bonus, for a card the user starts tracking partway through: cap key (as list_wallet shows it) → dollars, e.g. {"five-percent-quarterly": 400}. Ask; recorded purchases add to it from then on.'),
64
69
  bonus: z
65
70
  .object({
66
71
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
@@ -95,10 +100,12 @@ export function cardInput({ cardId, bonus, ...rest }) {
95
100
  }
96
101
  // Inputs that list the catalog's categories, issuers and memberships.
97
102
  export function makeInputs(catalog) {
98
- const categoryIds = catalog.categories.map((c) => c.id);
99
- const categoryList = catalog.categories
100
- .map((c) => `${c.id} (${c.name})`)
101
- .join(', ');
103
+ // A parent with subcategories and no merchant codes of its own ("Grocery")
104
+ // isn't something a purchase codes as, so it's left out.
105
+ const categories = catalog.categories.filter((c) => c.mccs.length > 0 ||
106
+ !catalog.categories.some((k) => k.id.startsWith(`${c.id}.`)));
107
+ const categoryIds = categories.map((c) => c.id);
108
+ const categoryList = categories.map((c) => `${c.id} (${c.name})`).join(', ');
102
109
  const membershipIds = knownMemberships(catalog);
103
110
  const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
104
111
  const CategoryInput = z
@@ -106,6 +106,7 @@ export const WalletSummarySchema = z.object({
106
106
  rotationGap: z.string().nullable(),
107
107
  caps: z.array(z.object({
108
108
  key: z.string(),
109
+ label: z.string(),
109
110
  rules: z.array(z.string()),
110
111
  amount: z.number(),
111
112
  period: CapPeriod,
@@ -117,10 +118,14 @@ export const WalletSummarySchema = z.object({
117
118
  .object({
118
119
  period: z.string(),
119
120
  current: z.boolean(),
121
+ until: z.string().nullable(),
120
122
  picks: z.record(z.string(), z.array(z.string())),
123
+ pickNames: z.record(z.string(), z.array(z.string())),
121
124
  })
122
125
  .nullable(),
126
+ rates: z.array(z.string()),
123
127
  activated: z.array(z.string()),
128
+ toActivate: z.array(z.string()),
124
129
  anniversary: z.string().nullable(),
125
130
  })),
126
131
  });
@@ -161,6 +166,7 @@ export const OfferComparisonSchema = z.object({
161
166
  spend: z.number(),
162
167
  months: z.number(),
163
168
  validTo: z.string().nullable(),
169
+ varies: z.boolean(), // the amount is the most the issuer shows
164
170
  note: z.string().nullable(),
165
171
  annualFee: z.number().nullable(),
166
172
  firstYearFee: z.number().nullable(),
@@ -15,8 +15,7 @@ export const ANSWER_TOOLS = [
15
15
  'resolve_merchant',
16
16
  'search_cards',
17
17
  ];
18
- // The tools that read or change a saved wallet. The hosted server asks for
19
- // sign-in before running one.
18
+ // The tools that read or change a saved wallet.
20
19
  export const SAVED_WALLET_TOOLS = [
21
20
  'list_wallet',
22
21
  'record_purchase',
@@ -27,14 +26,15 @@ export const SAVED_WALLET_TOOLS = [
27
26
  'export_wallet',
28
27
  'import_wallet',
29
28
  'delete_wallet',
29
+ 'switch_wallet',
30
30
  ];
31
31
  const SAVING = {
32
32
  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
- account: "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. Saved wallets are anonymous: the first wallet tool asks the user to open an earnrate page, where they start a wallet (it shows a recovery code to keep; no email or name) or reconnect one with their code. Never ask for the recovery code in the conversation; it's typed only on that page. To change the code, the user opens https://mcp.earnrate.dev/change-code.",
33
+ 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
34
  };
35
35
  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
36
 
37
- Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
37
+ 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
38
 
39
39
  The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.`;
40
40
  export function createEarnrateServer(deps) {
@@ -23,13 +23,17 @@ export function registerAccountTools(server, catalog, store, ctx) {
23
23
  });
24
24
  server.registerTool('import_wallet', {
25
25
  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. Confirm with the user first if they already have cards saved.',
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.',
27
27
  inputSchema: z.object({
28
28
  yaml: z
29
29
  .string()
30
30
  .min(1)
31
31
  .max(100_000)
32
32
  .describe('The wallet.yaml text, as the user gave it.'),
33
+ replace: z
34
+ .boolean()
35
+ .optional()
36
+ .describe('true to replace a wallet that already has cards, after the user confirms.'),
33
37
  }),
34
38
  outputSchema: WalletChangeSchema,
35
39
  annotations: {
@@ -38,17 +42,24 @@ export function registerAccountTools(server, catalog, store, ctx) {
38
42
  idempotentHint: true,
39
43
  openWorldHint: false,
40
44
  },
41
- }, async ({ yaml }) => {
45
+ }, async ({ yaml, replace }) => {
42
46
  const parsed = parseWalletYaml(yaml, catalog);
43
47
  if (typeof parsed === 'string')
44
48
  return fail(parsed);
49
+ const current = await store.load().catch(() => null);
50
+ if (current !== null && current.cards.length > 0 && replace !== true)
51
+ 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.`);
45
52
  const count = parsed.cards.length;
46
53
  return ctx.changed(await store.replace(parsed), `Imported a wallet with ${String(count)} ${count === 1 ? 'card' : 'cards'}.`, { notes: false });
47
54
  });
48
55
  server.registerTool('delete_wallet', {
49
56
  title: 'Delete the wallet',
50
- description: 'Deletes the saved wallet now and for good, and signs out every app using it. Only when the user asks to delete it; confirm first, and offer export_wallet to keep a copy.',
51
- inputSchema: z.object({}),
57
+ description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: offer export_wallet first, and pass confirm only after the user confirms. It refuses when the wallet has no cards, since there's nothing to delete.",
58
+ inputSchema: z.object({
59
+ confirm: z
60
+ .literal(true)
61
+ .describe('true, only after the user confirms the deletion.'),
62
+ }),
52
63
  outputSchema: z.object({ deleted: z.boolean() }),
53
64
  annotations: {
54
65
  readOnlyHint: false,
@@ -57,15 +68,41 @@ export function registerAccountTools(server, catalog, store, ctx) {
57
68
  openWorldHint: false,
58
69
  },
59
70
  }, async () => {
71
+ const current = await store.load().catch(() => null);
72
+ if (current === null || current.cards.length === 0)
73
+ return fail("This saved wallet has no cards, so there's nothing to delete. If the user meant a different wallet, it may already be deleted.");
60
74
  await store.erase();
61
75
  return {
62
76
  content: [
63
77
  {
64
78
  type: 'text',
65
- text: 'Deleted the saved wallet. Answers now cover every card, or the cards named in the conversation.',
79
+ 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.",
66
80
  },
67
81
  ],
68
82
  structuredContent: { deleted: true },
69
83
  };
70
84
  });
85
+ server.registerTool('switch_wallet', {
86
+ title: 'Switch wallets',
87
+ description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
88
+ inputSchema: z.object({}),
89
+ outputSchema: z.object({ disconnected: z.boolean() }),
90
+ annotations: {
91
+ readOnlyHint: false,
92
+ destructiveHint: false,
93
+ idempotentHint: true,
94
+ openWorldHint: false,
95
+ },
96
+ }, async () => {
97
+ await store.disconnect();
98
+ return {
99
+ content: [
100
+ {
101
+ type: 'text',
102
+ 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.",
103
+ },
104
+ ],
105
+ structuredContent: { disconnected: true },
106
+ };
107
+ });
71
108
  }
@@ -125,6 +125,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
125
125
  description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
126
126
  inputSchema: z.object({
127
127
  ...OfferFilters,
128
+ valuation: CatalogFilters.valuation,
128
129
  includeHeld: z
129
130
  .boolean()
130
131
  .optional()
@@ -137,7 +138,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
137
138
  const wallet = await ctx.cardsFor(args.cards, undefined);
138
139
  if (typeof wallet === 'string')
139
140
  return fail(wallet);
140
- const comparison = compareOffers(catalog, wallet ?? EMPTY_WALLET, {
141
+ const base = wallet ?? EMPTY_WALLET;
142
+ const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
141
143
  kind: args.kind ?? 'any',
142
144
  noAnnualFee: args.noAnnualFee ?? false,
143
145
  business: args.business ?? false,
@@ -273,11 +275,16 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
273
275
  });
274
276
  server.registerTool('cheat_sheet', {
275
277
  title: 'Cheat sheet',
276
- description: "The card to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else. With no cards given and no saved wallet, it picks from every card earnrate knows (`scope: all cards`, each row noting the card's fee and conditions). With cards named in the conversation or a saved wallet, it picks from the user's own cards, with caps, picks and activations as they are. For one specific store, call best_card_for_purchase instead. Pass on the notes.",
278
+ description: "The card to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else. With no cards given and no saved wallet, it picks from every card earnrate knows (`scope: all cards`, each row noting the card's fee and conditions). With cards named in the conversation or a saved wallet, it picks from the user's own cards, with caps, picks and activations as they are. The kind, noAnnualFee, business and issuer filters apply only when it picks from every card. For one specific store, call best_card_for_purchase instead. Pass on the notes.",
277
279
  inputSchema: z.object({
278
280
  date: DateInput,
279
281
  cards: NamedCards,
280
282
  memberships: NamedMemberships,
283
+ kind: CatalogFilters.kind,
284
+ noAnnualFee: CatalogFilters.noAnnualFee,
285
+ business: CatalogFilters.business,
286
+ issuer: CatalogFilters.issuer,
287
+ valuation: CatalogFilters.valuation,
281
288
  }),
282
289
  outputSchema: CheatSheetSchema,
283
290
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -288,10 +295,12 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
288
295
  const date = args.date ?? deps.today();
289
296
  const sheet = wallet === null
290
297
  ? {
291
- ...catalogCheatSheet(catalog, date, settings({ memberships: args.memberships })),
298
+ ...catalogCheatSheet(catalog, date, settings(args)),
292
299
  notes: [ALL_CARDS_NOTE],
293
300
  }
294
- : cheatSheet(catalog, wallet, date);
301
+ : cheatSheet(catalog, args.valuation
302
+ ? withValuationProfile(wallet, args.valuation)
303
+ : wallet, date);
295
304
  return {
296
305
  content: [{ type: 'text', text: formatCheatSheet(sheet) }],
297
306
  structuredContent: sheet,
@@ -75,6 +75,10 @@ export const WelcomeOfferSchema = z
75
75
  spend: z.number().nonnegative(), // dollars of purchases required
76
76
  months: z.number().int().positive(), // from account opening
77
77
  valid_to: IsoDate.optional(), // when the issuer says the offer ends
78
+ // The issuer shows each applicant their own amount ("as high as"), or
79
+ // the page showed different offers on different visits. The amount
80
+ // above is then the most it shows.
81
+ varies: z.literal(true).optional(),
78
82
  note: z.string().optional(),
79
83
  })
80
84
  .refine((o) => (o.points === undefined) !== (o.dollars === undefined), 'a welcome offer needs points or dollars, not both');