earnrate 0.2.14 → 0.2.16

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 (45) hide show
  1. package/README.md +10 -10
  2. package/data/CHANGELOG.md +12 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
  5. package/data/cards/discover-it-cash-back.yaml +1 -1
  6. package/data/cards/venmo-credit-card.yaml +1 -1
  7. package/data/categories.yaml +10 -0
  8. package/dist/cli/args.js +30 -1
  9. package/dist/cli/import.js +14 -11
  10. package/dist/cli/run.js +10 -1
  11. package/dist/cli/todo.js +3 -2
  12. package/dist/cli/wallet.js +49 -16
  13. package/dist/engine/cheat-sheet.js +13 -10
  14. package/dist/engine/checklist.js +5 -10
  15. package/dist/engine/credits.js +2 -2
  16. package/dist/engine/evaluate.js +1 -1
  17. package/dist/engine/explore.js +7 -3
  18. package/dist/engine/intro.js +1 -5
  19. package/dist/engine/memberships.js +3 -7
  20. package/dist/engine/offers.js +3 -5
  21. package/dist/engine/periods.js +5 -0
  22. package/dist/engine/profiles.js +2 -1
  23. package/dist/engine/recommend.js +19 -17
  24. package/dist/engine/record.js +1 -1
  25. package/dist/engine/rule-text.js +71 -23
  26. package/dist/engine/setup.js +4 -0
  27. package/dist/engine/text.js +23 -6
  28. package/dist/engine/wallet-change.js +5 -5
  29. package/dist/engine/wallet-summary.js +32 -23
  30. package/dist/format/card-text.js +6 -5
  31. package/dist/format/cards.js +2 -2
  32. package/dist/format/explore.js +2 -2
  33. package/dist/format/wallet-yaml.js +2 -2
  34. package/dist/format/wallet.js +5 -5
  35. package/dist/import/analyze.js +13 -1
  36. package/dist/mcp/context.js +2 -3
  37. package/dist/mcp/inputs.js +11 -3
  38. package/dist/mcp/schemas.js +2 -0
  39. package/dist/mcp/server.js +0 -2
  40. package/dist/mcp/tools/account.js +50 -42
  41. package/dist/mcp/tools/answers.js +8 -4
  42. package/dist/mcp/tools/wallet.js +205 -148
  43. package/dist/mcp/wallet-api.js +78 -0
  44. package/dist/schema/card.js +6 -10
  45. package/package.json +2 -1
@@ -63,12 +63,199 @@ function changes(catalog, args) {
63
63
  out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
64
64
  return out;
65
65
  }
66
- // The tools that read or change a saved wallet.
67
- export function registerWalletTools(server, deps, ctx, inputs) {
66
+ // The saved wallet with its summary and to-do list, as list_wallet shows it.
67
+ export async function walletListing(deps, ctx) {
68
+ const read = await ctx.readableWallet();
69
+ if (typeof read === 'string')
70
+ return read;
71
+ const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
72
+ const base = walletSummary(deps.catalog, read.wallet, deps.today());
73
+ const summary = {
74
+ ...base,
75
+ started,
76
+ checklist: checklist(deps.catalog, read.wallet, deps.today(), base, cardText(deps.catalog).shortName),
77
+ };
78
+ return { ...read, summary };
79
+ }
80
+ export function walletHandlers(deps, ctx, inputs) {
68
81
  const { catalog, store } = deps;
82
+ const listWallet = {
83
+ input: z.object({}),
84
+ run: async () => {
85
+ const found = await walletListing(deps, ctx);
86
+ if (typeof found === 'string')
87
+ return fail(found);
88
+ const { start, summary } = found;
89
+ const when = summary.started === null
90
+ ? `This is ${ctx.walletName}.\n`
91
+ : `This is ${ctx.walletName}. ${startedText(summary.started, Date.now())}\n`;
92
+ return {
93
+ content: [
94
+ {
95
+ type: 'text',
96
+ text: `${when}${start === null ? '' : `${start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
97
+ },
98
+ ],
99
+ structuredContent: summary,
100
+ };
101
+ },
102
+ };
103
+ const RecordInput = z.object({
104
+ cardId: z
105
+ .string()
106
+ .min(1)
107
+ .describe('The card used, as a cardId from list_wallet or best_card_for_purchase.'),
108
+ merchant: Merchant,
109
+ amount: z.number().positive().describe('Amount spent in US dollars.'),
110
+ channel: ChannelInput,
111
+ date: DateInput,
112
+ category: inputs.RecordCategoryInput,
113
+ });
114
+ const recordPurchase = {
115
+ input: RecordInput,
116
+ run: async ({ cardId, merchant, amount, channel, date, category }) => {
117
+ const w = await ctx.wallet();
118
+ if (typeof w === 'string')
119
+ return fail(w);
120
+ if (!w.cards.some((c) => c.id === cardId))
121
+ return fail(`"${cardId}" isn't in the wallet. Cards: ${w.cards.map((c) => c.id).join(', ')}.`);
122
+ const purchase = {
123
+ cardId,
124
+ merchant,
125
+ amount,
126
+ channel: channel ?? null,
127
+ date: date ?? deps.today(),
128
+ };
129
+ const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
130
+ if (resolved.merchant.source === 'none')
131
+ 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.`);
132
+ const result = await store.record(purchase, resolved.merchant);
133
+ if (!result.ok)
134
+ return fail(`Couldn't record it: ${result.error}`);
135
+ const { cap: booking, bonus, milestones, credit } = result;
136
+ const capLabel = booking
137
+ ? (walletSummary(catalog, w, purchase.date)
138
+ .cards.find((c) => c.cardId === cardId)
139
+ ?.caps.find((c) => c.key === booking.capKey)?.label ?? null)
140
+ : null;
141
+ const output = booking || bonus || milestones.length > 0 || credit
142
+ ? {
143
+ recorded: true,
144
+ booking,
145
+ bonus,
146
+ milestones,
147
+ credit,
148
+ message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
149
+ }
150
+ : {
151
+ recorded: false,
152
+ booking: null,
153
+ bonus: null,
154
+ milestones: [],
155
+ credit: null,
156
+ 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.`,
157
+ };
158
+ return {
159
+ content: [
160
+ {
161
+ type: 'text',
162
+ text: [output.message, ...resolved.notices].join('\n'),
163
+ },
164
+ ],
165
+ structuredContent: output,
166
+ };
167
+ },
168
+ };
169
+ const addCard = {
170
+ input: CardChange,
171
+ run: async (args) => {
172
+ const result = await store.addCard(cardInput(args), deps.today());
173
+ const started = !result.ok || !result.created
174
+ ? ''
175
+ : deps.savedWallets === 'account'
176
+ ? ', the first card in your saved wallet'
177
+ : `, starting a new wallet at ${store.where}`;
178
+ return ctx.changed(result, `Added ${ctx.cardName(args.cardId)} (${args.cardId})${started}.`);
179
+ },
180
+ };
181
+ const updateCard = {
182
+ input: CardChange,
183
+ run: async (args) => {
184
+ const what = changes(catalog, args);
185
+ const card = catalog.cards.find((c) => c.id === args.cardId);
186
+ const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
187
+ ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
188
+ : '';
189
+ return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
190
+ },
191
+ };
192
+ const RemoveInput = z.object({ cardId: CardId });
193
+ const removeCard = {
194
+ input: RemoveInput,
195
+ run: async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }),
196
+ };
197
+ const MembershipsInput = z.object({ memberships: inputs.AllMemberships });
198
+ const setMemberships = {
199
+ input: MembershipsInput,
200
+ run: async ({ memberships }) => {
201
+ const result = await store.setMemberships(memberships);
202
+ const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
203
+ const card = catalog.cards.find((c) => c.id === held.id);
204
+ return card ? membershipsFor(card) : [];
205
+ }));
206
+ const unused = memberships.filter((m) => !used.has(m));
207
+ return ctx.changed(result, `${memberships.length > 0
208
+ ? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
209
+ : '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 });
210
+ },
211
+ };
212
+ const PointValuesInput = z.object({
213
+ values: z
214
+ .record(z.string(), z.number().nullable())
215
+ .optional()
216
+ .describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
217
+ profile: z
218
+ .enum(['flat', 'points-heavy'])
219
+ .optional()
220
+ .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
221
+ });
222
+ const setPointValues = {
223
+ input: PointValuesInput,
224
+ run: async ({ values, profile }) => {
225
+ const given = currencyChanges(catalog, values ?? {});
226
+ if (!given.ok)
227
+ return fail(given.error);
228
+ const changes = {
229
+ ...(profile ? VALUATION_PROFILES[profile] : {}),
230
+ ...given.changes,
231
+ };
232
+ if (Object.keys(changes).length === 0)
233
+ return fail('Give values (cents per point by currency) or a profile.');
234
+ const result = await store.setValuations(changes);
235
+ const set = Object.entries(changes).map(([id, cents]) => cents === null
236
+ ? `${currencyLabel(catalog, id)} back to the default`
237
+ : `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
238
+ return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
239
+ notes: false,
240
+ });
241
+ },
242
+ };
243
+ return {
244
+ list_wallet: listWallet,
245
+ record_purchase: recordPurchase,
246
+ add_card: addCard,
247
+ update_card: updateCard,
248
+ remove_card: removeCard,
249
+ set_memberships: setMemberships,
250
+ set_point_values: setPointValues,
251
+ };
252
+ }
253
+ export function registerWalletTools(server, deps, ctx, inputs) {
254
+ const { catalog } = deps;
255
+ const tools = walletHandlers(deps, ctx, inputs);
69
256
  server.registerTool('list_wallet', {
70
257
  title: 'List wallet',
71
- 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, merchant 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, merchant 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}`,
258
+ 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}`,
72
259
  outputSchema: WalletSummarySchema,
73
260
  annotations: {
74
261
  title: 'List wallet',
@@ -76,44 +263,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
76
263
  destructiveHint: false,
77
264
  openWorldHint: false,
78
265
  },
79
- }, async () => {
80
- const read = await ctx.readableWallet();
81
- if (typeof read === 'string')
82
- return fail(read);
83
- const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
84
- const base = walletSummary(catalog, read.wallet, deps.today());
85
- const summary = {
86
- ...base,
87
- started,
88
- checklist: checklist(catalog, read.wallet, deps.today(), base, cardText(catalog).shortName),
89
- };
90
- const when = started === null
91
- ? `This is ${ctx.walletName}.\n`
92
- : `This is ${ctx.walletName}. ${startedText(started, Date.now())}\n`;
93
- return {
94
- content: [
95
- {
96
- type: 'text',
97
- text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
98
- },
99
- ],
100
- structuredContent: summary,
101
- };
102
- });
266
+ }, () => tools.list_wallet.run());
103
267
  server.registerTool('record_purchase', {
104
268
  title: 'Record a purchase',
105
- description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend milestones and merchant 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 milestone and no enrolled merchant 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}`,
106
- inputSchema: z.object({
107
- cardId: z
108
- .string()
109
- .min(1)
110
- .describe('The card used, as a cardId from list_wallet or best_card_for_purchase.'),
111
- merchant: Merchant,
112
- amount: z.number().positive().describe('Amount spent in US dollars.'),
113
- channel: ChannelInput,
114
- date: DateInput,
115
- category: inputs.RecordCategoryInput,
116
- }),
269
+ 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}`,
270
+ inputSchema: tools.record_purchase.input,
117
271
  outputSchema: RecordResultSchema,
118
272
  annotations: {
119
273
  title: 'Record a purchase',
@@ -122,57 +276,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
122
276
  idempotentHint: false,
123
277
  openWorldHint: deps.llm !== null,
124
278
  },
125
- }, async ({ cardId, merchant, amount, channel, date, category }) => {
126
- const w = await ctx.wallet();
127
- if (typeof w === 'string')
128
- return fail(w);
129
- if (!w.cards.some((c) => c.id === cardId))
130
- return fail(`"${cardId}" isn't in the wallet. Cards: ${w.cards.map((c) => c.id).join(', ')}.`);
131
- const purchase = {
132
- cardId,
133
- merchant,
134
- amount,
135
- channel: channel ?? null,
136
- date: date ?? deps.today(),
137
- };
138
- const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
139
- if (resolved.merchant.source === 'none')
140
- 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.`);
141
- const result = await store.record(purchase, resolved.merchant);
142
- if (!result.ok)
143
- return fail(`Couldn't record it: ${result.error}`);
144
- const { cap: booking, bonus, milestones, credit } = result;
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)} 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 milestone or enrolled merchant 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
- });
279
+ }, (args) => tools.record_purchase.run(args));
172
280
  server.registerTool('add_card', {
173
281
  title: 'Add a card',
174
282
  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}`,
175
- inputSchema: CardChange,
283
+ inputSchema: tools.add_card.input,
176
284
  outputSchema: WalletChangeSchema,
177
285
  annotations: {
178
286
  title: 'Add a card',
@@ -180,19 +288,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
180
288
  destructiveHint: false,
181
289
  idempotentHint: false,
182
290
  },
183
- }, async (args) => {
184
- const result = await store.addCard(cardInput(args), deps.today());
185
- const started = !result.ok || !result.created
186
- ? ''
187
- : deps.savedWallets === 'account'
188
- ? ', the first card in your saved wallet'
189
- : `, starting a new wallet at ${store.where}`;
190
- return ctx.changed(result, `Added ${ctx.cardName(args.cardId)} (${args.cardId})${started}.`);
191
- });
291
+ }, (args) => tools.add_card.run(args));
192
292
  server.registerTool('update_card', {
193
293
  title: 'Update a card',
194
- 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), merchant 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}`,
195
- inputSchema: CardChange,
294
+ 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}`,
295
+ inputSchema: tools.update_card.input,
196
296
  outputSchema: WalletChangeSchema,
197
297
  annotations: {
198
298
  title: 'Update a card',
@@ -200,18 +300,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
200
300
  destructiveHint: true,
201
301
  idempotentHint: true,
202
302
  },
203
- }, async (args) => {
204
- const what = changes(catalog, args);
205
- const card = catalog.cards.find((c) => c.id === args.cardId);
206
- const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
207
- ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
208
- : '';
209
- return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
210
- });
303
+ }, (args) => tools.update_card.run(args));
211
304
  server.registerTool('remove_card', {
212
305
  title: 'Remove a card',
213
306
  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}`,
214
- inputSchema: z.object({ cardId: CardId }),
307
+ inputSchema: tools.remove_card.input,
215
308
  outputSchema: WalletChangeSchema,
216
309
  annotations: {
217
310
  title: 'Remove a card',
@@ -219,11 +312,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
219
312
  destructiveHint: true,
220
313
  idempotentHint: true,
221
314
  },
222
- }, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
315
+ }, (args) => tools.remove_card.run(args));
223
316
  server.registerTool('set_memberships', {
224
317
  title: 'Set memberships',
225
318
  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}`,
226
- inputSchema: z.object({ memberships: inputs.AllMemberships }),
319
+ inputSchema: tools.set_memberships.input,
227
320
  outputSchema: WalletChangeSchema,
228
321
  annotations: {
229
322
  title: 'Set memberships',
@@ -231,30 +324,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
231
324
  destructiveHint: true,
232
325
  idempotentHint: true,
233
326
  },
234
- }, async ({ memberships }) => {
235
- const result = await store.setMemberships(memberships);
236
- const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
237
- const card = catalog.cards.find((c) => c.id === held.id);
238
- return card ? membershipsFor(card) : [];
239
- }));
240
- const unused = memberships.filter((m) => !used.has(m));
241
- return ctx.changed(result, `${memberships.length > 0
242
- ? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
243
- : '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 });
244
- });
327
+ }, (args) => tools.set_memberships.run(args));
245
328
  server.registerTool('set_point_values', {
246
329
  title: 'Set point values',
247
330
  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}`,
248
- inputSchema: z.object({
249
- values: z
250
- .record(z.string(), z.number().nullable())
251
- .optional()
252
- .describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
253
- profile: z
254
- .enum(['flat', 'points-heavy'])
255
- .optional()
256
- .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
257
- }),
331
+ inputSchema: tools.set_point_values.input,
258
332
  outputSchema: WalletChangeSchema,
259
333
  annotations: {
260
334
  title: 'Set point values',
@@ -262,22 +336,5 @@ export function registerWalletTools(server, deps, ctx, inputs) {
262
336
  destructiveHint: true,
263
337
  idempotentHint: true,
264
338
  },
265
- }, async ({ values, profile }) => {
266
- const given = currencyChanges(catalog, values ?? {});
267
- if (!given.ok)
268
- return fail(given.error);
269
- const changes = {
270
- ...(profile ? VALUATION_PROFILES[profile] : {}),
271
- ...given.changes,
272
- };
273
- if (Object.keys(changes).length === 0)
274
- return fail('Give values (cents per point by currency) or a profile.');
275
- const result = await store.setValuations(changes);
276
- const set = Object.entries(changes).map(([id, cents]) => cents === null
277
- ? `${currencyLabel(catalog, id)} back to the default`
278
- : `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
279
- return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
280
- notes: false,
281
- });
282
- });
339
+ }, (args) => tools.set_point_values.run(args));
283
340
  }
@@ -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
+ }
@@ -157,11 +157,9 @@ export const ChoiceSchema = z.strictObject({
157
157
  })),
158
158
  ])),
159
159
  });
160
- // A rate for a stretch after the account opens, counted from the opening
161
- // day: Discover's Cashback Match doubles every rate for the first 365 days,
162
- // Customized Cash adds 3% to its choice category. With `rules`, it changes
163
- // only those rules' rates, inside their caps; past a cap they earn what they
164
- // always do. Without, it changes every rate on the card, past caps included.
160
+ // Counted from the opening day. With `rules`, it changes only those rules'
161
+ // rates, inside their caps; past a cap they earn what they always do.
162
+ // Without, it changes every rate on the card, past caps included.
165
163
  export const IntroRateSchema = z
166
164
  .strictObject({
167
165
  id: KebabId,
@@ -172,11 +170,9 @@ export const IntroRateSchema = z
172
170
  note: z.string().optional(),
173
171
  })
174
172
  .refine((i) => (i.multiply === undefined) !== (i.add === undefined), 'an intro rate needs multiply or add, not both');
175
- // Merchant statement credits: dollars back for spending at named merchants
176
- // (or a category) up to `amount` each period, like Amex Platinum's $75 a
177
- // quarter at lululemon. They're separate from earn rates: the purchase still
178
- // earns the card's rate on its whole amount, and the credit comes on top.
179
- // `half_year` runs January to June and July to December.
173
+ // Separate from earn rates: the purchase still earns the card's rate on its
174
+ // whole amount, and the credit comes on top. `half_year` runs January to June
175
+ // and July to December.
180
176
  export const CreditPeriod = z.enum([...CapPeriod.options, 'half_year']);
181
177
  export const CreditSchema = z.strictObject({
182
178
  id: KebabId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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",
@@ -54,6 +54,7 @@
54
54
  "monitor:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc",
55
55
  "site:build": "vite build site && node scripts/build-pages.ts",
56
56
  "site:dev": "vite site",
57
+ "site:preview": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy --config wrangler.preview.jsonc",
57
58
  "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy && node scripts/indexnow.ts",
58
59
  "test": "vitest run",
59
60
  "test:watch": "vitest",