earnrate 0.1.2 → 0.1.4
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 +22 -4
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/cards/citi-custom-cash.yaml +4 -3
- package/data/categories.yaml +24 -0
- package/dist/cli/cheatsheet.js +81 -0
- package/dist/cli/explore.js +364 -0
- package/dist/cli/run.js +13 -0
- package/dist/data/wallet-setup.js +23 -165
- package/dist/engine/cheat-sheet.js +48 -0
- package/dist/engine/explore.js +292 -0
- package/dist/engine/recommend.js +7 -7
- package/dist/engine/setup.js +1 -1
- package/dist/engine/simulate.js +309 -0
- package/dist/engine/wallet-change.js +179 -0
- package/dist/mcp/schemas.js +83 -0
- package/dist/mcp/server.js +219 -17
- package/dist/schema/card.js +2 -0
- package/dist/schema/category.js +2 -0
- package/package.json +9 -3
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { periodKey } from "./periods.js";
|
|
2
|
+
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
3
|
+
const failed = (error) => ({ ok: false, error });
|
|
4
|
+
const list = (ids) => ids.join(', ');
|
|
5
|
+
export function findCatalogCard(catalog, id, today) {
|
|
6
|
+
const card = catalog.cards.find((c) => c.id === id);
|
|
7
|
+
if (card)
|
|
8
|
+
return card;
|
|
9
|
+
const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
|
|
10
|
+
return near.length > 0
|
|
11
|
+
? `No card '${id}' in earnrate. Closest: ${list(near)}.`
|
|
12
|
+
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
13
|
+
}
|
|
14
|
+
export function isAnniversary(text) {
|
|
15
|
+
const m = /^(\d{2})-(\d{2})$/.exec(text);
|
|
16
|
+
if (!m)
|
|
17
|
+
return false;
|
|
18
|
+
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
19
|
+
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
20
|
+
}
|
|
21
|
+
// The period key picks are filed under: the card's choice period, or for
|
|
22
|
+
// picks that carry over, the current year (it's never compared).
|
|
23
|
+
function pickPeriod(card, today) {
|
|
24
|
+
const periods = [
|
|
25
|
+
...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
|
|
26
|
+
];
|
|
27
|
+
if (periods.length > 1)
|
|
28
|
+
return null;
|
|
29
|
+
return periodKey(periods[0] ?? 'calendar_year', today, null);
|
|
30
|
+
}
|
|
31
|
+
export function cardFields(card, held, input, today) {
|
|
32
|
+
const problems = [];
|
|
33
|
+
const fields = {};
|
|
34
|
+
if (input.checkoutLabel !== undefined) {
|
|
35
|
+
if (input.checkoutLabel.trim() === '')
|
|
36
|
+
problems.push('The checkout label is empty.');
|
|
37
|
+
// earnrate never stores card numbers; a last four is fine.
|
|
38
|
+
if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
|
|
39
|
+
problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
|
|
40
|
+
fields.checkout_label = input.checkoutLabel.trim();
|
|
41
|
+
}
|
|
42
|
+
if (input.anniversary !== undefined) {
|
|
43
|
+
if (!isAnniversary(input.anniversary)) {
|
|
44
|
+
problems.push(`"${input.anniversary}" isn't an anniversary. Use MM-DD, like 03-15.`);
|
|
45
|
+
}
|
|
46
|
+
fields.anniversary = input.anniversary;
|
|
47
|
+
}
|
|
48
|
+
if (input.activated !== undefined) {
|
|
49
|
+
const needing = card.rules
|
|
50
|
+
.filter((r) => r.requires?.activation)
|
|
51
|
+
.map((r) => r.id);
|
|
52
|
+
for (const id of input.activated) {
|
|
53
|
+
if (!needing.includes(id)) {
|
|
54
|
+
problems.push(needing.length > 0
|
|
55
|
+
? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
|
|
56
|
+
: `${card.id} has no rules that need activating.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
fields.activated = [...new Set(input.activated)];
|
|
60
|
+
}
|
|
61
|
+
if (input.picks !== undefined) {
|
|
62
|
+
for (const [choiceId, picks] of Object.entries(input.picks)) {
|
|
63
|
+
const choice = card.choices.find((c) => c.id === choiceId);
|
|
64
|
+
if (!choice) {
|
|
65
|
+
problems.push(card.choices.length > 0
|
|
66
|
+
? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
|
|
67
|
+
: `${card.id} has no categories to pick.`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const options = Object.keys(choice.options);
|
|
71
|
+
for (const p of picks) {
|
|
72
|
+
if (!options.includes(p)) {
|
|
73
|
+
problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (picks.length === 0 || picks.length > choice.pick) {
|
|
77
|
+
problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const period = pickPeriod(card, today);
|
|
81
|
+
if (period === null) {
|
|
82
|
+
problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const kept = held?.chosen?.period === period ? held.chosen.picks : {};
|
|
86
|
+
fields.chosen = {
|
|
87
|
+
period,
|
|
88
|
+
picks: { ...kept, ...input.picks },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return problems.length > 0 ? problems.join('\n') : fields;
|
|
93
|
+
}
|
|
94
|
+
export const newHolding = (id) => ({
|
|
95
|
+
id,
|
|
96
|
+
cap_usage: {},
|
|
97
|
+
activated: [],
|
|
98
|
+
});
|
|
99
|
+
export const withFields = (held, f) => ({
|
|
100
|
+
...held,
|
|
101
|
+
...(f.checkout_label === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: { checkout_label: f.checkout_label }),
|
|
104
|
+
...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
|
|
105
|
+
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
106
|
+
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
107
|
+
});
|
|
108
|
+
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
109
|
+
export function addToWallet(catalog, wallet, input, today) {
|
|
110
|
+
const card = findCatalogCard(catalog, input.id, today);
|
|
111
|
+
if (typeof card === 'string')
|
|
112
|
+
return failed(card);
|
|
113
|
+
if (wallet?.cards.some((c) => c.id === card.id)) {
|
|
114
|
+
return failed(`${card.id} is already in the wallet. Update it instead.`);
|
|
115
|
+
}
|
|
116
|
+
const fields = cardFields(card, null, input, today);
|
|
117
|
+
if (typeof fields === 'string')
|
|
118
|
+
return failed(fields);
|
|
119
|
+
const held = withFields(newHolding(card.id), fields);
|
|
120
|
+
const next = wallet
|
|
121
|
+
? { ...wallet, cards: [...wallet.cards, held] }
|
|
122
|
+
: { valuations: {}, memberships: [], cards: [held] };
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
wallet: next,
|
|
126
|
+
fields,
|
|
127
|
+
notes: setupNotes(catalog, next, held, today),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function updateInWallet(catalog, wallet, input, today) {
|
|
131
|
+
const held = wallet.cards.find((c) => c.id === input.id);
|
|
132
|
+
const card = catalog.cards.find((c) => c.id === input.id);
|
|
133
|
+
if (!held || !card) {
|
|
134
|
+
return failed(`${input.id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
|
|
135
|
+
}
|
|
136
|
+
const fields = cardFields(card, held, input, today);
|
|
137
|
+
if (typeof fields === 'string')
|
|
138
|
+
return failed(fields);
|
|
139
|
+
if (Object.keys(fields).length === 0)
|
|
140
|
+
return failed('Nothing to change.');
|
|
141
|
+
const updated = withFields(held, fields);
|
|
142
|
+
const next = {
|
|
143
|
+
...wallet,
|
|
144
|
+
cards: wallet.cards.map((c) => (c.id === held.id ? updated : c)),
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
wallet: next,
|
|
149
|
+
fields,
|
|
150
|
+
notes: setupNotes(catalog, next, updated, today),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function removeFromWallet(wallet, id) {
|
|
154
|
+
if (!wallet.cards.some((c) => c.id === id)) {
|
|
155
|
+
return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
|
|
156
|
+
}
|
|
157
|
+
if (wallet.cards.length === 1) {
|
|
158
|
+
return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
|
|
163
|
+
fields: {},
|
|
164
|
+
notes: [],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export function withMemberships(catalog, wallet, memberships) {
|
|
168
|
+
const known = knownMemberships(catalog);
|
|
169
|
+
const unknown = memberships.filter((m) => !known.includes(m));
|
|
170
|
+
if (unknown.length > 0) {
|
|
171
|
+
return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
wallet: { ...wallet, memberships: [...new Set(memberships)] },
|
|
176
|
+
fields: {},
|
|
177
|
+
notes: [],
|
|
178
|
+
};
|
|
179
|
+
}
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -218,3 +218,86 @@ export const WalletChangeSchema = z.object({
|
|
|
218
218
|
text: z.string(),
|
|
219
219
|
})),
|
|
220
220
|
});
|
|
221
|
+
const ExcludedCardSchema = z.object({
|
|
222
|
+
cardId: z.string(),
|
|
223
|
+
cardName: z.string(),
|
|
224
|
+
checkoutLabel: z.string().nullable(),
|
|
225
|
+
network: Network,
|
|
226
|
+
reason: z.string(),
|
|
227
|
+
});
|
|
228
|
+
const Picks = z.record(z.string(), z.array(z.string()));
|
|
229
|
+
export const ExplorationSchema = z.object({
|
|
230
|
+
merchant: MerchantResolutionSchema,
|
|
231
|
+
ranked: z.array(RankedCardSchema.extend({
|
|
232
|
+
issuer: z.string(),
|
|
233
|
+
network: CardNetwork,
|
|
234
|
+
annualFee: z.number().nullable(),
|
|
235
|
+
firstYearFee: z.number().nullable(),
|
|
236
|
+
picks: Picks,
|
|
237
|
+
requirements: z.object({
|
|
238
|
+
pick: z
|
|
239
|
+
.object({
|
|
240
|
+
choice: z.string(),
|
|
241
|
+
options: z.array(z.string()),
|
|
242
|
+
period: z.string(),
|
|
243
|
+
})
|
|
244
|
+
.nullable(),
|
|
245
|
+
activation: z.string().nullable(),
|
|
246
|
+
membership: z.string().nullable(),
|
|
247
|
+
cap: z
|
|
248
|
+
.object({
|
|
249
|
+
amount: z.number(),
|
|
250
|
+
period: z.string(),
|
|
251
|
+
thenRate: z.number(),
|
|
252
|
+
currency: z.string(),
|
|
253
|
+
})
|
|
254
|
+
.nullable(),
|
|
255
|
+
}),
|
|
256
|
+
conditions: z.array(z.string()),
|
|
257
|
+
evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
|
|
258
|
+
withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
|
|
259
|
+
})),
|
|
260
|
+
excluded: z.array(ExcludedCardSchema),
|
|
261
|
+
assumptions: z.array(z.string()),
|
|
262
|
+
warnings: z.array(z.string()),
|
|
263
|
+
});
|
|
264
|
+
const SimulationSchema = z.object({
|
|
265
|
+
cards: z.array(z.object({ cardId: z.string(), picks: Picks })),
|
|
266
|
+
spend: z.number(),
|
|
267
|
+
rewards: z.number(),
|
|
268
|
+
annualFees: z.number(),
|
|
269
|
+
net: z.number(),
|
|
270
|
+
lines: z.array(z.object({
|
|
271
|
+
label: z.string(),
|
|
272
|
+
spend: z.number(),
|
|
273
|
+
earned: z.number(),
|
|
274
|
+
returnPct: z.number(),
|
|
275
|
+
cards: z.array(z.string()),
|
|
276
|
+
})),
|
|
277
|
+
notes: z.array(z.string()),
|
|
278
|
+
});
|
|
279
|
+
export const SpendingSimulationSchema = z.object({
|
|
280
|
+
singles: z.array(SimulationSchema),
|
|
281
|
+
pairs: z.array(SimulationSchema.extend({ alternatives: z.array(z.string()) })),
|
|
282
|
+
current: SimulationSchema.nullable(),
|
|
283
|
+
additions: z.array(z.object({
|
|
284
|
+
cardId: z.string(),
|
|
285
|
+
picks: Picks,
|
|
286
|
+
extraRewards: z.number(),
|
|
287
|
+
annualFee: z.number(),
|
|
288
|
+
gain: z.number(),
|
|
289
|
+
})),
|
|
290
|
+
});
|
|
291
|
+
export const CheatSheetSchema = z.object({
|
|
292
|
+
quarter: z.string(),
|
|
293
|
+
date: z.string(),
|
|
294
|
+
rows: z.array(z.object({
|
|
295
|
+
label: z.string(),
|
|
296
|
+
cardId: z.string().nullable(),
|
|
297
|
+
cardName: z.string().nullable(),
|
|
298
|
+
effectiveReturnPct: z.number().nullable(),
|
|
299
|
+
matchedRule: z.string().nullable(),
|
|
300
|
+
note: z.string().nullable(),
|
|
301
|
+
})),
|
|
302
|
+
notes: z.array(z.string()),
|
|
303
|
+
});
|
package/dist/mcp/server.js
CHANGED
|
@@ -3,20 +3,26 @@ import { z } from 'zod';
|
|
|
3
3
|
import { MCP_SERVER_NAME } from "../brand.js";
|
|
4
4
|
import { formatCardInfo, formatNotes } from "../cli/cards.js";
|
|
5
5
|
import { formatRecommendation } from "../cli/format.js";
|
|
6
|
+
import { formatCheatSheet } from "../cli/cheatsheet.js";
|
|
7
|
+
import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../cli/explore.js";
|
|
6
8
|
import { formatOffers } from "../cli/offers.js";
|
|
7
9
|
import { walletProblems } from "../data/integrity.js";
|
|
8
10
|
import { loadWallet } from "../data/load.js";
|
|
9
11
|
import { withWalletLock, writeBonusSpent, writeCapBooking, } from "../data/wallet-write.js";
|
|
10
12
|
import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
|
|
11
13
|
import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
|
|
14
|
+
import { cheatSheet } from "../engine/cheat-sheet.js";
|
|
15
|
+
import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
|
|
12
16
|
import { compareOffers } from "../engine/offers.js";
|
|
17
|
+
import { VALUATION_PROFILES } from "../engine/profiles.js";
|
|
13
18
|
import { capBooking } from "../engine/record.js";
|
|
14
19
|
import { recommend } from "../engine/recommend.js";
|
|
20
|
+
import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
|
|
15
21
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
16
22
|
import { walletSummary } from "../engine/wallet-summary.js";
|
|
17
23
|
import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
18
24
|
import { IsoDate } from "../schema/common.js";
|
|
19
|
-
import { CardSearchSchema, MerchantResolutionSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
|
|
25
|
+
import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, SpendingSimulationSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
|
|
20
26
|
const Merchant = z
|
|
21
27
|
.string()
|
|
22
28
|
.min(1)
|
|
@@ -26,7 +32,7 @@ const ChannelInput = z
|
|
|
26
32
|
.optional()
|
|
27
33
|
.describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
|
|
28
34
|
const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
|
|
29
|
-
const INSTRUCTIONS = `earnrate picks which of the user's credit cards earns the most for a purchase. Its wallet lists the cards they hold by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
|
|
35
|
+
const INSTRUCTIONS = `earnrate picks which of the user's credit cards earns the most for a purchase. best_cards_for and simulate_spending compare every card earnrate knows and need no wallet, for questions like "what's the best grocery card?" or "which cards fit my spending?". Its wallet lists the cards they hold by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
|
|
30
36
|
|
|
31
37
|
If a tool says there's no wallet yet, ask the user which cards they have, find each with search_cards, and add it with add_card. add_card and update_card reply with what a card still needs, such as category picks, memberships like Amazon Prime, or activated bonus categories. Ask the user about those and set them with update_card or set_memberships.`;
|
|
32
38
|
const fail = (text) => ({
|
|
@@ -37,6 +43,7 @@ const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2
|
|
|
37
43
|
export function createEarnrateServer(deps) {
|
|
38
44
|
const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS });
|
|
39
45
|
const { catalog } = deps;
|
|
46
|
+
const noWallet = `There's no wallet yet (${deps.walletFile}), which is normal before the user adds a card. Ask the user which credit cards they have, find each one with search_cards, and add it with add_card. best_cards_for and simulate_spending work without a wallet.`;
|
|
40
47
|
// Read on every call, so edits to the wallet file apply without a restart.
|
|
41
48
|
async function wallet() {
|
|
42
49
|
let loaded;
|
|
@@ -45,7 +52,7 @@ export function createEarnrateServer(deps) {
|
|
|
45
52
|
}
|
|
46
53
|
catch (e) {
|
|
47
54
|
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
|
48
|
-
return
|
|
55
|
+
return noWallet;
|
|
49
56
|
}
|
|
50
57
|
return `Couldn't read the wallet at ${deps.walletFile}: ${e instanceof Error ? e.message : String(e)}`;
|
|
51
58
|
}
|
|
@@ -54,6 +61,16 @@ export function createEarnrateServer(deps) {
|
|
|
54
61
|
? `The wallet at ${deps.walletFile} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
|
|
55
62
|
: loaded;
|
|
56
63
|
}
|
|
64
|
+
// No wallet yet is the first-run state, not a failure: read-only tools
|
|
65
|
+
// answer from an empty wallet and say how to start one.
|
|
66
|
+
async function readableWallet() {
|
|
67
|
+
const w = await wallet();
|
|
68
|
+
if (typeof w !== 'string')
|
|
69
|
+
return { wallet: w, start: null };
|
|
70
|
+
return w === noWallet
|
|
71
|
+
? { wallet: { valuations: {}, memberships: [], cards: [] }, start: w }
|
|
72
|
+
: w;
|
|
73
|
+
}
|
|
57
74
|
// The caller's category is free and comes before the paid Claude fallback;
|
|
58
75
|
// neither ever overrides a merchant earnrate already knows.
|
|
59
76
|
async function resolve(merchant, channel, category) {
|
|
@@ -88,9 +105,10 @@ export function createEarnrateServer(deps) {
|
|
|
88
105
|
openWorldHint: deps.llm !== null,
|
|
89
106
|
},
|
|
90
107
|
}, async ({ merchant, amount, channel, date, category }) => {
|
|
91
|
-
const
|
|
92
|
-
if (typeof
|
|
93
|
-
return fail(
|
|
108
|
+
const read = await readableWallet();
|
|
109
|
+
if (typeof read === 'string')
|
|
110
|
+
return fail(read);
|
|
111
|
+
const w = read.wallet;
|
|
94
112
|
const purchase = {
|
|
95
113
|
merchant,
|
|
96
114
|
amount: amount ?? null,
|
|
@@ -100,6 +118,16 @@ export function createEarnrateServer(deps) {
|
|
|
100
118
|
};
|
|
101
119
|
const resolved = await resolve(merchant, purchase.channel, category ?? null);
|
|
102
120
|
const rec = recommend(catalog, w, purchase, resolved.merchant);
|
|
121
|
+
if (read.start !== null) {
|
|
122
|
+
const output = {
|
|
123
|
+
...rec,
|
|
124
|
+
warnings: [read.start, ...resolved.notices],
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: 'text', text: read.start }],
|
|
128
|
+
structuredContent: output,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
103
131
|
const output = {
|
|
104
132
|
...rec,
|
|
105
133
|
warnings: [...rec.warnings, ...resolved.notices],
|
|
@@ -142,10 +170,15 @@ export function createEarnrateServer(deps) {
|
|
|
142
170
|
outputSchema: WalletSummarySchema,
|
|
143
171
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
144
172
|
}, async () => {
|
|
145
|
-
const
|
|
146
|
-
if (typeof
|
|
147
|
-
return fail(
|
|
148
|
-
const summary = walletSummary(catalog,
|
|
173
|
+
const read = await readableWallet();
|
|
174
|
+
if (typeof read === 'string')
|
|
175
|
+
return fail(read);
|
|
176
|
+
const summary = walletSummary(catalog, read.wallet, deps.today());
|
|
177
|
+
if (read.start !== null)
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: 'text', text: read.start }],
|
|
180
|
+
structuredContent: summary,
|
|
181
|
+
};
|
|
149
182
|
const lines = summary.cards.map((c) => {
|
|
150
183
|
const caps = c.caps
|
|
151
184
|
.filter((cap) => cap.remaining !== null)
|
|
@@ -194,10 +227,10 @@ export function createEarnrateServer(deps) {
|
|
|
194
227
|
outputSchema: OfferComparisonSchema,
|
|
195
228
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
196
229
|
}, async (args) => {
|
|
197
|
-
const
|
|
198
|
-
if (typeof
|
|
199
|
-
return fail(
|
|
200
|
-
const comparison = compareOffers(catalog,
|
|
230
|
+
const read = await readableWallet();
|
|
231
|
+
if (typeof read === 'string')
|
|
232
|
+
return fail(read);
|
|
233
|
+
const comparison = compareOffers(catalog, read.wallet, {
|
|
201
234
|
kind: args.kind ?? 'any',
|
|
202
235
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
203
236
|
business: args.business ?? false,
|
|
@@ -211,6 +244,176 @@ export function createEarnrateServer(deps) {
|
|
|
211
244
|
structuredContent: comparison,
|
|
212
245
|
};
|
|
213
246
|
});
|
|
247
|
+
const CatalogCategory = z
|
|
248
|
+
.enum(ids)
|
|
249
|
+
.describe(`A spending category: ${catalog.categories.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
|
|
250
|
+
const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
|
|
251
|
+
const membershipIds = knownMemberships(catalog);
|
|
252
|
+
const CatalogFilters = {
|
|
253
|
+
kind: z
|
|
254
|
+
.enum(['any', 'cash', 'points'])
|
|
255
|
+
.optional()
|
|
256
|
+
.describe('cash: cash back cards only. points: points and miles cards only.'),
|
|
257
|
+
noAnnualFee: z
|
|
258
|
+
.boolean()
|
|
259
|
+
.optional()
|
|
260
|
+
.describe('Only cards with no annual fee.'),
|
|
261
|
+
business: z
|
|
262
|
+
.boolean()
|
|
263
|
+
.optional()
|
|
264
|
+
.describe('Business cards instead of personal ones.'),
|
|
265
|
+
issuer: z.enum(issuers).optional().describe("Only this issuer's cards."),
|
|
266
|
+
memberships: z
|
|
267
|
+
.array(z.enum(membershipIds))
|
|
268
|
+
.optional()
|
|
269
|
+
.describe("Memberships the user has, such as amazon-prime. Rates that need one they don't have are listed separately."),
|
|
270
|
+
valuation: z
|
|
271
|
+
.enum(['flat', 'points-heavy'])
|
|
272
|
+
.optional()
|
|
273
|
+
.describe('flat (default): points at their cash-out value, about 1¢. points-heavy: Amex MR and Chase UR at 1.7¢, for people who transfer points to airlines and hotels.'),
|
|
274
|
+
};
|
|
275
|
+
const settings = (args) => ({
|
|
276
|
+
filters: {
|
|
277
|
+
...ALL_PERSONAL_CARDS,
|
|
278
|
+
kind: args.kind ?? 'any',
|
|
279
|
+
noAnnualFee: args.noAnnualFee ?? false,
|
|
280
|
+
business: args.business ?? false,
|
|
281
|
+
issuer: args.issuer ?? null,
|
|
282
|
+
},
|
|
283
|
+
memberships: args.memberships ?? [],
|
|
284
|
+
valuations: args.valuation ? VALUATION_PROFILES[args.valuation] : {},
|
|
285
|
+
});
|
|
286
|
+
server.registerTool('best_cards_for', {
|
|
287
|
+
title: 'Best cards for a purchase',
|
|
288
|
+
description: 'Ranks every card earnrate knows, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
|
|
289
|
+
inputSchema: z.object({
|
|
290
|
+
merchant: Merchant.optional(),
|
|
291
|
+
category: CatalogCategory.optional(),
|
|
292
|
+
amount: z
|
|
293
|
+
.number()
|
|
294
|
+
.positive()
|
|
295
|
+
.optional()
|
|
296
|
+
.describe('Purchase amount in US dollars, if known.'),
|
|
297
|
+
channel: ChannelInput,
|
|
298
|
+
...CatalogFilters,
|
|
299
|
+
}),
|
|
300
|
+
outputSchema: ExplorationSchema,
|
|
301
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
302
|
+
}, (args) => {
|
|
303
|
+
if (args.merchant === undefined && args.category === undefined)
|
|
304
|
+
return fail('Give a merchant or a category.');
|
|
305
|
+
const result = exploreCards(catalog, {
|
|
306
|
+
merchant: args.merchant ?? null,
|
|
307
|
+
category: args.merchant === undefined ? (args.category ?? null) : null,
|
|
308
|
+
amount: args.amount ?? null,
|
|
309
|
+
channel: args.channel ?? null,
|
|
310
|
+
date: deps.today(),
|
|
311
|
+
}, settings(args));
|
|
312
|
+
const trimmed = { ...result, ranked: result.ranked.slice(0, 10) };
|
|
313
|
+
return {
|
|
314
|
+
content: [{ type: 'text', text: formatExploration(trimmed, true) }],
|
|
315
|
+
structuredContent: trimmed,
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
server.registerTool('simulate_spending', {
|
|
319
|
+
title: 'Simulate a year of spending',
|
|
320
|
+
description: 'Spends the user\'s monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees. With compareToWallet, it starts from the user\'s wallet and ranks the cards that would add the most to it. Use it for "which card fits my spending?" or "is it worth adding a card?". Ask for rough monthly amounts if the user hasn\'t given them. It leaves out welcome bonuses.',
|
|
321
|
+
inputSchema: z.object({
|
|
322
|
+
spending: z
|
|
323
|
+
.array(z.object({
|
|
324
|
+
merchant: Merchant.optional(),
|
|
325
|
+
category: CatalogCategory.optional(),
|
|
326
|
+
monthly: z.number().positive().describe('US dollars a month.'),
|
|
327
|
+
}))
|
|
328
|
+
.min(1)
|
|
329
|
+
.describe('Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco).'),
|
|
330
|
+
compareToWallet: z
|
|
331
|
+
.boolean()
|
|
332
|
+
.optional()
|
|
333
|
+
.describe("Rank the cards that would add the most to the user's wallet."),
|
|
334
|
+
...CatalogFilters,
|
|
335
|
+
}),
|
|
336
|
+
outputSchema: SpendingSimulationSchema,
|
|
337
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
338
|
+
}, async (args) => {
|
|
339
|
+
if (args.spending.some((l) => l.merchant === undefined && l.category === undefined))
|
|
340
|
+
return fail('Each spending line needs a merchant or a category.');
|
|
341
|
+
const lines = resolveSpendLines(catalog, args.spending.map((l) => ({
|
|
342
|
+
merchant: l.merchant ?? null,
|
|
343
|
+
category: l.merchant === undefined ? (l.category ?? null) : null,
|
|
344
|
+
monthly: l.monthly,
|
|
345
|
+
})));
|
|
346
|
+
const unknown = lines.filter((l) => l.merchant.source === 'none');
|
|
347
|
+
if (unknown.length > 0)
|
|
348
|
+
return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
|
|
349
|
+
const options = { ...settings(args), start: deps.today() };
|
|
350
|
+
const read = args.compareToWallet ? await readableWallet() : null;
|
|
351
|
+
if (typeof read === 'string')
|
|
352
|
+
return fail(read);
|
|
353
|
+
if (read && read.start === null) {
|
|
354
|
+
const w = read.wallet;
|
|
355
|
+
const valued = args.valuation
|
|
356
|
+
? {
|
|
357
|
+
...w,
|
|
358
|
+
valuations: {
|
|
359
|
+
...w.valuations,
|
|
360
|
+
...VALUATION_PROFILES[args.valuation],
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
: w;
|
|
364
|
+
const result = simulateAdditions(catalog, valued, lines, options);
|
|
365
|
+
return {
|
|
366
|
+
content: [
|
|
367
|
+
{ type: 'text', text: formatAdditions(result, catalog, false) },
|
|
368
|
+
],
|
|
369
|
+
structuredContent: {
|
|
370
|
+
singles: [],
|
|
371
|
+
pairs: [],
|
|
372
|
+
current: result.current,
|
|
373
|
+
additions: result.additions
|
|
374
|
+
.slice(0, 10)
|
|
375
|
+
.map(({ simulation: _, ...a }) => a),
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const result = simulateCatalog(catalog, lines, options);
|
|
380
|
+
const text = formatCatalogSimulation(result, catalog, false);
|
|
381
|
+
return {
|
|
382
|
+
content: [
|
|
383
|
+
{
|
|
384
|
+
type: 'text',
|
|
385
|
+
text: read?.start ? `${read.start}\n\n${text}` : text,
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
structuredContent: {
|
|
389
|
+
singles: result.singles.slice(0, 10),
|
|
390
|
+
pairs: result.pairs.slice(0, 5),
|
|
391
|
+
current: null,
|
|
392
|
+
additions: [],
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
server.registerTool('cheat_sheet', {
|
|
397
|
+
title: 'Cheat sheet',
|
|
398
|
+
description: "The card in the user's wallet to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else, with caps, picks and activations as the wallet has them. Use it when the user asks which card to use for what, or wants a quick reference for the quarter. For one specific store, call best_card_for_purchase instead. Pass on the notes: they say what the wallet still needs, like this quarter's picks.",
|
|
399
|
+
inputSchema: z.object({ date: DateInput }),
|
|
400
|
+
outputSchema: CheatSheetSchema,
|
|
401
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
402
|
+
}, async (args) => {
|
|
403
|
+
const read = await readableWallet();
|
|
404
|
+
if (typeof read === 'string')
|
|
405
|
+
return fail(read);
|
|
406
|
+
const sheet = cheatSheet(catalog, read.wallet, args.date ?? deps.today());
|
|
407
|
+
if (read.start !== null)
|
|
408
|
+
return {
|
|
409
|
+
content: [{ type: 'text', text: read.start }],
|
|
410
|
+
structuredContent: { ...sheet, notes: [read.start] },
|
|
411
|
+
};
|
|
412
|
+
return {
|
|
413
|
+
content: [{ type: 'text', text: formatCheatSheet(sheet) }],
|
|
414
|
+
structuredContent: sheet,
|
|
415
|
+
};
|
|
416
|
+
});
|
|
214
417
|
server.registerTool('record_purchase', {
|
|
215
418
|
title: 'Record a purchase',
|
|
216
419
|
description: "Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's wallet file. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written.",
|
|
@@ -441,14 +644,13 @@ export function createEarnrateServer(deps) {
|
|
|
441
644
|
openWorldHint: false,
|
|
442
645
|
},
|
|
443
646
|
}, ({ cardId }) => withWalletLock(deps.walletFile, async () => changed(await removeCard(deps.walletFile, catalog, cardId), `Removed ${cardName(cardId)} (${cardId}).`, false)));
|
|
444
|
-
const memberships = knownMemberships(catalog);
|
|
445
647
|
server.registerTool('set_memberships', {
|
|
446
648
|
title: 'Set memberships',
|
|
447
649
|
description: "Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier. Replaces the whole list, so include the ones to keep.",
|
|
448
650
|
inputSchema: z.object({
|
|
449
651
|
memberships: z
|
|
450
|
-
.array(z.enum(
|
|
451
|
-
.describe(`Every membership the user has. Known: ${
|
|
652
|
+
.array(z.enum(membershipIds))
|
|
653
|
+
.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`),
|
|
452
654
|
}),
|
|
453
655
|
outputSchema: WalletChangeSchema,
|
|
454
656
|
annotations: {
|
package/dist/schema/card.js
CHANGED
|
@@ -133,6 +133,8 @@ export const CardSchema = z
|
|
|
133
133
|
source_urls: z.array(z.url()).min(1),
|
|
134
134
|
base_rate: z.number().nonnegative(),
|
|
135
135
|
business: z.boolean().default(false),
|
|
136
|
+
// When the issuer stopped taking applications; cardholders keep earning.
|
|
137
|
+
applications_closed: IsoDate.optional(),
|
|
136
138
|
annual_fee: z.number().nonnegative().optional(), // dollars
|
|
137
139
|
// When the first year differs, usually a waived fee.
|
|
138
140
|
first_year_fee: z.number().nonnegative().optional(),
|
package/dist/schema/category.js
CHANGED
|
@@ -6,6 +6,8 @@ export const CategorySchema = z.strictObject({
|
|
|
6
6
|
// Typical MCCs, as hints for explanations and the LLM fallback. Matching is
|
|
7
7
|
// by category, not by these.
|
|
8
8
|
mccs: z.array(Mcc).default([]),
|
|
9
|
+
// Everyday words people search by ("groceries"), besides the name.
|
|
10
|
+
aliases: z.array(z.string().min(1)).default([]),
|
|
9
11
|
note: z.string().optional(),
|
|
10
12
|
});
|
|
11
13
|
export const CategoriesFileSchema = z.array(CategorySchema).min(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "earnrate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Picks the best credit card from your wallet for a merchant, and explains why",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"credit cards",
|
|
@@ -40,11 +40,15 @@
|
|
|
40
40
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
41
41
|
"prepack": "yarn build",
|
|
42
42
|
"smoke:pack": "node scripts/smoke-pack.ts",
|
|
43
|
-
"
|
|
43
|
+
"mcpb:pack": "node scripts/build-mcpb.ts",
|
|
44
|
+
"smoke:mcpb": "node scripts/smoke-mcpb.ts",
|
|
45
|
+
"site:build": "vite build site && node scripts/build-pages.ts",
|
|
46
|
+
"site:dev": "vite site",
|
|
47
|
+
"site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy",
|
|
44
48
|
"test": "vitest run",
|
|
45
49
|
"test:watch": "vitest",
|
|
46
50
|
"coverage": "vitest run --coverage --coverage.include=src/**",
|
|
47
|
-
"typecheck": "tsc",
|
|
51
|
+
"typecheck": "tsc && tsc -p site",
|
|
48
52
|
"lint": "eslint",
|
|
49
53
|
"format": "prettier --write .",
|
|
50
54
|
"format:check": "prettier --check .",
|
|
@@ -57,6 +61,7 @@
|
|
|
57
61
|
"zod": "^4.6.7"
|
|
58
62
|
},
|
|
59
63
|
"devDependencies": {
|
|
64
|
+
"@anthropic-ai/mcpb": "^2.1.2",
|
|
60
65
|
"@eslint/js": "^10.0.1",
|
|
61
66
|
"@modelcontextprotocol/client": "2.3.1",
|
|
62
67
|
"@types/node": "^24",
|
|
@@ -64,6 +69,7 @@
|
|
|
64
69
|
"eslint": "^10.13.0",
|
|
65
70
|
"eslint-config-prettier": "^10.1.8",
|
|
66
71
|
"playwright": "1.64.1",
|
|
72
|
+
"preact": "^10.29.8",
|
|
67
73
|
"prettier": "^3.9.10",
|
|
68
74
|
"typescript": "~6.0.3",
|
|
69
75
|
"typescript-eslint": "^8.72.0",
|