earnrate 0.1.4 → 0.2.1

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 (53) hide show
  1. package/README.md +52 -17
  2. package/data/CHANGELOG.md +8 -0
  3. package/data/VERSION +1 -1
  4. package/data/categories.yaml +22 -7
  5. package/dist/brand.js +2 -3
  6. package/dist/cli/args.js +67 -45
  7. package/dist/cli/cheatsheet.js +36 -35
  8. package/dist/cli/explore.js +47 -163
  9. package/dist/cli/import.js +3 -8
  10. package/dist/cli/offers.js +14 -56
  11. package/dist/cli/run.js +90 -20
  12. package/dist/cli/wallet.js +47 -6
  13. package/dist/data/file-store.js +95 -0
  14. package/dist/data/load.js +10 -2
  15. package/dist/data/store.js +92 -0
  16. package/dist/data/wallet-edit.js +93 -4
  17. package/dist/engine/bonus.js +15 -12
  18. package/dist/engine/cheat-sheet.js +59 -4
  19. package/dist/engine/evaluate.js +34 -10
  20. package/dist/engine/explore.js +16 -28
  21. package/dist/engine/offers.js +8 -21
  22. package/dist/engine/periods.js +9 -0
  23. package/dist/engine/recommend.js +17 -53
  24. package/dist/engine/record.js +34 -4
  25. package/dist/engine/simulate.js +33 -19
  26. package/dist/engine/text.js +53 -0
  27. package/dist/engine/transient.js +26 -0
  28. package/dist/engine/valuation.js +1 -1
  29. package/dist/engine/wallet-change.js +36 -15
  30. package/dist/engine/wallet-summary.js +4 -8
  31. package/dist/format/card-text.js +79 -0
  32. package/dist/format/cheat-sheet.js +19 -0
  33. package/dist/format/explore.js +114 -0
  34. package/dist/format/offers.js +33 -0
  35. package/dist/{cli/format.js → format/recommendation.js} +9 -11
  36. package/dist/format/wallet-yaml.js +45 -0
  37. package/dist/format/wallet.js +38 -0
  38. package/dist/import/analyze.js +17 -19
  39. package/dist/mcp/context.js +92 -0
  40. package/dist/mcp/inputs.js +148 -0
  41. package/dist/mcp/schemas.js +8 -2
  42. package/dist/mcp/serve.js +3 -1
  43. package/dist/mcp/server.js +43 -660
  44. package/dist/mcp/tools/account.js +71 -0
  45. package/dist/mcp/tools/answers.js +329 -0
  46. package/dist/mcp/tools/wallet.js +128 -0
  47. package/dist/merchants/from-env.js +1 -1
  48. package/dist/merchants/resolve.js +3 -3
  49. package/dist/schema/catalog.js +1 -0
  50. package/package.json +8 -3
  51. package/dist/data/wallet-setup.js +0 -60
  52. package/dist/data/wallet-write.js +0 -140
  53. /package/dist/{cli → format}/cards.js +0 -0
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import { parseWalletYaml, walletYaml } from "../../format/wallet-yaml.js";
3
+ import { fail } from "../context.js";
4
+ import { WalletChangeSchema } from "../schemas.js";
5
+ // A saved wallet on the hosted server has no file to copy or delete, so
6
+ // these do it.
7
+ export function registerAccountTools(server, catalog, store, ctx) {
8
+ server.registerTool('export_wallet', {
9
+ title: 'Export the wallet',
10
+ description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, cap usage and bonus progress. Never card numbers.',
11
+ inputSchema: z.object({}),
12
+ outputSchema: z.object({ yaml: z.string() }),
13
+ annotations: { readOnlyHint: true, openWorldHint: false },
14
+ }, async () => {
15
+ const w = await ctx.wallet();
16
+ if (typeof w === 'string')
17
+ return fail(w);
18
+ const yaml = walletYaml(w);
19
+ return {
20
+ content: [{ type: 'text', text: yaml }],
21
+ structuredContent: { yaml },
22
+ };
23
+ });
24
+ server.registerTool('import_wallet', {
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.',
27
+ inputSchema: z.object({
28
+ yaml: z
29
+ .string()
30
+ .min(1)
31
+ .max(100_000)
32
+ .describe('The wallet.yaml text, as the user gave it.'),
33
+ }),
34
+ outputSchema: WalletChangeSchema,
35
+ annotations: {
36
+ readOnlyHint: false,
37
+ destructiveHint: true,
38
+ idempotentHint: true,
39
+ openWorldHint: false,
40
+ },
41
+ }, async ({ yaml }) => {
42
+ const parsed = parseWalletYaml(yaml, catalog);
43
+ if (typeof parsed === 'string')
44
+ return fail(parsed);
45
+ const count = parsed.cards.length;
46
+ return ctx.changed(await store.replace(parsed), `Imported a wallet with ${String(count)} ${count === 1 ? 'card' : 'cards'}.`, { notes: false });
47
+ });
48
+ server.registerTool('delete_wallet', {
49
+ 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({}),
52
+ outputSchema: z.object({ deleted: z.boolean() }),
53
+ annotations: {
54
+ readOnlyHint: false,
55
+ destructiveHint: true,
56
+ idempotentHint: true,
57
+ openWorldHint: false,
58
+ },
59
+ }, async () => {
60
+ await store.erase();
61
+ return {
62
+ content: [
63
+ {
64
+ type: 'text',
65
+ text: 'Deleted the saved wallet. Answers now cover every card, or the cards named in the conversation.',
66
+ },
67
+ ],
68
+ structuredContent: { deleted: true },
69
+ };
70
+ });
71
+ }
@@ -0,0 +1,329 @@
1
+ import { z } from 'zod';
2
+ import { catalogCheatSheet, cheatSheet } from "../../engine/cheat-sheet.js";
3
+ import { exploreCards } from "../../engine/explore.js";
4
+ import { compareOffers } from "../../engine/offers.js";
5
+ import { withValuationProfile } from "../../engine/profiles.js";
6
+ import { recommend } from "../../engine/recommend.js";
7
+ import { searchCards } from "../../engine/setup.js";
8
+ import { resolveSpendLines, simulateAdditions, simulateCatalog, TYPICAL_SPEND_NOTE, typicalSpendLines, } from "../../engine/simulate.js";
9
+ import { currencyName } from "../../engine/text.js";
10
+ import { EMPTY_WALLET } from "../../engine/transient.js";
11
+ import { formatCardInfo } from "../../format/cards.js";
12
+ import { formatCheatSheet } from "../../format/cheat-sheet.js";
13
+ import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../../format/explore.js";
14
+ import { formatOffers } from "../../format/offers.js";
15
+ import { formatRecommendation } from "../../format/recommendation.js";
16
+ import { ALL_CARDS_NOTE, fail } from "../context.js";
17
+ import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
18
+ import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
19
+ const TOP = 10;
20
+ // The tools that answer without a saved wallet: from every card, the cards
21
+ // named in the conversation, or a saved wallet if there is one.
22
+ export function registerAnswerTools(server, deps, ctx, inputs) {
23
+ const { catalog } = deps;
24
+ const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
25
+ server.registerTool('best_card_for_purchase', {
26
+ title: 'Best card for a purchase',
27
+ description: "Which card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. Never needs or returns card numbers.",
28
+ inputSchema: z.object({
29
+ merchant: Merchant,
30
+ amount: z
31
+ .number()
32
+ .positive()
33
+ .optional()
34
+ .describe('Purchase amount in US dollars. Improves cap math.'),
35
+ channel: ChannelInput,
36
+ date: DateInput,
37
+ category: CategoryInput,
38
+ cards: NamedCards,
39
+ memberships: NamedMemberships,
40
+ }),
41
+ outputSchema: PurchaseAnswerSchema,
42
+ annotations: {
43
+ readOnlyHint: true,
44
+ openWorldHint: deps.llm !== null,
45
+ },
46
+ }, async ({ merchant, amount, channel, date, category, cards, memberships, }) => {
47
+ const wallet = await ctx.cardsFor(cards, memberships);
48
+ if (typeof wallet === 'string')
49
+ return fail(wallet);
50
+ const purchase = {
51
+ merchant,
52
+ amount: amount ?? null,
53
+ channel: channel ?? null,
54
+ date: date ?? deps.today(),
55
+ country: 'US',
56
+ };
57
+ const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
58
+ if (wallet === null) {
59
+ const explored = exploreCards(catalog, {
60
+ merchant,
61
+ category: null,
62
+ amount: purchase.amount,
63
+ channel: purchase.channel,
64
+ date: purchase.date,
65
+ }, settings({ memberships }), resolved.merchant);
66
+ const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
67
+ const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
68
+ return {
69
+ content: [
70
+ {
71
+ type: 'text',
72
+ text: `${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
73
+ },
74
+ ],
75
+ structuredContent: {
76
+ ...empty,
77
+ warnings: [ALL_CARDS_NOTE, ...resolved.notices],
78
+ allCards,
79
+ },
80
+ };
81
+ }
82
+ const rec = recommend(catalog, wallet, purchase, resolved.merchant);
83
+ const output = {
84
+ ...rec,
85
+ warnings: [...rec.warnings, ...resolved.notices],
86
+ allCards: null,
87
+ };
88
+ return {
89
+ content: [
90
+ {
91
+ type: 'text',
92
+ text: formatRecommendation(output, purchase, { all: true }),
93
+ },
94
+ ],
95
+ structuredContent: output,
96
+ };
97
+ });
98
+ server.registerTool('resolve_merchant', {
99
+ title: 'Resolve a merchant',
100
+ description: 'Shows how earnrate identifies a merchant: its spend category, likely MCC, which card networks it takes, confidence, and whether that came from curated data, Claude, or nowhere. Use it to explain or double-check a recommendation.',
101
+ inputSchema: z.object({
102
+ merchant: Merchant,
103
+ channel: ChannelInput,
104
+ category: CategoryInput,
105
+ }),
106
+ outputSchema: MerchantResolutionSchema,
107
+ annotations: { readOnlyHint: true, openWorldHint: deps.llm !== null },
108
+ }, async ({ merchant, channel, category }) => {
109
+ const resolved = await ctx.resolve(merchant, channel ?? null, category ?? null);
110
+ const m = resolved.merchant;
111
+ const text = [
112
+ `${m.resolvedName}: ${m.category}${m.likelyMcc === null ? '' : ` (MCC ${String(m.likelyMcc)})`}, ${m.confidence} confidence, from ${m.source}.`,
113
+ ...(m.inference
114
+ ? [`Claude's reasoning: ${m.inference.rationale}`]
115
+ : []),
116
+ ...resolved.notices,
117
+ ].join('\n');
118
+ return {
119
+ content: [{ type: 'text', text }],
120
+ structuredContent: m,
121
+ };
122
+ });
123
+ server.registerTool('compare_welcome_offers', {
124
+ title: 'Compare welcome offers',
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
+ inputSchema: z.object({
127
+ ...OfferFilters,
128
+ includeHeld: z
129
+ .boolean()
130
+ .optional()
131
+ .describe('Include cards already in the wallet.'),
132
+ cards: NamedCards,
133
+ }),
134
+ outputSchema: OfferComparisonSchema,
135
+ annotations: { readOnlyHint: true, openWorldHint: false },
136
+ }, async (args) => {
137
+ const wallet = await ctx.cardsFor(args.cards, undefined);
138
+ if (typeof wallet === 'string')
139
+ return fail(wallet);
140
+ const comparison = compareOffers(catalog, wallet ?? EMPTY_WALLET, {
141
+ kind: args.kind ?? 'any',
142
+ noAnnualFee: args.noAnnualFee ?? false,
143
+ business: args.business ?? false,
144
+ includeHeld: args.includeHeld ?? false,
145
+ }, deps.today());
146
+ const trimmed = {
147
+ ...comparison,
148
+ offers: comparison.offers.slice(0, TOP),
149
+ };
150
+ const more = comparison.offers.length - trimmed.offers.length;
151
+ return {
152
+ content: [
153
+ {
154
+ type: 'text',
155
+ text: formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
156
+ (more > 0
157
+ ? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers. Narrow them with kind, noAnnualFee or business.\n`
158
+ : ''),
159
+ },
160
+ ],
161
+ structuredContent: trimmed,
162
+ };
163
+ });
164
+ server.registerTool('best_cards_for', {
165
+ title: 'Best cards for a purchase',
166
+ description: 'Ranks every card earnrate knows, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
167
+ inputSchema: z.object({
168
+ merchant: Merchant.optional(),
169
+ category: CatalogCategory.optional(),
170
+ amount: z
171
+ .number()
172
+ .positive()
173
+ .optional()
174
+ .describe('Purchase amount in US dollars, if known.'),
175
+ channel: ChannelInput,
176
+ ...CatalogFilters,
177
+ }),
178
+ outputSchema: ExplorationSchema,
179
+ annotations: { readOnlyHint: true, openWorldHint: false },
180
+ }, (args) => {
181
+ if (args.merchant === undefined && args.category === undefined)
182
+ return fail('Give a merchant or a category.');
183
+ const result = exploreCards(catalog, {
184
+ merchant: args.merchant ?? null,
185
+ category: args.merchant === undefined ? (args.category ?? null) : null,
186
+ amount: args.amount ?? null,
187
+ channel: args.channel ?? null,
188
+ date: deps.today(),
189
+ }, settings(args));
190
+ const trimmed = { ...result, ranked: result.ranked.slice(0, TOP) };
191
+ return {
192
+ content: [{ type: 'text', text: formatExploration(trimmed, true) }],
193
+ structuredContent: trimmed,
194
+ };
195
+ });
196
+ server.registerTool('simulate_spending', {
197
+ title: 'Simulate a year of spending',
198
+ description: 'Spends monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees. Spending is optional: without it, typical U.S. household amounts are used and the answer says so. With `cards` (named in the conversation) or compareToWallet (the saved wallet), it ranks the cards that would add the most to those cards instead. Use it for "which card fits my spending?" or "is it worth adding a card?". It leaves out welcome bonuses.',
199
+ inputSchema: z.object({
200
+ spending: z
201
+ .array(z.object({
202
+ merchant: Merchant.optional(),
203
+ category: CatalogCategory.optional(),
204
+ monthly: z.number().positive().describe('US dollars a month.'),
205
+ }))
206
+ .optional()
207
+ .describe("Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco). Optional: leave out to use typical amounts, and don't press the user for numbers they haven't offered."),
208
+ cards: NamedCards,
209
+ compareToWallet: z
210
+ .boolean()
211
+ .optional()
212
+ .describe("Rank the cards that would add the most to the user's saved wallet."),
213
+ ...CatalogFilters,
214
+ }),
215
+ outputSchema: SpendingSimulationSchema,
216
+ annotations: { readOnlyHint: true, openWorldHint: false },
217
+ }, async (args) => {
218
+ const given = args.spending ?? [];
219
+ if (given.some((l) => l.merchant === undefined && l.category === undefined))
220
+ return fail('Each spending line needs a merchant or a category.');
221
+ const lines = given.length === 0
222
+ ? typicalSpendLines(catalog)
223
+ : resolveSpendLines(catalog, given.map((l) => ({
224
+ merchant: l.merchant ?? null,
225
+ category: l.merchant === undefined ? (l.category ?? null) : null,
226
+ monthly: l.monthly,
227
+ })));
228
+ const unknown = lines.filter((l) => l.merchant.source === 'none');
229
+ if (unknown.length > 0)
230
+ return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
231
+ const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
232
+ const options = { ...settings(args), start: deps.today() };
233
+ const wantsTheirs = (args.cards !== undefined && args.cards.length > 0) ||
234
+ args.compareToWallet === true;
235
+ const wallet = wantsTheirs
236
+ ? await ctx.cardsFor(args.cards, args.memberships)
237
+ : null;
238
+ if (typeof wallet === 'string')
239
+ return fail(wallet);
240
+ if (wallet !== null) {
241
+ const valued = args.valuation
242
+ ? withValuationProfile(wallet, args.valuation)
243
+ : wallet;
244
+ const result = simulateAdditions(catalog, valued, lines, options);
245
+ const text = formatAdditions(result, catalog, false);
246
+ return {
247
+ content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
248
+ structuredContent: {
249
+ notes,
250
+ singles: [],
251
+ pairs: [],
252
+ current: result.current,
253
+ additions: result.additions
254
+ .slice(0, TOP)
255
+ .map(({ simulation: _, ...a }) => a),
256
+ },
257
+ };
258
+ }
259
+ if (wantsTheirs)
260
+ notes.push(ALL_CARDS_NOTE);
261
+ const result = simulateCatalog(catalog, lines, options);
262
+ const text = formatCatalogSimulation(result, catalog, false);
263
+ return {
264
+ content: [{ type: 'text', text: [...notes, text].join('\n\n') }],
265
+ structuredContent: {
266
+ notes,
267
+ singles: result.singles.slice(0, TOP),
268
+ pairs: result.pairs.slice(0, 5),
269
+ current: null,
270
+ additions: [],
271
+ },
272
+ };
273
+ });
274
+ server.registerTool('cheat_sheet', {
275
+ 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.",
277
+ inputSchema: z.object({
278
+ date: DateInput,
279
+ cards: NamedCards,
280
+ memberships: NamedMemberships,
281
+ }),
282
+ outputSchema: CheatSheetSchema,
283
+ annotations: { readOnlyHint: true, openWorldHint: false },
284
+ }, async (args) => {
285
+ const wallet = await ctx.cardsFor(args.cards, args.memberships);
286
+ if (typeof wallet === 'string')
287
+ return fail(wallet);
288
+ const date = args.date ?? deps.today();
289
+ const sheet = wallet === null
290
+ ? {
291
+ ...catalogCheatSheet(catalog, date, settings({ memberships: args.memberships })),
292
+ notes: [ALL_CARDS_NOTE],
293
+ }
294
+ : cheatSheet(catalog, wallet, date);
295
+ return {
296
+ content: [{ type: 'text', text: formatCheatSheet(sheet) }],
297
+ structuredContent: sheet,
298
+ };
299
+ });
300
+ server.registerTool('search_cards', {
301
+ title: 'Search cards',
302
+ description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet, and what it needs set up: category picks and their options, memberships, activations. Leave the query out to list every card. US cards only.',
303
+ inputSchema: z.object({
304
+ query: z
305
+ .string()
306
+ .optional()
307
+ .describe('Card name or issuer, as the user said it.'),
308
+ }),
309
+ outputSchema: CardSearchSchema,
310
+ annotations: { readOnlyHint: true, openWorldHint: false },
311
+ }, async ({ query }) => {
312
+ const held = await deps.store.load().catch(() => null);
313
+ const result = searchCards(catalog, query ?? '', held, deps.today());
314
+ const header = result.exact
315
+ ? ''
316
+ : result.cards.length > 0
317
+ ? 'No card matched every word. Closest:\n'
318
+ : `No card matched "${query ?? ''}". earnrate covers ${String(catalog.cards.length)} US cards; list them all by leaving the query out.`;
319
+ return {
320
+ content: [
321
+ {
322
+ type: 'text',
323
+ text: header + result.cards.map(formatCardInfo).join('\n'),
324
+ },
325
+ ],
326
+ structuredContent: result,
327
+ };
328
+ });
329
+ }
@@ -0,0 +1,128 @@
1
+ import { z } from 'zod';
2
+ import { walletSummary } from "../../engine/wallet-summary.js";
3
+ import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
4
+ import { fail } from "../context.js";
5
+ import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
6
+ import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
7
+ const WRITE = { readOnlyHint: false, openWorldHint: false };
8
+ // The tools that read or change a saved wallet.
9
+ export function registerWalletTools(server, deps, ctx, inputs) {
10
+ const { catalog, store } = deps;
11
+ server.registerTool('list_wallet', {
12
+ title: 'List wallet',
13
+ description: "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, which stores a store card works at, and when each card's data was last verified.",
14
+ outputSchema: WalletSummarySchema,
15
+ annotations: { readOnlyHint: true, openWorldHint: false },
16
+ }, async () => {
17
+ const read = await ctx.readableWallet();
18
+ if (typeof read === 'string')
19
+ return fail(read);
20
+ const summary = walletSummary(catalog, read.wallet, deps.today());
21
+ return {
22
+ content: [
23
+ { type: 'text', text: read.start ?? formatWalletSummary(summary) },
24
+ ],
25
+ structuredContent: summary,
26
+ };
27
+ });
28
+ server.registerTool('record_purchase', {
29
+ title: 'Record a purchase',
30
+ description: "Records a purchase the user has already made, so spending caps and welcome-bonus progress 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 and no open welcome bonus are acknowledged and nothing is written.",
31
+ inputSchema: z.object({
32
+ cardId: z
33
+ .string()
34
+ .min(1)
35
+ .describe('The card used, as a cardId from list_wallet or best_card_for_purchase.'),
36
+ merchant: Merchant,
37
+ amount: z.number().positive().describe('Amount spent in US dollars.'),
38
+ channel: ChannelInput,
39
+ date: DateInput,
40
+ category: inputs.CategoryInput,
41
+ }),
42
+ outputSchema: RecordResultSchema,
43
+ annotations: {
44
+ ...WRITE,
45
+ destructiveHint: false,
46
+ idempotentHint: false,
47
+ openWorldHint: deps.llm !== null,
48
+ },
49
+ }, async ({ cardId, merchant, amount, channel, date, category }) => {
50
+ const w = await ctx.wallet();
51
+ if (typeof w === 'string')
52
+ return fail(w);
53
+ if (!w.cards.some((c) => c.id === cardId))
54
+ return fail(`"${cardId}" isn't in the wallet. Cards: ${w.cards.map((c) => c.id).join(', ')}.`);
55
+ const purchase = {
56
+ cardId,
57
+ merchant,
58
+ amount,
59
+ channel: channel ?? null,
60
+ date: date ?? deps.today(),
61
+ };
62
+ const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
63
+ const result = await store.record(purchase, resolved.merchant);
64
+ if (!result.ok)
65
+ return fail(`Couldn't record it: ${result.error}`);
66
+ const { cap: booking, bonus } = result;
67
+ const output = booking || bonus
68
+ ? {
69
+ recorded: true,
70
+ booking,
71
+ bonus,
72
+ message: formatBooking(amount, cardId, booking, bonus),
73
+ }
74
+ : {
75
+ recorded: false,
76
+ booking: null,
77
+ bonus: null,
78
+ message: `Nothing to track: ${cardId} has no spending cap or open welcome bonus for this purchase, so the wallet wasn't changed.`,
79
+ };
80
+ return {
81
+ content: [
82
+ {
83
+ type: 'text',
84
+ text: [output.message, ...resolved.notices].join('\n'),
85
+ },
86
+ ],
87
+ structuredContent: output,
88
+ };
89
+ });
90
+ server.registerTool('add_card', {
91
+ title: 'Add a card',
92
+ 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.",
93
+ inputSchema: CardChange,
94
+ outputSchema: WalletChangeSchema,
95
+ annotations: { ...WRITE, destructiveHint: false, idempotentHint: false },
96
+ }, async (args) => {
97
+ const result = await store.addCard(cardInput(args), deps.today());
98
+ const started = !result.ok || !result.created
99
+ ? ''
100
+ : deps.savedWallets === 'account'
101
+ ? ', the first card in your saved wallet'
102
+ : `, starting a new wallet at ${store.where}`;
103
+ return ctx.changed(result, `Added ${ctx.cardName(args.cardId)} (${args.cardId})${started}.`);
104
+ });
105
+ server.registerTool('update_card', {
106
+ title: 'Update a card',
107
+ description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary or activated categories. Picks for the current period are merged with the ones already saved. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+.',
108
+ inputSchema: CardChange,
109
+ outputSchema: WalletChangeSchema,
110
+ annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
111
+ }, async (args) => ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId}).`));
112
+ server.registerTool('remove_card', {
113
+ title: 'Remove a card',
114
+ description: "Removes a card from the wallet, with its tracked cap usage and bonus progress. Only when the user says they no longer have it or don't want it considered.",
115
+ inputSchema: z.object({ cardId: CardId }),
116
+ outputSchema: WalletChangeSchema,
117
+ annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
118
+ }, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
119
+ server.registerTool('set_memberships', {
120
+ title: 'Set memberships',
121
+ 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. Replaces the whole list, so include the ones to keep.",
122
+ inputSchema: z.object({ memberships: inputs.AllMemberships }),
123
+ outputSchema: WalletChangeSchema,
124
+ annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
125
+ }, async ({ memberships }) => ctx.changed(await store.setMemberships(memberships), memberships.length > 0
126
+ ? `Memberships set to ${memberships.join(', ')}.`
127
+ : 'Memberships cleared.', { notes: false }));
128
+ }
@@ -6,7 +6,7 @@ const ON = new Set(['1', 'on', 'true', 'yes']);
6
6
  export function llmEnabled(env) {
7
7
  return ON.has((env[LLM_ENV_VAR] ?? '').trim().toLowerCase());
8
8
  }
9
- export function maxCallsFromEnv(env) {
9
+ function maxCallsFromEnv(env) {
10
10
  const raw = env[LLM_MAX_CALLS_ENV_VAR];
11
11
  if (raw === undefined || raw === '')
12
12
  return DEFAULT_MAX_CALLS;
@@ -1,4 +1,4 @@
1
- const ALL_NETWORKS = ['visa', 'mastercard', 'amex', 'discover'];
1
+ import { Network } from "../schema/common.js";
2
2
  export function normalizeMerchantName(input) {
3
3
  return input
4
4
  .toLowerCase()
@@ -74,7 +74,7 @@ export function resolveMerchant(catalog, input, channel) {
74
74
  resolvedName: `${found.name} gas`,
75
75
  category: storeGas,
76
76
  likelyMcc: null,
77
- accepts: found.in_store?.accepts ?? found.accepts ?? [...ALL_NETWORKS],
77
+ accepts: found.in_store?.accepts ?? found.accepts ?? [...Network.options],
78
78
  ambiguity: storeGas === 'gas.grocery_store'
79
79
  ? {
80
80
  mayCodeAs: [found.category],
@@ -105,7 +105,7 @@ function fromMerchant(found, source, input, channel, typedDomain) {
105
105
  resolvedName: found.name,
106
106
  category: view?.category ?? found.category,
107
107
  likelyMcc: view?.likely_mcc ?? found.likely_mcc ?? null,
108
- accepts: view?.accepts ?? found.accepts ?? [...ALL_NETWORKS],
108
+ accepts: view?.accepts ?? found.accepts ?? [...Network.options],
109
109
  ambiguity: found.ambiguity
110
110
  ? { mayCodeAs: found.ambiguity.may_code_as, note: found.ambiguity.note }
111
111
  : null,
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.1.4",
4
- "description": "Picks the best credit card from your wallet for a merchant, and explains why",
3
+ "version": "0.2.1",
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",
7
7
  "rewards",
@@ -42,13 +42,16 @@
42
42
  "smoke:pack": "node scripts/smoke-pack.ts",
43
43
  "mcpb:pack": "node scripts/build-mcpb.ts",
44
44
  "smoke:mcpb": "node scripts/smoke-mcpb.ts",
45
+ "remote:build": "node scripts/build-remote.ts",
46
+ "remote:check": "yarn remote:build && WRANGLER_SEND_METRICS=false wrangler deploy --config remote/wrangler.jsonc --dry-run --outdir dist",
47
+ "remote:deploy": "yarn remote:build && WRANGLER_SEND_METRICS=false wrangler deploy --config remote/wrangler.jsonc",
45
48
  "site:build": "vite build site && node scripts/build-pages.ts",
46
49
  "site:dev": "vite site",
47
50
  "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy",
48
51
  "test": "vitest run",
49
52
  "test:watch": "vitest",
50
53
  "coverage": "vitest run --coverage --coverage.include=src/**",
51
- "typecheck": "tsc && tsc -p site",
54
+ "typecheck": "tsc && tsc -p site && tsc -p remote",
52
55
  "lint": "eslint",
53
56
  "format": "prettier --write .",
54
57
  "format:check": "prettier --check .",
@@ -62,6 +65,8 @@
62
65
  },
63
66
  "devDependencies": {
64
67
  "@anthropic-ai/mcpb": "^2.1.2",
68
+ "@cloudflare/workers-oauth-provider": "1.2.4",
69
+ "@cloudflare/workers-types": "5.20261031.1",
65
70
  "@eslint/js": "^10.0.1",
66
71
  "@modelcontextprotocol/client": "2.3.1",
67
72
  "@types/node": "^24",
@@ -1,60 +0,0 @@
1
- import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
2
- import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, } from "./wallet-edit.js";
3
- const failed = (error) => ({ ok: false, error });
4
- async function attempt(task) {
5
- try {
6
- return await task();
7
- }
8
- catch (e) {
9
- return failed(e instanceof Error ? e.message : String(e));
10
- }
11
- }
12
- const noWallet = (file) => `No wallet at ${file} yet. Add a card first to create it.`;
13
- // The change is worked out on the parsed wallet, then spliced into the file
14
- // text, which must re-parse to exactly that wallet before it's written.
15
- async function commit(file, edited, change, created) {
16
- await commitWalletText(file, edited, change.wallet);
17
- return { ok: true, wallet: change.wallet, created, notes: change.notes };
18
- }
19
- export function addCard(file, catalog, input, today) {
20
- return attempt(async () => {
21
- const { text, wallet } = await readWalletText(file);
22
- const change = addToWallet(catalog, wallet, input, today);
23
- if (!change.ok)
24
- return change;
25
- return commit(file, addCardText(text, { id: input.id, ...change.fields }), change, text === null);
26
- });
27
- }
28
- export function updateCard(file, catalog, input, today) {
29
- return attempt(async () => {
30
- const { text, wallet } = await readWalletText(file);
31
- if (text === null || wallet === null)
32
- return failed(noWallet(file));
33
- const change = updateInWallet(catalog, wallet, input, today);
34
- if (!change.ok)
35
- return change;
36
- return commit(file, updateCardText(text, input.id, change.fields), change, false);
37
- });
38
- }
39
- export function removeCard(file, _catalog, id) {
40
- return attempt(async () => {
41
- const { text, wallet } = await readWalletText(file);
42
- if (text === null || wallet === null)
43
- return failed(noWallet(file));
44
- const change = removeFromWallet(wallet, id);
45
- if (!change.ok)
46
- return change;
47
- return commit(file, removeCardText(text, id), change, false);
48
- });
49
- }
50
- export function setMemberships(file, catalog, memberships) {
51
- return attempt(async () => {
52
- const { text, wallet } = await readWalletText(file);
53
- if (text === null || wallet === null)
54
- return failed(noWallet(file));
55
- const change = withMemberships(catalog, wallet, memberships);
56
- if (!change.ok)
57
- return change;
58
- return commit(file, setMembershipsText(text, change.wallet.memberships), change, false);
59
- });
60
- }