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