earnrate 0.2.15 → 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 +1 -1
- package/dist/cli/args.js +30 -1
- package/dist/cli/run.js +10 -1
- package/dist/engine/cheat-sheet.js +1 -1
- package/dist/engine/checklist.js +1 -1
- package/dist/engine/explore.js +5 -1
- package/dist/engine/memberships.js +1 -1
- package/dist/engine/wallet-summary.js +20 -14
- package/dist/format/card-text.js +4 -2
- package/dist/mcp/inputs.js +8 -0
- package/dist/mcp/schemas.js +1 -0
- package/dist/mcp/tools/account.js +49 -41
- package/dist/mcp/tools/wallet.js +202 -149
- package/dist/mcp/wallet-api.js +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -315,7 +315,7 @@ earnrate needs no account. The local version has no telemetry; the hosted server
|
|
|
315
315
|
- **What it stores locally:** your wallet, a YAML file on your computer (`~/.earnrate/wallet.yaml` unless you set `EARNRATE_WALLET`). It lists cards by product ID, like `amex-gold`, plus your category picks, activations and how much you've spent toward caps. earnrate never asks for or stores card numbers, expiry dates, security codes or bank logins. When you ask about a store earnrate doesn't recognize, its name goes in `~/.earnrate/missed-merchants.json` (`earnrate missed` lists them; `EARNRATE_MISSED_MERCHANTS=off` keeps no list).
|
|
316
316
|
- **What the local version sends:** nothing, by default. The optional merchant lookup (off unless you turn it on with your own Anthropic API key) sends only the name of a store earnrate doesn't recognize to Anthropic's API, and saves the answer in `~/.earnrate/merchant-cache.json`.
|
|
317
317
|
- **AI assistants:** when you use earnrate through Claude or another assistant, that assistant's provider sees the conversation, including earnrate's answers, under its own privacy policy.
|
|
318
|
-
- **earnrate.dev:** everything you enter on the site stays in your browser, except when you add the cheat sheet to Apple Wallet: then pass.earnrate.dev receives what the pass needs (card IDs, picks, activations, checkout names, memberships and the date) and keeps none of it. The site counts which of its parts get used, by name only, and counts visits with Cloudflare Web Analytics, without cookies.
|
|
318
|
+
- **earnrate.dev:** everything you enter on the site stays in your browser, except when you add the cheat sheet to Apple Wallet: then pass.earnrate.dev receives what the pass needs (card IDs, picks, activations, checkout names, memberships and the date) and keeps none of it. If you turn on syncing with Claude, the wallet (cards, picks, activations, memberships, point values, cap and spending totals, credits used, opening dates) goes to mcp.earnrate.dev and is stored encrypted, like the connector's; a purchase you record is sent so its totals can be updated, and only the totals are kept. Searches, stores and amounts you ask about still never leave the browser. The sign-in stays in a cookie the page's scripts can't read. The site counts which of its parts get used, by name only, and counts visits with Cloudflare Web Analytics, without cookies.
|
|
319
319
|
- **Retention:** locally, your data lives only in files you control, for as long as you keep them. Delete the wallet file, or clear the site's data in your browser, to remove it. A saved wallet on mcp.earnrate.dev lasts until you delete it or leave it unused for 13 months (a week, if it's never used); the record that an app is connected goes after 90 days unused.
|
|
320
320
|
- **Terms:** [earnrate.dev/terms](https://earnrate.dev/terms).
|
|
321
321
|
- **Contact:** [hello@earnrate.dev](mailto:hello@earnrate.dev).
|
package/dist/cli/args.js
CHANGED
|
@@ -95,6 +95,8 @@ export function parseCliArgs(argv) {
|
|
|
95
95
|
llm: { type: 'boolean' },
|
|
96
96
|
cards: { type: 'string' },
|
|
97
97
|
member: { type: 'string', multiple: true },
|
|
98
|
+
enrolled: { type: 'string', multiple: true },
|
|
99
|
+
opened: { type: 'string', multiple: true },
|
|
98
100
|
help: { type: 'boolean', short: 'h' },
|
|
99
101
|
},
|
|
100
102
|
});
|
|
@@ -129,6 +131,25 @@ export function parseCliArgs(argv) {
|
|
|
129
131
|
const paying = paymentOf(values.paying);
|
|
130
132
|
if ('problem' in paying)
|
|
131
133
|
return { kind: 'error', message: paying.problem };
|
|
134
|
+
const cards = cardList(values.cards);
|
|
135
|
+
const perCard = (flag, example) => {
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const pair of values[flag] ?? []) {
|
|
138
|
+
const [id = '', value = ''] = pair.split('=');
|
|
139
|
+
if (id.trim() === '' || value.trim() === '')
|
|
140
|
+
return `--${flag} takes <card>=<${flag === 'opened' ? 'date' : 'credits'}>, like ${example}.`;
|
|
141
|
+
if (!cards?.includes(id.trim()))
|
|
142
|
+
return `--${flag} is for a card named with --cards; ${id.trim()} isn't one.`;
|
|
143
|
+
out[id.trim()] = value.trim();
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
};
|
|
147
|
+
const enrolled = perCard('enrolled', 'amex-platinum=lululemon');
|
|
148
|
+
if (typeof enrolled === 'string')
|
|
149
|
+
return { kind: 'error', message: enrolled };
|
|
150
|
+
const opened = perCard('opened', 'discover-it-cash-back=2026-03-04');
|
|
151
|
+
if (typeof opened === 'string')
|
|
152
|
+
return { kind: 'error', message: opened };
|
|
132
153
|
return {
|
|
133
154
|
kind: 'run',
|
|
134
155
|
options: {
|
|
@@ -143,8 +164,16 @@ export function parseCliArgs(argv) {
|
|
|
143
164
|
all: values.all ?? false,
|
|
144
165
|
category: values.category ?? null,
|
|
145
166
|
llm: values.llm ?? null,
|
|
146
|
-
cards
|
|
167
|
+
cards,
|
|
147
168
|
members: values.member ?? [],
|
|
169
|
+
enrolled: Object.fromEntries(Object.entries(enrolled).map(([id, list]) => [
|
|
170
|
+
id,
|
|
171
|
+
list
|
|
172
|
+
.split(',')
|
|
173
|
+
.map((c) => c.trim())
|
|
174
|
+
.filter(Boolean),
|
|
175
|
+
])),
|
|
176
|
+
opened,
|
|
148
177
|
},
|
|
149
178
|
};
|
|
150
179
|
}
|
package/dist/cli/run.js
CHANGED
|
@@ -70,6 +70,11 @@ Options:
|
|
|
70
70
|
${PAYING_HELP}
|
|
71
71
|
--cards <ids> Answer for these cards instead of your wallet, comma
|
|
72
72
|
separated: --cards amex-gold,chase-prime-visa.
|
|
73
|
+
--enrolled <card>=<credits>
|
|
74
|
+
For a named card, statement credits you've enrolled
|
|
75
|
+
in: --enrolled amex-platinum=lululemon. Repeat per card.
|
|
76
|
+
--opened <card>=<date> For a named card, the day it opened (YYYY-MM-DD), for
|
|
77
|
+
a first-year rate. Repeat per card.
|
|
73
78
|
--member <name> A membership you have, in place of the saved ones.
|
|
74
79
|
--category <id> For a merchant earnrate doesn't know: its category,
|
|
75
80
|
e.g. grocery.supermarket. Ignored for known merchants.
|
|
@@ -106,7 +111,11 @@ export async function cardsToUse(catalog, file, io, named) {
|
|
|
106
111
|
if (named.cards !== null) {
|
|
107
112
|
const saved = await savedSettings(io);
|
|
108
113
|
const wallet = walletFromCards(catalog, {
|
|
109
|
-
cards: named.cards.map((id) => ({
|
|
114
|
+
cards: named.cards.map((id) => ({
|
|
115
|
+
id,
|
|
116
|
+
...(named.enrolled?.[id] ? { enrolled: named.enrolled[id] } : {}),
|
|
117
|
+
...(named.opened?.[id] ? { opened: named.opened[id] } : {}),
|
|
118
|
+
})),
|
|
110
119
|
memberships: named.members.length > 0 ? named.members : saved.memberships,
|
|
111
120
|
}, io.today);
|
|
112
121
|
if (typeof wallet === 'string') {
|
|
@@ -86,7 +86,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
86
86
|
];
|
|
87
87
|
return [
|
|
88
88
|
`Working on ${possessive(name)} welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
|
|
89
|
-
? `It ${where.join(' and ')}, so those are the places to use it.`
|
|
89
|
+
? `It ${where.join(' and ')}, so ${matches.length + near.length === 1 ? "that's the place" : 'those are the places'} to use it.`
|
|
90
90
|
: 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
|
|
91
91
|
];
|
|
92
92
|
});
|
package/dist/engine/checklist.js
CHANGED
|
@@ -24,7 +24,7 @@ shortName) {
|
|
|
24
24
|
items.push({
|
|
25
25
|
kind: 'activate',
|
|
26
26
|
cardId: card.id,
|
|
27
|
-
text: `Activate ${possessive(name)} bonus ${a.
|
|
27
|
+
text: `Activate ${possessive(name)} bonus ${a.where}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
|
|
28
28
|
due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
|
|
29
29
|
});
|
|
30
30
|
}
|
package/dist/engine/explore.js
CHANGED
|
@@ -223,6 +223,10 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
223
223
|
...membershipNeeded(catalog, card, members),
|
|
224
224
|
],
|
|
225
225
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
226
|
+
needsMembership: card.requires_membership !== undefined &&
|
|
227
|
+
!members.has(card.requires_membership)
|
|
228
|
+
? card.requires_membership
|
|
229
|
+
: null,
|
|
226
230
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
227
231
|
});
|
|
228
232
|
}
|
|
@@ -303,7 +307,7 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid
|
|
|
303
307
|
function exploreWarnings(catalog, merchant) {
|
|
304
308
|
const warnings = [];
|
|
305
309
|
if (merchant.source === 'none') {
|
|
306
|
-
warnings.push(`
|
|
310
|
+
warnings.push(`earnrate doesn't know "${merchant.input}" yet, so only base rates apply. Try a category, or the name of a larger chain.`);
|
|
307
311
|
}
|
|
308
312
|
if (merchant.ambiguity) {
|
|
309
313
|
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
|
@@ -50,7 +50,7 @@ export function membershipCosts(catalog, cards, paid) {
|
|
|
50
50
|
export function membershipText(catalog, id) {
|
|
51
51
|
const m = membership(catalog, id);
|
|
52
52
|
const name = withArticle(m?.name ?? words(id));
|
|
53
|
-
return m?.annual_fee === undefined
|
|
53
|
+
return m?.annual_fee === undefined || m.annual_fee === 0
|
|
54
54
|
? name
|
|
55
55
|
: `${name} (${money(m.annual_fee)} a year)`;
|
|
56
56
|
}
|
|
@@ -68,23 +68,13 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
68
68
|
pending ? ', once activated' : '',
|
|
69
69
|
payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
|
|
70
70
|
].join('|');
|
|
71
|
-
|
|
72
|
-
const whats = line[rulePreposition(catalog, [r])];
|
|
73
|
-
for (const part of ruleParts(catalog, r, card))
|
|
74
|
-
if (!whats.includes(part))
|
|
75
|
-
whats.push(part);
|
|
76
|
-
lines.set(key, line);
|
|
71
|
+
lines.set(key, [...(lines.get(key) ?? []), r]);
|
|
77
72
|
}
|
|
78
73
|
return [
|
|
79
|
-
...[...lines].map(([key,
|
|
74
|
+
...[...lines].map(([key, rules]) => {
|
|
80
75
|
const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
|
|
81
76
|
const after = `${limit}${activate}`;
|
|
82
|
-
|
|
83
|
-
const where = [
|
|
84
|
-
...(at.length > 0 ? [`at ${listed(at)}`] : []),
|
|
85
|
-
...(on.length > 0 ? [`on ${listed(on)}`] : []),
|
|
86
|
-
].join(at.length > 1 ? ', and ' : ' and ');
|
|
87
|
-
return `${head} ${where}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
77
|
+
return `${head} ${wherePhrase(catalog, card, rules)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
88
78
|
}),
|
|
89
79
|
...(shown.some((r) => r.match.any &&
|
|
90
80
|
r.geography !== 'foreign_only' &&
|
|
@@ -96,6 +86,22 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
96
86
|
}
|
|
97
87
|
// Bonuses current or starting soon that the card hasn't activated, one per
|
|
98
88
|
// activation group (one activation covers a quarter's categories).
|
|
89
|
+
// "at gas stations and restaurants, and on transit": each rule's parts after
|
|
90
|
+
// its own preposition, the "at" ones first.
|
|
91
|
+
export function wherePhrase(catalog, card, rules) {
|
|
92
|
+
const parts = { at: [], on: [] };
|
|
93
|
+
for (const r of rules) {
|
|
94
|
+
const list = parts[rulePreposition(catalog, [r])];
|
|
95
|
+
for (const part of ruleParts(catalog, r, card))
|
|
96
|
+
if (!list.includes(part))
|
|
97
|
+
list.push(part);
|
|
98
|
+
}
|
|
99
|
+
const { at, on } = parts;
|
|
100
|
+
return [
|
|
101
|
+
...(at.length > 0 ? [`at ${listed(at)}`] : []),
|
|
102
|
+
...(on.length > 0 ? [`on ${listed(on)}`] : []),
|
|
103
|
+
].join(at.length > 1 ? ', and ' : ' and ');
|
|
104
|
+
}
|
|
99
105
|
export function activationsDue(catalog, card, held, today) {
|
|
100
106
|
const seen = new Set();
|
|
101
107
|
const out = [];
|
|
@@ -111,7 +117,7 @@ export function activationsDue(catalog, card, held, today) {
|
|
|
111
117
|
what: listed([
|
|
112
118
|
...new Set(rules.flatMap((r) => ruleParts(catalog, r, card))),
|
|
113
119
|
]),
|
|
114
|
-
|
|
120
|
+
where: wherePhrase(catalog, card, rules),
|
|
115
121
|
from: first?.valid_from ?? null,
|
|
116
122
|
to: first?.valid_to ?? null,
|
|
117
123
|
activateBy: first?.activate_by ?? null,
|
package/dist/format/card-text.js
CHANGED
|
@@ -15,9 +15,11 @@ export function cardText(catalog) {
|
|
|
15
15
|
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
|
|
16
16
|
.replace(/ Card by /, ' by '));
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// `membership: false` leaves out a membership the card needs, for lists
|
|
19
|
+
// whose own conditions say it only when it applies.
|
|
20
|
+
function feeLabel(id, { membership = true } = {}) {
|
|
19
21
|
const card = cards.get(id);
|
|
20
|
-
const m = card?.requires_membership;
|
|
22
|
+
const m = membership ? card?.requires_membership : undefined;
|
|
21
23
|
const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
|
|
22
24
|
const fee = card?.annual_fee;
|
|
23
25
|
if (fee === undefined)
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -49,6 +49,14 @@ const NamedCard = z.object({
|
|
|
49
49
|
.array(z.string())
|
|
50
50
|
.optional()
|
|
51
51
|
.describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
|
|
52
|
+
enrolled: z
|
|
53
|
+
.array(z.string())
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Statement credits the user has enrolled in with the issuer, as credit ids from search_cards. A credit that needs enrolling counts only when it's here. Ask; never assume."),
|
|
56
|
+
opened: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('The day the account opened (YYYY-MM-DD), for a card with a first-year rate. Optional; without it the card is ranked at its standard rates.'),
|
|
52
60
|
});
|
|
53
61
|
const cardFields = {
|
|
54
62
|
checkoutLabel: z
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -413,6 +413,7 @@ export const ExplorationSchema = z.object({
|
|
|
413
413
|
conditions: z.array(z.string()),
|
|
414
414
|
evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
|
|
415
415
|
withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
|
|
416
|
+
needsMembership: z.string().nullable(),
|
|
416
417
|
})),
|
|
417
418
|
excluded: z.array(ExcludedCardSchema),
|
|
418
419
|
assumptions: z.array(z.string()),
|
|
@@ -5,19 +5,56 @@ import { listed } from "../../engine/text.js";
|
|
|
5
5
|
import { savedSettingsText } from "../../engine/wallet-summary.js";
|
|
6
6
|
import { fail } from "../context.js";
|
|
7
7
|
import { WalletChangeSchema } from "../schemas.js";
|
|
8
|
+
const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
|
|
9
|
+
// Everything a saved wallet holds, in words; empty when there's nothing.
|
|
10
|
+
const contentsOf = (catalog, w) => [
|
|
11
|
+
...(w.cards.length > 0
|
|
12
|
+
? [`${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`]
|
|
13
|
+
: []),
|
|
14
|
+
...savedSettingsText(catalog, w),
|
|
15
|
+
];
|
|
16
|
+
const ImportInput = z.object({
|
|
17
|
+
yaml: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.max(100_000)
|
|
21
|
+
.describe('The wallet.yaml text, as the user gave it.'),
|
|
22
|
+
replace: z
|
|
23
|
+
.boolean()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('true to replace what the wallet already holds, after the user confirms.'),
|
|
26
|
+
});
|
|
27
|
+
export function importHandler(catalog, store, ctx) {
|
|
28
|
+
const contents = (w) => contentsOf(catalog, w);
|
|
29
|
+
return {
|
|
30
|
+
input: ImportInput,
|
|
31
|
+
run: async ({ yaml, replace }) => {
|
|
32
|
+
const parsed = parseWalletYaml(yaml, catalog);
|
|
33
|
+
if (typeof parsed === 'string')
|
|
34
|
+
return fail(parsed);
|
|
35
|
+
let current;
|
|
36
|
+
try {
|
|
37
|
+
current = await store.load();
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (replace !== true)
|
|
41
|
+
return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
|
|
42
|
+
current = null;
|
|
43
|
+
}
|
|
44
|
+
const before = current === null ? [] : contents(current);
|
|
45
|
+
if (before.length > 0 && replace !== true)
|
|
46
|
+
return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
|
|
47
|
+
return ctx.changed(await store.replace(parsed), before.length > 0
|
|
48
|
+
? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
|
|
49
|
+
: `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
8
53
|
// A saved wallet on the hosted server has no file to copy or delete, so
|
|
9
54
|
// these do it.
|
|
10
55
|
export function registerAccountTools(server, catalog, store, ctx) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const contents = (w) => [
|
|
14
|
-
...(w.cards.length > 0
|
|
15
|
-
? [
|
|
16
|
-
`${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`,
|
|
17
|
-
]
|
|
18
|
-
: []),
|
|
19
|
-
...savedSettingsText(catalog, w),
|
|
20
|
-
];
|
|
56
|
+
const contents = (w) => contentsOf(catalog, w);
|
|
57
|
+
const importWallet = importHandler(catalog, store, ctx);
|
|
21
58
|
server.registerTool('export_wallet', {
|
|
22
59
|
title: 'Export the wallet',
|
|
23
60
|
description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in and used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
|
|
@@ -42,17 +79,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
42
79
|
server.registerTool('import_wallet', {
|
|
43
80
|
title: 'Import a wallet',
|
|
44
81
|
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. If anything is already saved (cards, memberships or point values), it refuses unless replace is true: confirm with the user first.',
|
|
45
|
-
inputSchema:
|
|
46
|
-
yaml: z
|
|
47
|
-
.string()
|
|
48
|
-
.min(1)
|
|
49
|
-
.max(100_000)
|
|
50
|
-
.describe('The wallet.yaml text, as the user gave it.'),
|
|
51
|
-
replace: z
|
|
52
|
-
.boolean()
|
|
53
|
-
.optional()
|
|
54
|
-
.describe('true to replace what the wallet already holds, after the user confirms.'),
|
|
55
|
-
}),
|
|
82
|
+
inputSchema: importWallet.input,
|
|
56
83
|
outputSchema: WalletChangeSchema,
|
|
57
84
|
annotations: {
|
|
58
85
|
title: 'Import a wallet',
|
|
@@ -61,26 +88,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
61
88
|
idempotentHint: true,
|
|
62
89
|
openWorldHint: false,
|
|
63
90
|
},
|
|
64
|
-
},
|
|
65
|
-
const parsed = parseWalletYaml(yaml, catalog);
|
|
66
|
-
if (typeof parsed === 'string')
|
|
67
|
-
return fail(parsed);
|
|
68
|
-
let current;
|
|
69
|
-
try {
|
|
70
|
-
current = await store.load();
|
|
71
|
-
}
|
|
72
|
-
catch (e) {
|
|
73
|
-
if (replace !== true)
|
|
74
|
-
return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
|
|
75
|
-
current = null;
|
|
76
|
-
}
|
|
77
|
-
const before = current === null ? [] : contents(current);
|
|
78
|
-
if (before.length > 0 && replace !== true)
|
|
79
|
-
return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
|
|
80
|
-
return ctx.changed(await store.replace(parsed), before.length > 0
|
|
81
|
-
? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
|
|
82
|
-
: `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
|
|
83
|
-
});
|
|
91
|
+
}, (args) => importWallet.run(args));
|
|
84
92
|
server.registerTool('delete_wallet', {
|
|
85
93
|
title: 'Delete the wallet',
|
|
86
94
|
description: "Deletes the saved wallet and its recovery code now and for good, and signs out every app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -63,8 +63,196 @@ 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
|
-
|
|
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) {
|
|
67
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);
|
|
68
256
|
server.registerTool('list_wallet', {
|
|
69
257
|
title: 'List wallet',
|
|
70
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}`,
|
|
@@ -75,44 +263,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
75
263
|
destructiveHint: false,
|
|
76
264
|
openWorldHint: false,
|
|
77
265
|
},
|
|
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
|
-
});
|
|
266
|
+
}, () => tools.list_wallet.run());
|
|
102
267
|
server.registerTool('record_purchase', {
|
|
103
268
|
title: 'Record a purchase',
|
|
104
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}`,
|
|
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
|
-
}),
|
|
270
|
+
inputSchema: tools.record_purchase.input,
|
|
116
271
|
outputSchema: RecordResultSchema,
|
|
117
272
|
annotations: {
|
|
118
273
|
title: 'Record a purchase',
|
|
@@ -121,62 +276,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
121
276
|
idempotentHint: false,
|
|
122
277
|
openWorldHint: deps.llm !== null,
|
|
123
278
|
},
|
|
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
|
-
});
|
|
279
|
+
}, (args) => tools.record_purchase.run(args));
|
|
176
280
|
server.registerTool('add_card', {
|
|
177
281
|
title: 'Add a card',
|
|
178
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}`,
|
|
179
|
-
inputSchema:
|
|
283
|
+
inputSchema: tools.add_card.input,
|
|
180
284
|
outputSchema: WalletChangeSchema,
|
|
181
285
|
annotations: {
|
|
182
286
|
title: 'Add a card',
|
|
@@ -184,19 +288,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
184
288
|
destructiveHint: false,
|
|
185
289
|
idempotentHint: false,
|
|
186
290
|
},
|
|
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
|
-
});
|
|
291
|
+
}, (args) => tools.add_card.run(args));
|
|
196
292
|
server.registerTool('update_card', {
|
|
197
293
|
title: 'Update a card',
|
|
198
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}`,
|
|
199
|
-
inputSchema:
|
|
295
|
+
inputSchema: tools.update_card.input,
|
|
200
296
|
outputSchema: WalletChangeSchema,
|
|
201
297
|
annotations: {
|
|
202
298
|
title: 'Update a card',
|
|
@@ -204,18 +300,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
204
300
|
destructiveHint: true,
|
|
205
301
|
idempotentHint: true,
|
|
206
302
|
},
|
|
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
|
-
});
|
|
303
|
+
}, (args) => tools.update_card.run(args));
|
|
215
304
|
server.registerTool('remove_card', {
|
|
216
305
|
title: 'Remove a card',
|
|
217
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}`,
|
|
218
|
-
inputSchema:
|
|
307
|
+
inputSchema: tools.remove_card.input,
|
|
219
308
|
outputSchema: WalletChangeSchema,
|
|
220
309
|
annotations: {
|
|
221
310
|
title: 'Remove a card',
|
|
@@ -223,11 +312,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
223
312
|
destructiveHint: true,
|
|
224
313
|
idempotentHint: true,
|
|
225
314
|
},
|
|
226
|
-
},
|
|
315
|
+
}, (args) => tools.remove_card.run(args));
|
|
227
316
|
server.registerTool('set_memberships', {
|
|
228
317
|
title: 'Set memberships',
|
|
229
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}`,
|
|
230
|
-
inputSchema:
|
|
319
|
+
inputSchema: tools.set_memberships.input,
|
|
231
320
|
outputSchema: WalletChangeSchema,
|
|
232
321
|
annotations: {
|
|
233
322
|
title: 'Set memberships',
|
|
@@ -235,30 +324,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
235
324
|
destructiveHint: true,
|
|
236
325
|
idempotentHint: true,
|
|
237
326
|
},
|
|
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
|
-
});
|
|
327
|
+
}, (args) => tools.set_memberships.run(args));
|
|
249
328
|
server.registerTool('set_point_values', {
|
|
250
329
|
title: 'Set point values',
|
|
251
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}`,
|
|
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
|
-
}),
|
|
331
|
+
inputSchema: tools.set_point_values.input,
|
|
262
332
|
outputSchema: WalletChangeSchema,
|
|
263
333
|
annotations: {
|
|
264
334
|
title: 'Set point values',
|
|
@@ -266,22 +336,5 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
266
336
|
destructiveHint: true,
|
|
267
337
|
idempotentHint: true,
|
|
268
338
|
},
|
|
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
|
-
});
|
|
339
|
+
}, (args) => tools.set_point_values.run(args));
|
|
287
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
|
+
}
|