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.
- package/README.md +10 -10
- package/data/CHANGELOG.md +12 -0
- package/data/VERSION +1 -1
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +1 -1
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/dist/cli/args.js +30 -1
- package/dist/cli/import.js +14 -11
- package/dist/cli/run.js +10 -1
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +49 -16
- package/dist/engine/cheat-sheet.js +13 -10
- package/dist/engine/checklist.js +5 -10
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +7 -3
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +3 -7
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +19 -17
- package/dist/engine/record.js +1 -1
- package/dist/engine/rule-text.js +71 -23
- package/dist/engine/setup.js +4 -0
- package/dist/engine/text.js +23 -6
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +32 -23
- package/dist/format/card-text.js +6 -5
- package/dist/format/cards.js +2 -2
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +2 -2
- package/dist/format/wallet.js +5 -5
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +11 -3
- package/dist/mcp/schemas.js +2 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +50 -42
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +205 -148
- package/dist/mcp/wallet-api.js +78 -0
- package/dist/schema/card.js +6 -10
- package/package.json +2 -1
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -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
|
|
67
|
-
export function
|
|
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,
|
|
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
|
-
},
|
|
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
|
|
106
|
-
inputSchema:
|
|
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
|
-
},
|
|
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:
|
|
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
|
-
},
|
|
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
|
|
195
|
-
inputSchema:
|
|
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
|
-
},
|
|
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:
|
|
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
|
-
},
|
|
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:
|
|
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
|
-
},
|
|
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:
|
|
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
|
-
},
|
|
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
|
+
}
|
package/dist/schema/card.js
CHANGED
|
@@ -157,11 +157,9 @@ export const ChoiceSchema = z.strictObject({
|
|
|
157
157
|
})),
|
|
158
158
|
])),
|
|
159
159
|
});
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
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.
|
|
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",
|