earnrate 0.2.15 → 0.2.17

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 (54) hide show
  1. package/README.md +14 -8
  2. package/data/CHANGELOG.md +14 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-platinum.yaml +15 -1
  5. package/data/cards/barclays-jetblue-plus.yaml +19 -1
  6. package/data/cards/chase-freedom-flex.yaml +7 -7
  7. package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
  8. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
  9. package/data/cards/chase-ihg-one-rewards-premier.yaml +5 -1
  10. package/data/cards/chase-ihg-one-rewards.yaml +13 -2
  11. package/data/cards/chase-ink-business-cash.yaml +5 -1
  12. package/data/cards/chase-ink-business-unlimited.yaml +5 -1
  13. package/data/cards/chase-prime-visa.yaml +3 -3
  14. package/data/cards/chase-sapphire-reserve.yaml +12 -3
  15. package/data/cards/chase-united-business.yaml +5 -1
  16. package/data/cards/chase-united-club-business.yaml +5 -1
  17. package/data/cards/chase-united-club.yaml +5 -1
  18. package/data/cards/chase-united-explorer.yaml +5 -1
  19. package/data/cards/chase-united-quest.yaml +5 -1
  20. package/data/cards/citi-strata-premier.yaml +14 -1
  21. package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
  22. package/data/merchants/overrides.yaml +3 -3
  23. package/dist/cli/args.js +36 -1
  24. package/dist/cli/run.js +14 -1
  25. package/dist/cli/wallet.js +18 -8
  26. package/dist/data/integrity.js +13 -0
  27. package/dist/data/wallet-edit.js +5 -0
  28. package/dist/engine/cheat-sheet.js +1 -1
  29. package/dist/engine/checklist.js +26 -6
  30. package/dist/engine/credits.js +56 -7
  31. package/dist/engine/evaluate.js +21 -1
  32. package/dist/engine/explore.js +32 -10
  33. package/dist/engine/memberships.js +22 -10
  34. package/dist/engine/milestones.js +11 -3
  35. package/dist/engine/offers.js +9 -4
  36. package/dist/engine/recommend.js +21 -2
  37. package/dist/engine/rule-text.js +20 -7
  38. package/dist/engine/setup.js +7 -6
  39. package/dist/engine/simulate.js +43 -10
  40. package/dist/engine/text.js +46 -5
  41. package/dist/engine/wallet-change.js +18 -7
  42. package/dist/engine/wallet-summary.js +45 -24
  43. package/dist/format/card-text.js +6 -12
  44. package/dist/format/explore.js +12 -8
  45. package/dist/format/offers.js +1 -1
  46. package/dist/format/wallet.js +2 -0
  47. package/dist/mcp/inputs.js +17 -1
  48. package/dist/mcp/schemas.js +24 -0
  49. package/dist/mcp/tools/account.js +50 -42
  50. package/dist/mcp/tools/wallet.js +207 -150
  51. package/dist/mcp/wallet-api.js +78 -0
  52. package/dist/schema/card.js +11 -0
  53. package/dist/schema/wallet.js +6 -2
  54. package/package.json +1 -1
@@ -57,14 +57,206 @@ function changes(catalog, args) {
57
57
  : `enrolled in: ${args.enrolled.join(', ')}`);
58
58
  for (const [id, used] of Object.entries(args.creditUsed ?? {}))
59
59
  out.push(`${money(used)} of the ${id} credit used this period`);
60
+ if (args.skippedCredits !== undefined)
61
+ out.push(args.skippedCredits.length === 0
62
+ ? 'every credit counted'
63
+ : `credits not used: ${args.skippedCredits.join(', ')}`);
60
64
  if (args.bonus === null)
61
65
  out.push('welcome bonus no longer tracked');
62
66
  else if (args.bonus !== undefined)
63
67
  out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
64
68
  return out;
65
69
  }
66
- export function registerWalletTools(server, deps, ctx, inputs) {
70
+ // The saved wallet with its summary and to-do list, as list_wallet shows it.
71
+ export async function walletListing(deps, ctx) {
72
+ const read = await ctx.readableWallet();
73
+ if (typeof read === 'string')
74
+ return read;
75
+ const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
76
+ const base = walletSummary(deps.catalog, read.wallet, deps.today());
77
+ const summary = {
78
+ ...base,
79
+ started,
80
+ checklist: checklist(deps.catalog, read.wallet, deps.today(), base, cardText(deps.catalog).shortName),
81
+ };
82
+ return { ...read, summary };
83
+ }
84
+ export function walletHandlers(deps, ctx, inputs) {
67
85
  const { catalog, store } = deps;
86
+ const listWallet = {
87
+ input: z.object({}),
88
+ run: async () => {
89
+ const found = await walletListing(deps, ctx);
90
+ if (typeof found === 'string')
91
+ return fail(found);
92
+ const { start, summary } = found;
93
+ const when = summary.started === null
94
+ ? `This is ${ctx.walletName}.\n`
95
+ : `This is ${ctx.walletName}. ${startedText(summary.started, Date.now())}\n`;
96
+ return {
97
+ content: [
98
+ {
99
+ type: 'text',
100
+ text: `${when}${start === null ? '' : `${start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
101
+ },
102
+ ],
103
+ structuredContent: summary,
104
+ };
105
+ },
106
+ };
107
+ const RecordInput = z.object({
108
+ cardId: z
109
+ .string()
110
+ .min(1)
111
+ .describe('The card used, as a cardId from list_wallet or best_card_for_purchase.'),
112
+ merchant: Merchant,
113
+ amount: z.number().positive().describe('Amount spent in US dollars.'),
114
+ channel: ChannelInput,
115
+ date: DateInput,
116
+ category: inputs.RecordCategoryInput,
117
+ });
118
+ const recordPurchase = {
119
+ input: RecordInput,
120
+ run: async ({ cardId, merchant, amount, channel, date, category }) => {
121
+ const w = await ctx.wallet();
122
+ if (typeof w === 'string')
123
+ return fail(w);
124
+ if (!w.cards.some((c) => c.id === cardId))
125
+ return fail(`"${cardId}" isn't in the wallet. Cards: ${w.cards.map((c) => c.id).join(', ')}.`);
126
+ const purchase = {
127
+ cardId,
128
+ merchant,
129
+ amount,
130
+ channel: channel ?? null,
131
+ date: date ?? deps.today(),
132
+ };
133
+ const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
134
+ if (resolved.merchant.source === 'none')
135
+ return fail(`earnrate doesn't recognize "${merchant}", so it can't tell which caps this purchase counts toward. Nothing was recorded. Call record_purchase again with category if you can tell what kind of business it is (a restaurant, a grocery store, a gas station), or ask the user. If nobody knows, category "other" records it toward a welcome bonus without touching any cap.`);
136
+ const result = await store.record(purchase, resolved.merchant);
137
+ if (!result.ok)
138
+ return fail(`Couldn't record it: ${result.error}`);
139
+ const { cap: booking, bonus, milestones, credit } = result;
140
+ const capLabel = booking
141
+ ? (walletSummary(catalog, w, purchase.date)
142
+ .cards.find((c) => c.cardId === cardId)
143
+ ?.caps.find((c) => c.key === booking.capKey)?.label ?? null)
144
+ : null;
145
+ const output = booking || bonus || milestones.length > 0 || credit
146
+ ? {
147
+ recorded: true,
148
+ booking,
149
+ bonus,
150
+ milestones,
151
+ credit,
152
+ message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
153
+ }
154
+ : {
155
+ recorded: false,
156
+ booking: null,
157
+ bonus: null,
158
+ milestones: [],
159
+ credit: null,
160
+ message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend bonus or enrolled statement credit for this purchase, so the wallet wasn't changed.`,
161
+ };
162
+ return {
163
+ content: [
164
+ {
165
+ type: 'text',
166
+ text: [output.message, ...resolved.notices].join('\n'),
167
+ },
168
+ ],
169
+ structuredContent: output,
170
+ };
171
+ },
172
+ };
173
+ const addCard = {
174
+ input: CardChange,
175
+ run: async (args) => {
176
+ const result = await store.addCard(cardInput(args), deps.today());
177
+ const started = !result.ok || !result.created
178
+ ? ''
179
+ : deps.savedWallets === 'account'
180
+ ? ', the first card in your saved wallet'
181
+ : `, starting a new wallet at ${store.where}`;
182
+ return ctx.changed(result, `Added ${ctx.cardName(args.cardId)} (${args.cardId})${started}.`);
183
+ },
184
+ };
185
+ const updateCard = {
186
+ input: CardChange,
187
+ run: async (args) => {
188
+ const what = changes(catalog, args);
189
+ const card = catalog.cards.find((c) => c.id === args.cardId);
190
+ const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
191
+ ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
192
+ : '';
193
+ return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
194
+ },
195
+ };
196
+ const RemoveInput = z.object({ cardId: CardId });
197
+ const removeCard = {
198
+ input: RemoveInput,
199
+ run: async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }),
200
+ };
201
+ const MembershipsInput = z.object({ memberships: inputs.AllMemberships });
202
+ const setMemberships = {
203
+ input: MembershipsInput,
204
+ run: async ({ memberships }) => {
205
+ const result = await store.setMemberships(memberships);
206
+ const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
207
+ const card = catalog.cards.find((c) => c.id === held.id);
208
+ return card ? membershipsFor(card) : [];
209
+ }));
210
+ const unused = memberships.filter((m) => !used.has(m));
211
+ return ctx.changed(result, `${memberships.length > 0
212
+ ? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
213
+ : 'Memberships cleared.'}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${unused.length === 1 ? "doesn't" : "don't"} change any saved card's rates yet; kept for cards that use ${unused.length === 1 ? 'it' : 'them'}.` : ''}`, { notes: false });
214
+ },
215
+ };
216
+ const PointValuesInput = z.object({
217
+ values: z
218
+ .record(z.string(), z.number().nullable())
219
+ .optional()
220
+ .describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
221
+ profile: z
222
+ .enum(['flat', 'points-heavy'])
223
+ .optional()
224
+ .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
225
+ });
226
+ const setPointValues = {
227
+ input: PointValuesInput,
228
+ run: async ({ values, profile }) => {
229
+ const given = currencyChanges(catalog, values ?? {});
230
+ if (!given.ok)
231
+ return fail(given.error);
232
+ const changes = {
233
+ ...(profile ? VALUATION_PROFILES[profile] : {}),
234
+ ...given.changes,
235
+ };
236
+ if (Object.keys(changes).length === 0)
237
+ return fail('Give values (cents per point by currency) or a profile.');
238
+ const result = await store.setValuations(changes);
239
+ const set = Object.entries(changes).map(([id, cents]) => cents === null
240
+ ? `${currencyLabel(catalog, id)} back to the default`
241
+ : `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
242
+ return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
243
+ notes: false,
244
+ });
245
+ },
246
+ };
247
+ return {
248
+ list_wallet: listWallet,
249
+ record_purchase: recordPurchase,
250
+ add_card: addCard,
251
+ update_card: updateCard,
252
+ remove_card: removeCard,
253
+ set_memberships: setMemberships,
254
+ set_point_values: setPointValues,
255
+ };
256
+ }
257
+ export function registerWalletTools(server, deps, ctx, inputs) {
258
+ const { catalog } = deps;
259
+ const tools = walletHandlers(deps, ctx, inputs);
68
260
  server.registerTool('list_wallet', {
69
261
  title: 'List wallet',
70
262
  description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, statement credits to enroll in or use before they reset); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
@@ -75,44 +267,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
75
267
  destructiveHint: false,
76
268
  openWorldHint: false,
77
269
  },
78
- }, async () => {
79
- const read = await ctx.readableWallet();
80
- if (typeof read === 'string')
81
- return fail(read);
82
- const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
83
- const base = walletSummary(catalog, read.wallet, deps.today());
84
- const summary = {
85
- ...base,
86
- started,
87
- checklist: checklist(catalog, read.wallet, deps.today(), base, cardText(catalog).shortName),
88
- };
89
- const when = started === null
90
- ? `This is ${ctx.walletName}.\n`
91
- : `This is ${ctx.walletName}. ${startedText(started, Date.now())}\n`;
92
- return {
93
- content: [
94
- {
95
- type: 'text',
96
- text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
97
- },
98
- ],
99
- structuredContent: summary,
100
- };
101
- });
270
+ }, () => tools.list_wallet.run());
102
271
  server.registerTool('record_purchase', {
103
272
  title: 'Record a purchase',
104
273
  description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend bonuses and statement credits used stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap, no open welcome bonus, no spend bonus and no enrolled statement credit are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
105
- inputSchema: z.object({
106
- cardId: z
107
- .string()
108
- .min(1)
109
- .describe('The card used, as a cardId from list_wallet or best_card_for_purchase.'),
110
- merchant: Merchant,
111
- amount: z.number().positive().describe('Amount spent in US dollars.'),
112
- channel: ChannelInput,
113
- date: DateInput,
114
- category: inputs.RecordCategoryInput,
115
- }),
274
+ inputSchema: tools.record_purchase.input,
116
275
  outputSchema: RecordResultSchema,
117
276
  annotations: {
118
277
  title: 'Record a purchase',
@@ -121,62 +280,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
121
280
  idempotentHint: false,
122
281
  openWorldHint: deps.llm !== null,
123
282
  },
124
- }, async ({ cardId, merchant, amount, channel, date, category }) => {
125
- const w = await ctx.wallet();
126
- if (typeof w === 'string')
127
- return fail(w);
128
- if (!w.cards.some((c) => c.id === cardId))
129
- return fail(`"${cardId}" isn't in the wallet. Cards: ${w.cards.map((c) => c.id).join(', ')}.`);
130
- const purchase = {
131
- cardId,
132
- merchant,
133
- amount,
134
- channel: channel ?? null,
135
- date: date ?? deps.today(),
136
- };
137
- const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
138
- if (resolved.merchant.source === 'none')
139
- return fail(`earnrate doesn't recognize "${merchant}", so it can't tell which caps this purchase counts toward. Nothing was recorded. Call record_purchase again with category if you can tell what kind of business it is (a restaurant, a grocery store, a gas station), or ask the user. If nobody knows, category "other" records it toward a welcome bonus without touching any cap.`);
140
- const result = await store.record(purchase, resolved.merchant);
141
- if (!result.ok)
142
- return fail(`Couldn't record it: ${result.error}`);
143
- const { cap: booking, bonus, milestones, credit } = result;
144
- const capLabel = booking
145
- ? (walletSummary(catalog, w, purchase.date)
146
- .cards.find((c) => c.cardId === cardId)
147
- ?.caps.find((c) => c.key === booking.capKey)?.label ?? null)
148
- : null;
149
- const output = booking || bonus || milestones.length > 0 || credit
150
- ? {
151
- recorded: true,
152
- booking,
153
- bonus,
154
- milestones,
155
- credit,
156
- message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
157
- }
158
- : {
159
- recorded: false,
160
- booking: null,
161
- bonus: null,
162
- milestones: [],
163
- credit: null,
164
- message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend bonus or enrolled statement credit for this purchase, so the wallet wasn't changed.`,
165
- };
166
- return {
167
- content: [
168
- {
169
- type: 'text',
170
- text: [output.message, ...resolved.notices].join('\n'),
171
- },
172
- ],
173
- structuredContent: output,
174
- };
175
- });
283
+ }, (args) => tools.record_purchase.run(args));
176
284
  server.registerTool('add_card', {
177
285
  title: 'Add a card',
178
286
  description: `Adds a card the user holds to their wallet by its id from search_cards, creating the wallet if there isn't one. The reply says what the card still needs (category picks, memberships, activations); ask the user about those. Stores only the card's product id and settings, never card numbers. ${ctx.walletUse}`,
179
- inputSchema: CardChange,
287
+ inputSchema: tools.add_card.input,
180
288
  outputSchema: WalletChangeSchema,
181
289
  annotations: {
182
290
  title: 'Add a card',
@@ -184,19 +292,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
184
292
  destructiveHint: false,
185
293
  idempotentHint: false,
186
294
  },
187
- }, async (args) => {
188
- const result = await store.addCard(cardInput(args), deps.today());
189
- const started = !result.ok || !result.created
190
- ? ''
191
- : deps.savedWallets === 'account'
192
- ? ', the first card in your saved wallet'
193
- : `, starting a new wallet at ${store.where}`;
194
- return ctx.changed(result, `Added ${ctx.cardName(args.cardId)} (${args.cardId})${started}.`);
195
- });
295
+ }, (args) => tools.add_card.run(args));
196
296
  server.registerTool('update_card', {
197
297
  title: 'Update a card',
198
- 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 bonuses (milestoneSpent), statement credits enrolled in (enrolled) and used this period (creditUsed), 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}`,
199
- inputSchema: CardChange,
298
+ 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 bonuses (milestoneSpent), statement credits enrolled in (enrolled), used this period (creditUsed) or not used at all (skippedCredits), 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}`,
299
+ inputSchema: tools.update_card.input,
200
300
  outputSchema: WalletChangeSchema,
201
301
  annotations: {
202
302
  title: 'Update a card',
@@ -204,18 +304,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
204
304
  destructiveHint: true,
205
305
  idempotentHint: true,
206
306
  },
207
- }, async (args) => {
208
- const what = changes(catalog, args);
209
- const card = catalog.cards.find((c) => c.id === args.cardId);
210
- const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
211
- ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
212
- : '';
213
- return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
214
- });
307
+ }, (args) => tools.update_card.run(args));
215
308
  server.registerTool('remove_card', {
216
309
  title: 'Remove a card',
217
310
  description: `Removes a card from the wallet, with its tracked cap usage, bonus progress and yearly spending. Memberships and point values stay, even when the last card goes. Only when the user says they no longer have it or don't want it considered. ${ctx.walletUse}`,
218
- inputSchema: z.object({ cardId: CardId }),
311
+ inputSchema: tools.remove_card.input,
219
312
  outputSchema: WalletChangeSchema,
220
313
  annotations: {
221
314
  title: 'Remove a card',
@@ -223,11 +316,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
223
316
  destructiveHint: true,
224
317
  idempotentHint: true,
225
318
  },
226
- }, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
319
+ }, (args) => tools.remove_card.run(args));
227
320
  server.registerTool('set_memberships', {
228
321
  title: 'Set memberships',
229
322
  description: `Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep. It saves them even with no cards saved, and every answer then uses them. ${ctx.walletUse}`,
230
- inputSchema: z.object({ memberships: inputs.AllMemberships }),
323
+ inputSchema: tools.set_memberships.input,
231
324
  outputSchema: WalletChangeSchema,
232
325
  annotations: {
233
326
  title: 'Set memberships',
@@ -235,30 +328,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
235
328
  destructiveHint: true,
236
329
  idempotentHint: true,
237
330
  },
238
- }, async ({ memberships }) => {
239
- const result = await store.setMemberships(memberships);
240
- const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
241
- const card = catalog.cards.find((c) => c.id === held.id);
242
- return card ? membershipsFor(card) : [];
243
- }));
244
- const unused = memberships.filter((m) => !used.has(m));
245
- return ctx.changed(result, `${memberships.length > 0
246
- ? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
247
- : 'Memberships cleared.'}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${unused.length === 1 ? "doesn't" : "don't"} change any saved card's rates yet; kept for cards that use ${unused.length === 1 ? 'it' : 'them'}.` : ''}`, { notes: false });
248
- });
331
+ }, (args) => tools.set_memberships.run(args));
249
332
  server.registerTool('set_point_values', {
250
333
  title: 'Set point values',
251
334
  description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for. No saved cards are needed. Use it when the user says what their points are worth ("I value Chase points at 1.5¢") or that they transfer points to airlines and hotels (profile points-heavy). Values change only the currencies given; null goes back to earnrate's default. Currencies by id: ${catalog.currencies.map((c) => `${c.id} (${c.name})`).join(', ')}. ${ctx.walletUse}`,
252
- inputSchema: z.object({
253
- values: z
254
- .record(z.string(), z.number().nullable())
255
- .optional()
256
- .describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
257
- profile: z
258
- .enum(['flat', 'points-heavy'])
259
- .optional()
260
- .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
261
- }),
335
+ inputSchema: tools.set_point_values.input,
262
336
  outputSchema: WalletChangeSchema,
263
337
  annotations: {
264
338
  title: 'Set point values',
@@ -266,22 +340,5 @@ export function registerWalletTools(server, deps, ctx, inputs) {
266
340
  destructiveHint: true,
267
341
  idempotentHint: true,
268
342
  },
269
- }, async ({ values, profile }) => {
270
- const given = currencyChanges(catalog, values ?? {});
271
- if (!given.ok)
272
- return fail(given.error);
273
- const changes = {
274
- ...(profile ? VALUATION_PROFILES[profile] : {}),
275
- ...given.changes,
276
- };
277
- if (Object.keys(changes).length === 0)
278
- return fail('Give values (cents per point by currency) or a profile.');
279
- const result = await store.setValuations(changes);
280
- const set = Object.entries(changes).map(([id, cents]) => cents === null
281
- ? `${currencyLabel(catalog, id)} back to the default`
282
- : `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
283
- return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
284
- notes: false,
285
- });
286
- });
343
+ }, (args) => tools.set_point_values.run(args));
287
344
  }
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+ import { makeContext } from "./context.js";
3
+ import { makeInputs } from "./inputs.js";
4
+ import { importHandler } from "./tools/account.js";
5
+ import { walletHandlers, walletListing } from "./tools/wallet.js";
6
+ // The changes earnrate.dev can make to a synced wallet, by MCP tool name.
7
+ // import_wallet replaces it all, with the tool's own confirmation.
8
+ export const WALLET_CHANGES = [
9
+ 'add_card',
10
+ 'update_card',
11
+ 'remove_card',
12
+ 'set_memberships',
13
+ 'set_point_values',
14
+ 'record_purchase',
15
+ 'import_wallet',
16
+ ];
17
+ const ChangeRequest = z.object({
18
+ tool: z.enum(WALLET_CHANGES),
19
+ input: z.record(z.string(), z.unknown()).optional(),
20
+ });
21
+ // The hosted wallet over plain HTTP: reads give what list_wallet gives, and
22
+ // each change runs the MCP tool's own handler, so the rules and refusals
23
+ // can't drift between earnrate.dev and Claude.
24
+ export function walletApi(deps) {
25
+ const server = {
26
+ ...deps,
27
+ savedWallets: 'account',
28
+ llm: null,
29
+ version: '',
30
+ };
31
+ const ctx = makeContext(server);
32
+ const handlers = {
33
+ ...walletHandlers(server, ctx, makeInputs(deps.catalog)),
34
+ import_wallet: importHandler(deps.catalog, deps.store, ctx),
35
+ };
36
+ async function read() {
37
+ const found = await walletListing(server, ctx);
38
+ if (typeof found === 'string')
39
+ return { status: 422, body: { error: found } };
40
+ return {
41
+ status: 200,
42
+ body: { wallet: found.wallet, summary: found.summary },
43
+ };
44
+ }
45
+ // `body` is the request's parsed JSON: {"tool": "add_card", "input": {...}}.
46
+ async function change(body) {
47
+ const request = ChangeRequest.safeParse(body);
48
+ if (!request.success)
49
+ return {
50
+ status: 400,
51
+ body: {
52
+ error: `Send one change as {"tool": ..., "input": {...}}, where tool is one of ${WALLET_CHANGES.join(', ')}.`,
53
+ },
54
+ };
55
+ const { tool, input } = request.data;
56
+ const handler = handlers[tool];
57
+ const args = handler.input.safeParse(input ?? {});
58
+ if (!args.success)
59
+ return {
60
+ status: 400,
61
+ tool,
62
+ body: {
63
+ error: `Invalid input for ${tool}: ${z.prettifyError(args.error)}`,
64
+ },
65
+ };
66
+ const result = await handler.run(args.data);
67
+ const text = result.content.map((c) => c.text).join('\n');
68
+ if (result.isError)
69
+ return { status: 422, tool, body: { error: text } };
70
+ const after = await read();
71
+ return {
72
+ status: after.status,
73
+ tool,
74
+ body: after.status === 200 ? { message: text, ...after.body } : after.body,
75
+ };
76
+ }
77
+ return { read, change };
78
+ }
@@ -23,6 +23,9 @@ export const MilestoneSchema = z
23
23
  period: z.enum(['calendar_year', 'cardmember_year']),
24
24
  points: z.number().positive().optional(), // in the card's currency
25
25
  rewards: z.array(z.string().min(1)).min(1).optional(),
26
+ // The day the issuer starts it, for one announced ahead: spending counts
27
+ // from then.
28
+ valid_from: IsoDate.optional(),
26
29
  note: z.string().optional(),
27
30
  })
28
31
  .refine((m) => m.points !== undefined || m.rewards !== undefined, 'a milestone needs points, rewards or both');
@@ -76,6 +79,9 @@ export const RuleSchema = z
76
79
  geography: z
77
80
  .enum(['us_only', 'foreign_only', 'worldwide'])
78
81
  .default('worldwide'),
82
+ // Only a purchase of at least this many dollars. Unknown without an
83
+ // amount, so then it never applies by assumption.
84
+ min_amount: z.number().positive().optional(),
79
85
  // Inline cap, or the id of a shared cap in the card's `caps`.
80
86
  cap: z.union([CapSchema, KebabId]).optional(),
81
87
  requires: z
@@ -186,6 +192,11 @@ export const CreditSchema = z.strictObject({
186
192
  period: CreditPeriod,
187
193
  // The cardholder must enroll with the issuer before it pays.
188
194
  enroll: z.literal(true).optional(),
195
+ // It pays only on a purchase of at least this many dollars.
196
+ min_amount: z.number().positive().optional(),
197
+ // The issuer's last day for it. Past it the credit counts nowhere, and a
198
+ // year's worth counts only the periods that start by then.
199
+ valid_to: IsoDate.optional(),
189
200
  note: z.string().optional(),
190
201
  });
191
202
  const HexColor = z.string().regex(/^#[0-9a-f]{6}$/);
@@ -46,10 +46,14 @@ export const WalletCardSchema = z.strictObject({
46
46
  activated: z.array(KebabId).default([]),
47
47
  // Keyed by milestone id: every purchase on the card this period.
48
48
  milestone_usage: z.record(KebabId, CapUsage).optional(),
49
- // Merchant credits (the card's `credits`) the cardholder has enrolled in,
50
- // by credit id, and the dollars of each credit already received this period.
49
+ // Merchant credits (the card's `credits`) the cardholder has enrolled in
50
+ // or, for ones that need no enrolling, says they use, by credit id; and
51
+ // the dollars of each credit already received this period.
51
52
  enrolled: z.array(KebabId).optional(),
52
53
  credit_usage: z.record(KebabId, CreditUsage).optional(),
54
+ // Credits the cardholder doesn't use: left out of the pick, the to-do
55
+ // list and the fee math, even if enrolled.
56
+ skipped_credits: z.array(KebabId).optional(),
53
57
  // A welcome bonus in progress. Terms default to the card's published
54
58
  // offer; set spend, by, points or dollars when yours was different.
55
59
  bonus: z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
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",